From e27b2320b4d9d238bcffb0d623e23564baef977a Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Wed, 27 May 2026 00:54:32 -0700 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=8A=9F=E8=83=BD(#79):=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E4=BF=83=E9=94=80=E7=AE=A1=E7=90=86=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=B9=B6=E6=94=AF=E6=8C=81=E6=95=B0=E9=87=8F=E9=85=8D=E4=BB=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- apis/admin/promo.api | 121 +++++++++ apis/types.api | 35 +++ .../database/02154_promo_admin_api.down.sql | 3 + .../database/02154_promo_admin_api.up.sql | 42 +++ .../handler/admin/promo/createRuleHandler.go | 23 ++ .../handler/admin/promo/deletePriceHandler.go | 26 ++ .../handler/admin/promo/deleteRuleHandler.go | 26 ++ .../admin/promo/getPriceListHandler.go | 23 ++ .../admin/promo/getRuleDetailHandler.go | 26 ++ .../handler/admin/promo/getRuleListHandler.go | 23 ++ .../admin/promo/getUsageListHandler.go | 23 ++ .../handler/admin/promo/setPriceHandler.go | 23 ++ .../handler/admin/promo/updateRuleHandler.go | 27 ++ internal/handler/routes.go | 33 +++ internal/logic/admin/promo/createRuleLogic.go | 58 +++++ .../logic/admin/promo/deletePriceLogic.go | 41 +++ internal/logic/admin/promo/deleteRuleLogic.go | 36 +++ .../logic/admin/promo/getPriceListLogic.go | 41 +++ .../logic/admin/promo/getRuleDetailLogic.go | 35 +++ .../logic/admin/promo/getRuleListLogic.go | 41 +++ .../logic/admin/promo/getUsageListLogic.go | 49 ++++ internal/logic/admin/promo/setPriceLogic.go | 67 +++++ internal/logic/admin/promo/tool.go | 166 ++++++++++++ internal/logic/admin/promo/updateRuleLogic.go | 63 +++++ internal/model/promo/default.go | 239 ++++++++++++++++++ internal/model/promo/promo.go | 53 ++++ internal/svc/serviceContext.go | 5 +- internal/types/types.go | 115 +++++++++ 28 files changed, 1462 insertions(+), 1 deletion(-) create mode 100644 apis/admin/promo.api create mode 100644 initialize/migrate/database/02154_promo_admin_api.down.sql create mode 100644 initialize/migrate/database/02154_promo_admin_api.up.sql create mode 100644 internal/handler/admin/promo/createRuleHandler.go create mode 100644 internal/handler/admin/promo/deletePriceHandler.go create mode 100644 internal/handler/admin/promo/deleteRuleHandler.go create mode 100644 internal/handler/admin/promo/getPriceListHandler.go create mode 100644 internal/handler/admin/promo/getRuleDetailHandler.go create mode 100644 internal/handler/admin/promo/getRuleListHandler.go create mode 100644 internal/handler/admin/promo/getUsageListHandler.go create mode 100644 internal/handler/admin/promo/setPriceHandler.go create mode 100644 internal/handler/admin/promo/updateRuleHandler.go create mode 100644 internal/logic/admin/promo/createRuleLogic.go create mode 100644 internal/logic/admin/promo/deletePriceLogic.go create mode 100644 internal/logic/admin/promo/deleteRuleLogic.go create mode 100644 internal/logic/admin/promo/getPriceListLogic.go create mode 100644 internal/logic/admin/promo/getRuleDetailLogic.go create mode 100644 internal/logic/admin/promo/getRuleListLogic.go create mode 100644 internal/logic/admin/promo/getUsageListLogic.go create mode 100644 internal/logic/admin/promo/setPriceLogic.go create mode 100644 internal/logic/admin/promo/tool.go create mode 100644 internal/logic/admin/promo/updateRuleLogic.go create mode 100644 internal/model/promo/default.go create mode 100644 internal/model/promo/promo.go diff --git a/apis/admin/promo.api b/apis/admin/promo.api new file mode 100644 index 0000000..ab456e9 --- /dev/null +++ b/apis/admin/promo.api @@ -0,0 +1,121 @@ +syntax = "v1" + +info ( + title: "promo admin API" + desc: "API for ppanel" + author: "Tension" + email: "tension@ppanel.com" + version: "0.0.1" +) + +import "../types.api" + +type ( + CreatePromoRuleRequest { + Name string `json:"name" validate:"required,max=100"` + Type string `json:"type" validate:"required,oneof=new_user inactive_user campaign"` + Params map[string]interface{} `json:"params"` + Priority int64 `json:"priority" validate:"gte=0"` + Enabled *bool `json:"enabled"` + StartTime *int64 `json:"start_time"` + EndTime *int64 `json:"end_time"` + } + UpdatePromoRuleRequest { + Id int64 `uri:"id" validate:"required,gt=0"` + Name string `json:"name" validate:"required,max=100"` + Type string `json:"type" validate:"required,oneof=new_user inactive_user campaign"` + Params map[string]interface{} `json:"params"` + Priority int64 `json:"priority" validate:"gte=0"` + Enabled *bool `json:"enabled"` + StartTime *int64 `json:"start_time"` + EndTime *int64 `json:"end_time"` + } + GetPromoRuleDetailRequest { + Id int64 `uri:"id" validate:"required,gt=0"` + } + DeletePromoRuleRequest { + Id int64 `uri:"id" validate:"required,gt=0"` + } + GetPromoRuleListRequest { + Page int64 `form:"page" validate:"required"` + Size int64 `form:"size" validate:"required"` + Type string `form:"type" validate:"omitempty,oneof=new_user inactive_user campaign"` + Enabled *bool `form:"enabled"` + Search string `form:"search,omitempty"` + } + GetPromoRuleListResponse { + Total int64 `json:"total"` + List []PromoRule `json:"list"` + } + SetPromoPriceRequest { + PromoRuleId int64 `json:"promo_rule_id" validate:"required,gt=0"` + Items []PromoPriceItem `json:"items" validate:"required,dive"` + } + GetPromoPriceListRequest { + PromoRuleId int64 `form:"promo_rule_id" validate:"required,gt=0"` + Page int64 `form:"page" validate:"required"` + Size int64 `form:"size" validate:"required"` + } + GetPromoPriceListResponse { + Total int64 `json:"total"` + List []PromoPrice `json:"list"` + } + DeletePromoPriceRequest { + Id int64 `uri:"id" validate:"required,gt=0"` + } + GetPromoUsageListRequest { + Page int64 `form:"page" validate:"required"` + Size int64 `form:"size" validate:"required"` + RuleId int64 `form:"rule_id,omitempty"` + UserId int64 `form:"user_id,omitempty"` + SubscribeId int64 `form:"subscribe_id,omitempty"` + OrderNo string `form:"order_no,omitempty"` + } + GetPromoUsageListResponse { + Total int64 `json:"total"` + List []PromoUsage `json:"list"` + } +) + +@server ( + prefix: v1/admin/promo + group: admin/promo + middleware: AuthMiddleware +) +service ppanel { + @doc "Create promo rule" + @handler CreateRule + post /rule (CreatePromoRuleRequest) returns (PromoRule) + + @doc "Get promo rule list" + @handler GetRuleList + get /rule/list (GetPromoRuleListRequest) returns (GetPromoRuleListResponse) + + @doc "Get promo rule detail" + @handler GetRuleDetail + get /rule/:id (GetPromoRuleDetailRequest) returns (PromoRule) + + @doc "Update promo rule" + @handler UpdateRule + put /rule/:id (UpdatePromoRuleRequest) returns (PromoRule) + + @doc "Delete promo rule" + @handler DeleteRule + delete /rule/:id (DeletePromoRuleRequest) + + @doc "Set promo prices" + @handler SetPrice + post /price (SetPromoPriceRequest) + + @doc "Get promo price list" + @handler GetPriceList + get /price/list (GetPromoPriceListRequest) returns (GetPromoPriceListResponse) + + @doc "Delete promo price" + @handler DeletePrice + delete /price/:id (DeletePromoPriceRequest) + + @doc "Get promo usage list" + @handler GetUsageList + get /usage/list (GetPromoUsageListRequest) returns (GetPromoUsageListResponse) +} diff --git a/apis/types.api b/apis/types.api index 27a5716..836a358 100644 --- a/apis/types.api +++ b/apis/types.api @@ -230,6 +230,41 @@ type ( Discount float64 `json:"discount"` MapApple string `json:"map_apple"` } + PromoPrice { + Id int64 `json:"id"` + SubscribeId int64 `json:"subscribe_id"` + PromoRuleId int64 `json:"promo_rule_id"` + Quantity int64 `json:"quantity"` + PromoPrice int64 `json:"promo_price"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + } + PromoPriceItem { + SubscribeId int64 `json:"subscribe_id" validate:"required,gt=0"` + Quantity int64 `json:"quantity" validate:"required,gt=0,lte=1000"` + PromoPrice int64 `json:"promo_price" validate:"required,gt=0"` + } + PromoRule { + Id int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Params map[string]interface{} `json:"params"` + Priority int64 `json:"priority"` + Enabled bool `json:"enabled"` + StartTime *int64 `json:"start_time"` + EndTime *int64 `json:"end_time"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + } + PromoUsage { + Id int64 `json:"id"` + UserId int64 `json:"user_id"` + PromoRuleId int64 `json:"promo_rule_id"` + SubscribeId int64 `json:"subscribe_id"` + OrderNo string `json:"order_no"` + PromoPrice int64 `json:"promo_price"` + CreatedAt int64 `json:"created_at"` + } TrafficLimit { StatType string `json:"stat_type"` StatValue int64 `json:"stat_value"` diff --git a/initialize/migrate/database/02154_promo_admin_api.down.sql b/initialize/migrate/database/02154_promo_admin_api.down.sql new file mode 100644 index 0000000..a6103d3 --- /dev/null +++ b/initialize/migrate/database/02154_promo_admin_api.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS `promo_usage`; +DROP TABLE IF EXISTS `subscribe_promo`; +DROP TABLE IF EXISTS `promo_rule`; diff --git a/initialize/migrate/database/02154_promo_admin_api.up.sql b/initialize/migrate/database/02154_promo_admin_api.up.sql new file mode 100644 index 0000000..e91709a --- /dev/null +++ b/initialize/migrate/database/02154_promo_admin_api.up.sql @@ -0,0 +1,42 @@ +CREATE TABLE IF NOT EXISTS `promo_rule` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '规则名称', + `type` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '规则类型:new_user / inactive_user / campaign', + `params` JSON NOT NULL COMMENT '类型专属参数', + `priority` INT NOT NULL DEFAULT 0 COMMENT '优先级,数值越大越优先匹配', + `enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + `start_time` DATETIME DEFAULT NULL COMMENT '生效开始时间', + `end_time` DATETIME DEFAULT NULL COMMENT '生效结束时间', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME DEFAULT NULL COMMENT '软删除时间', + PRIMARY KEY (`id`), + KEY `idx_enabled_priority` (`enabled`, `priority` DESC), + KEY `idx_deleted_at` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='促销规则表'; + +CREATE TABLE IF NOT EXISTS `subscribe_promo` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `subscribe_id` BIGINT UNSIGNED NOT NULL COMMENT '套餐规格 ID', + `quantity` INT NOT NULL DEFAULT 0 COMMENT '购买数量', + `promo_rule_id` BIGINT UNSIGNED NOT NULL COMMENT '促销规则 ID', + `promo_price` BIGINT NOT NULL DEFAULT 0 COMMENT '优惠价(分)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_subscribe_qty_rule` (`subscribe_id`, `quantity`, `promo_rule_id`), + KEY `idx_promo_rule_id` (`promo_rule_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规格促销价表'; + +CREATE TABLE IF NOT EXISTS `promo_usage` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID', + `promo_rule_id` BIGINT UNSIGNED NOT NULL COMMENT '促销规则 ID', + `subscribe_id` BIGINT UNSIGNED NOT NULL COMMENT '套餐规格 ID', + `order_no` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '关联订单号', + `promo_price` BIGINT NOT NULL DEFAULT 0 COMMENT '使用时的促销单价(分)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_user_rule` (`user_id`, `promo_rule_id`), + KEY `idx_order_no` (`order_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='促销使用记录表'; diff --git a/internal/handler/admin/promo/createRuleHandler.go b/internal/handler/admin/promo/createRuleHandler.go new file mode 100644 index 0000000..ca783df --- /dev/null +++ b/internal/handler/admin/promo/createRuleHandler.go @@ -0,0 +1,23 @@ +package promo + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +func CreateRuleHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.CreatePromoRuleRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := promo.NewCreateRuleLogic(c.Request.Context(), svcCtx) + resp, err := l.CreateRule(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/admin/promo/deletePriceHandler.go b/internal/handler/admin/promo/deletePriceHandler.go new file mode 100644 index 0000000..6665cc2 --- /dev/null +++ b/internal/handler/admin/promo/deletePriceHandler.go @@ -0,0 +1,26 @@ +package promo + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +func DeletePriceHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.DeletePromoPriceRequest + if err := c.ShouldBindUri(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := promo.NewDeletePriceLogic(c.Request.Context(), svcCtx) + err := l.DeletePrice(&req) + result.HttpResult(c, nil, err) + } +} diff --git a/internal/handler/admin/promo/deleteRuleHandler.go b/internal/handler/admin/promo/deleteRuleHandler.go new file mode 100644 index 0000000..9c4c497 --- /dev/null +++ b/internal/handler/admin/promo/deleteRuleHandler.go @@ -0,0 +1,26 @@ +package promo + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +func DeleteRuleHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.DeletePromoRuleRequest + if err := c.ShouldBindUri(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := promo.NewDeleteRuleLogic(c.Request.Context(), svcCtx) + err := l.DeleteRule(&req) + result.HttpResult(c, nil, err) + } +} diff --git a/internal/handler/admin/promo/getPriceListHandler.go b/internal/handler/admin/promo/getPriceListHandler.go new file mode 100644 index 0000000..052dedd --- /dev/null +++ b/internal/handler/admin/promo/getPriceListHandler.go @@ -0,0 +1,23 @@ +package promo + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +func GetPriceListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.GetPromoPriceListRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := promo.NewGetPriceListLogic(c.Request.Context(), svcCtx) + resp, err := l.GetPriceList(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/admin/promo/getRuleDetailHandler.go b/internal/handler/admin/promo/getRuleDetailHandler.go new file mode 100644 index 0000000..4ed036d --- /dev/null +++ b/internal/handler/admin/promo/getRuleDetailHandler.go @@ -0,0 +1,26 @@ +package promo + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +func GetRuleDetailHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.GetPromoRuleDetailRequest + if err := c.ShouldBindUri(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := promo.NewGetRuleDetailLogic(c.Request.Context(), svcCtx) + resp, err := l.GetRuleDetail(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/admin/promo/getRuleListHandler.go b/internal/handler/admin/promo/getRuleListHandler.go new file mode 100644 index 0000000..d189231 --- /dev/null +++ b/internal/handler/admin/promo/getRuleListHandler.go @@ -0,0 +1,23 @@ +package promo + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +func GetRuleListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.GetPromoRuleListRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := promo.NewGetRuleListLogic(c.Request.Context(), svcCtx) + resp, err := l.GetRuleList(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/admin/promo/getUsageListHandler.go b/internal/handler/admin/promo/getUsageListHandler.go new file mode 100644 index 0000000..1933274 --- /dev/null +++ b/internal/handler/admin/promo/getUsageListHandler.go @@ -0,0 +1,23 @@ +package promo + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +func GetUsageListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.GetPromoUsageListRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := promo.NewGetUsageListLogic(c.Request.Context(), svcCtx) + resp, err := l.GetUsageList(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/admin/promo/setPriceHandler.go b/internal/handler/admin/promo/setPriceHandler.go new file mode 100644 index 0000000..006adbc --- /dev/null +++ b/internal/handler/admin/promo/setPriceHandler.go @@ -0,0 +1,23 @@ +package promo + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +func SetPriceHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.SetPromoPriceRequest + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := promo.NewSetPriceLogic(c.Request.Context(), svcCtx) + err := l.SetPrice(&req) + result.HttpResult(c, nil, err) + } +} diff --git a/internal/handler/admin/promo/updateRuleHandler.go b/internal/handler/admin/promo/updateRuleHandler.go new file mode 100644 index 0000000..23b3255 --- /dev/null +++ b/internal/handler/admin/promo/updateRuleHandler.go @@ -0,0 +1,27 @@ +package promo + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +func UpdateRuleHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.UpdatePromoRuleRequest + if err := c.ShouldBindUri(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + _ = c.ShouldBind(&req) + if err := svcCtx.Validate(&req); err != nil { + result.ParamErrorResult(c, err) + return + } + l := promo.NewUpdateRuleLogic(c.Request.Context(), svcCtx) + resp, err := l.UpdateRule(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 7b61ba9..c55af69 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -17,6 +17,7 @@ import ( adminMarketing "github.com/perfect-panel/server/internal/handler/admin/marketing" adminOrder "github.com/perfect-panel/server/internal/handler/admin/order" adminPayment "github.com/perfect-panel/server/internal/handler/admin/payment" + adminPromo "github.com/perfect-panel/server/internal/handler/admin/promo" adminRedemption "github.com/perfect-panel/server/internal/handler/admin/redemption" adminServer "github.com/perfect-panel/server/internal/handler/admin/server" adminSubscribe "github.com/perfect-panel/server/internal/handler/admin/subscribe" @@ -374,6 +375,38 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { adminPaymentGroupRouter.GET("/platform", adminPayment.GetPaymentPlatformHandler(serverCtx)) } + adminPromoGroupRouter := router.Group("/v1/admin/promo") + adminPromoGroupRouter.Use(middleware.AuthMiddleware(serverCtx)) + + { + // Create promo rule + adminPromoGroupRouter.POST("/rule", adminPromo.CreateRuleHandler(serverCtx)) + + // Get promo rule list + adminPromoGroupRouter.GET("/rule/list", adminPromo.GetRuleListHandler(serverCtx)) + + // Get promo rule detail + adminPromoGroupRouter.GET("/rule/:id", adminPromo.GetRuleDetailHandler(serverCtx)) + + // Update promo rule + adminPromoGroupRouter.PUT("/rule/:id", adminPromo.UpdateRuleHandler(serverCtx)) + + // Delete promo rule + adminPromoGroupRouter.DELETE("/rule/:id", adminPromo.DeleteRuleHandler(serverCtx)) + + // Set promo prices + adminPromoGroupRouter.POST("/price", adminPromo.SetPriceHandler(serverCtx)) + + // Get promo price list + adminPromoGroupRouter.GET("/price/list", adminPromo.GetPriceListHandler(serverCtx)) + + // Delete promo price + adminPromoGroupRouter.DELETE("/price/:id", adminPromo.DeletePriceHandler(serverCtx)) + + // Get promo usage list + adminPromoGroupRouter.GET("/usage/list", adminPromo.GetUsageListHandler(serverCtx)) + } + adminRedemptionGroupRouter := router.Group("/v1/admin/redemption") adminRedemptionGroupRouter.Use(middleware.AuthMiddleware(serverCtx)) diff --git a/internal/logic/admin/promo/createRuleLogic.go b/internal/logic/admin/promo/createRuleLogic.go new file mode 100644 index 0000000..ceefdb8 --- /dev/null +++ b/internal/logic/admin/promo/createRuleLogic.go @@ -0,0 +1,58 @@ +package promo + +import ( + "context" + + promomodel "github.com/perfect-panel/server/internal/model/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type CreateRuleLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewCreateRuleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateRuleLogic { + return &CreateRuleLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *CreateRuleLogic) CreateRule(req *types.CreatePromoRuleRequest) (*types.PromoRule, error) { + if err := validateRuleInput(req.Type, req.Params, req.Priority, req.StartTime, req.EndTime); err != nil { + return nil, err + } + params, err := paramsToString(req.Params) + if err != nil { + return nil, err + } + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + rule := &promomodel.Rule{ + Name: req.Name, + Type: req.Type, + Params: params, + Priority: req.Priority, + Enabled: &enabled, + StartTime: unixPtrToTimePtr(req.StartTime), + EndTime: unixPtrToTimePtr(req.EndTime), + } + if err := l.svcCtx.PromoModel.InsertRule(l.ctx, rule); err != nil { + l.Errorw("[CreatePromoRule] Database Error", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create promo rule error: %v", err.Error()) + } + if err := l.svcCtx.Redis.Del(l.ctx, ruleCacheKey).Err(); err != nil { + l.Errorw("[CreatePromoRule] Delete Cache Error", logger.Field("error", err.Error())) + } + resp := convertRule(rule) + return &resp, nil +} diff --git a/internal/logic/admin/promo/deletePriceLogic.go b/internal/logic/admin/promo/deletePriceLogic.go new file mode 100644 index 0000000..9964d50 --- /dev/null +++ b/internal/logic/admin/promo/deletePriceLogic.go @@ -0,0 +1,41 @@ +package promo + +import ( + "context" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type DeletePriceLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDeletePriceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeletePriceLogic { + return &DeletePriceLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *DeletePriceLogic) DeletePrice(req *types.DeletePromoPriceRequest) error { + price, err := l.svcCtx.PromoModel.FindPrice(l.ctx, req.Id) + if err != nil { + l.Errorw("[DeletePromoPrice] Find Price Error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find promo price error: %v", err.Error()) + } + if err := l.svcCtx.PromoModel.DeletePrice(l.ctx, req.Id); err != nil { + l.Errorw("[DeletePromoPrice] Database Error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "delete promo price error: %v", err.Error()) + } + if err := l.svcCtx.Redis.Del(l.ctx, subscribePromoCacheKey(price.SubscribeId)).Err(); err != nil { + l.Errorw("[DeletePromoPrice] Delete Cache Error", logger.Field("error", err.Error())) + } + return nil +} diff --git a/internal/logic/admin/promo/deleteRuleLogic.go b/internal/logic/admin/promo/deleteRuleLogic.go new file mode 100644 index 0000000..c09a574 --- /dev/null +++ b/internal/logic/admin/promo/deleteRuleLogic.go @@ -0,0 +1,36 @@ +package promo + +import ( + "context" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type DeleteRuleLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDeleteRuleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteRuleLogic { + return &DeleteRuleLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *DeleteRuleLogic) DeleteRule(req *types.DeletePromoRuleRequest) error { + if err := l.svcCtx.PromoModel.DeleteRule(l.ctx, req.Id); err != nil { + l.Errorw("[DeletePromoRule] Database Error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "delete promo rule error: %v", err.Error()) + } + if err := l.svcCtx.Redis.Del(l.ctx, ruleCacheKey).Err(); err != nil { + l.Errorw("[DeletePromoRule] Delete Cache Error", logger.Field("error", err.Error())) + } + return nil +} diff --git a/internal/logic/admin/promo/getPriceListLogic.go b/internal/logic/admin/promo/getPriceListLogic.go new file mode 100644 index 0000000..e02b043 --- /dev/null +++ b/internal/logic/admin/promo/getPriceListLogic.go @@ -0,0 +1,41 @@ +package promo + +import ( + "context" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type GetPriceListLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetPriceListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPriceListLogic { + return &GetPriceListLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetPriceListLogic) GetPriceList(req *types.GetPromoPriceListRequest) (*types.GetPromoPriceListResponse, error) { + total, list, err := l.svcCtx.PromoModel.QueryPriceList(l.ctx, req.PromoRuleId, int(req.Page), int(req.Size)) + if err != nil { + l.Errorw("[GetPromoPriceList] Database Error", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "get promo price list error: %v", err.Error()) + } + resp := &types.GetPromoPriceListResponse{ + Total: total, + List: make([]types.PromoPrice, 0, len(list)), + } + for _, item := range list { + resp.List = append(resp.List, convertPrice(item)) + } + return resp, nil +} diff --git a/internal/logic/admin/promo/getRuleDetailLogic.go b/internal/logic/admin/promo/getRuleDetailLogic.go new file mode 100644 index 0000000..19e9112 --- /dev/null +++ b/internal/logic/admin/promo/getRuleDetailLogic.go @@ -0,0 +1,35 @@ +package promo + +import ( + "context" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type GetRuleDetailLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetRuleDetailLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetRuleDetailLogic { + return &GetRuleDetailLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetRuleDetailLogic) GetRuleDetail(req *types.GetPromoRuleDetailRequest) (*types.PromoRule, error) { + rule, err := l.svcCtx.PromoModel.FindRule(l.ctx, req.Id) + if err != nil { + l.Errorw("[GetPromoRuleDetail] Database Error", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "get promo rule detail error: %v", err.Error()) + } + resp := convertRule(rule) + return &resp, nil +} diff --git a/internal/logic/admin/promo/getRuleListLogic.go b/internal/logic/admin/promo/getRuleListLogic.go new file mode 100644 index 0000000..7d0c3f1 --- /dev/null +++ b/internal/logic/admin/promo/getRuleListLogic.go @@ -0,0 +1,41 @@ +package promo + +import ( + "context" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type GetRuleListLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetRuleListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetRuleListLogic { + return &GetRuleListLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetRuleListLogic) GetRuleList(req *types.GetPromoRuleListRequest) (*types.GetPromoRuleListResponse, error) { + total, list, err := l.svcCtx.PromoModel.QueryRuleList(l.ctx, int(req.Page), int(req.Size), req.Type, req.Enabled, req.Search) + if err != nil { + l.Errorw("[GetPromoRuleList] Database Error", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "get promo rule list error: %v", err.Error()) + } + resp := &types.GetPromoRuleListResponse{ + Total: total, + List: make([]types.PromoRule, 0, len(list)), + } + for _, item := range list { + resp.List = append(resp.List, convertRule(item)) + } + return resp, nil +} diff --git a/internal/logic/admin/promo/getUsageListLogic.go b/internal/logic/admin/promo/getUsageListLogic.go new file mode 100644 index 0000000..1901c60 --- /dev/null +++ b/internal/logic/admin/promo/getUsageListLogic.go @@ -0,0 +1,49 @@ +package promo + +import ( + "context" + + promomodel "github.com/perfect-panel/server/internal/model/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type GetUsageListLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetUsageListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetUsageListLogic { + return &GetUsageListLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetUsageListLogic) GetUsageList(req *types.GetPromoUsageListRequest) (*types.GetPromoUsageListResponse, error) { + total, list, err := l.svcCtx.PromoModel.QueryUsageList(l.ctx, promomodel.UsageFilter{ + Page: int(req.Page), + Size: int(req.Size), + RuleId: req.RuleId, + UserId: req.UserId, + SubscribeId: req.SubscribeId, + OrderNo: req.OrderNo, + }) + if err != nil { + l.Errorw("[GetPromoUsageList] Database Error", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "get promo usage list error: %v", err.Error()) + } + resp := &types.GetPromoUsageListResponse{ + Total: total, + List: make([]types.PromoUsage, 0, len(list)), + } + for _, item := range list { + resp.List = append(resp.List, convertUsage(item)) + } + return resp, nil +} diff --git a/internal/logic/admin/promo/setPriceLogic.go b/internal/logic/admin/promo/setPriceLogic.go new file mode 100644 index 0000000..1c83d2f --- /dev/null +++ b/internal/logic/admin/promo/setPriceLogic.go @@ -0,0 +1,67 @@ +package promo + +import ( + "context" + + promomodel "github.com/perfect-panel/server/internal/model/promo" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +type SetPriceLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSetPriceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SetPriceLogic { + return &SetPriceLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *SetPriceLogic) SetPrice(req *types.SetPromoPriceRequest) error { + if _, err := l.svcCtx.PromoModel.FindRule(l.ctx, req.PromoRuleId); err != nil { + l.Errorw("[SetPromoPrice] Find Rule Error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find promo rule error: %v", err.Error()) + } + items := make([]*promomodel.SubscribePromo, 0, len(req.Items)) + cacheKeys := make([]string, 0, len(req.Items)) + for _, item := range req.Items { + sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, item.SubscribeId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "subscribe plan not found") + } + l.Errorw("[SetPromoPrice] Find Subscribe Error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find subscribe error: %v", err.Error()) + } + originPrice := sub.UnitPrice * item.Quantity + if item.PromoPrice >= originPrice { + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "promo_price must be less than unit_price * quantity") + } + items = append(items, &promomodel.SubscribePromo{ + SubscribeId: item.SubscribeId, + PromoRuleId: req.PromoRuleId, + Quantity: item.Quantity, + PromoPrice: item.PromoPrice, + }) + cacheKeys = append(cacheKeys, subscribePromoCacheKey(item.SubscribeId)) + } + if err := l.svcCtx.PromoModel.UpsertPrices(l.ctx, req.PromoRuleId, items); err != nil { + l.Errorw("[SetPromoPrice] Database Error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "set promo price error: %v", err.Error()) + } + if len(cacheKeys) > 0 { + if err := l.svcCtx.Redis.Del(l.ctx, cacheKeys...).Err(); err != nil { + l.Errorw("[SetPromoPrice] Delete Cache Error", logger.Field("error", err.Error())) + } + } + return nil +} diff --git a/internal/logic/admin/promo/tool.go b/internal/logic/admin/promo/tool.go new file mode 100644 index 0000000..54898f8 --- /dev/null +++ b/internal/logic/admin/promo/tool.go @@ -0,0 +1,166 @@ +package promo + +import ( + "encoding/json" + "fmt" + "math" + "time" + + promomodel "github.com/perfect-panel/server/internal/model/promo" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +const ( + ruleCacheKey = "promo:rules:enabled" + subscribeCachePref = "promo:subscribe:" +) + +func validateRuleInput(ruleType string, params map[string]interface{}, priority int64, startTime, endTime *int64) error { + if priority < 0 { + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "priority must be greater than or equal to 0") + } + if startTime != nil && endTime != nil && *startTime >= *endTime { + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "start_time must be less than end_time") + } + switch ruleType { + case "new_user": + windowHours, ok := numberParam(params, "window_hours") + if !ok || windowHours <= 0 { + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "params.window_hours must be greater than 0") + } + case "inactive_user": + inactiveMonths, ok := numberParam(params, "inactive_months") + if !ok || inactiveMonths <= 0 { + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "params.inactive_months must be greater than 0") + } + case "campaign": + if params == nil { + return nil + } + default: + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "unsupported promo rule type") + } + return nil +} + +func numberParam(params map[string]interface{}, key string) (int64, bool) { + if params == nil { + return 0, false + } + value, ok := params[key] + if !ok { + return 0, false + } + switch v := value.(type) { + case float64: + if math.Trunc(v) != v { + return 0, false + } + return int64(v), true + case int64: + return v, true + case int: + return int64(v), true + case json.Number: + n, err := v.Int64() + return n, err == nil + default: + return 0, false + } +} + +func paramsToString(params map[string]interface{}) (string, error) { + if params == nil { + params = map[string]interface{}{} + } + b, err := json.Marshal(params) + if err != nil { + return "", errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "invalid params") + } + return string(b), nil +} + +func parseParams(data string) map[string]interface{} { + if data == "" { + return map[string]interface{}{} + } + var params map[string]interface{} + if err := json.Unmarshal([]byte(data), ¶ms); err != nil { + return map[string]interface{}{} + } + return params +} + +func unixPtrToTimePtr(ts *int64) *time.Time { + if ts == nil || *ts == 0 { + return nil + } + t := time.Unix(*ts, 0) + return &t +} + +func timePtrToUnixPtr(t *time.Time) *int64 { + if t == nil { + return nil + } + ts := t.Unix() + return &ts +} + +func convertRule(item *promomodel.Rule) types.PromoRule { + if item == nil { + return types.PromoRule{} + } + enabled := false + if item.Enabled != nil { + enabled = *item.Enabled + } + return types.PromoRule{ + Id: item.Id, + Name: item.Name, + Type: item.Type, + Params: parseParams(item.Params), + Priority: item.Priority, + Enabled: enabled, + StartTime: timePtrToUnixPtr(item.StartTime), + EndTime: timePtrToUnixPtr(item.EndTime), + CreatedAt: item.CreatedAt.Unix(), + UpdatedAt: item.UpdatedAt.Unix(), + } +} + +func convertPrice(item *promomodel.SubscribePromo) types.PromoPrice { + if item == nil { + return types.PromoPrice{} + } + return types.PromoPrice{ + Id: item.Id, + SubscribeId: item.SubscribeId, + PromoRuleId: item.PromoRuleId, + Quantity: item.Quantity, + PromoPrice: item.PromoPrice, + CreatedAt: item.CreatedAt.Unix(), + UpdatedAt: item.UpdatedAt.Unix(), + } +} + +func convertUsage(item *promomodel.Usage) types.PromoUsage { + if item == nil { + return types.PromoUsage{} + } + return types.PromoUsage{ + Id: item.Id, + UserId: item.UserId, + PromoRuleId: item.PromoRuleId, + SubscribeId: item.SubscribeId, + OrderNo: item.OrderNo, + PromoPrice: item.PromoPrice, + CreatedAt: item.CreatedAt.Unix(), + } +} + +func subscribePromoCacheKey(subscribeId int64) string { + return fmt.Sprintf("%s%d", subscribeCachePref, subscribeId) +} diff --git a/internal/logic/admin/promo/updateRuleLogic.go b/internal/logic/admin/promo/updateRuleLogic.go new file mode 100644 index 0000000..4ccd408 --- /dev/null +++ b/internal/logic/admin/promo/updateRuleLogic.go @@ -0,0 +1,63 @@ +package promo + +import ( + "context" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type UpdateRuleLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpdateRuleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateRuleLogic { + return &UpdateRuleLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *UpdateRuleLogic) UpdateRule(req *types.UpdatePromoRuleRequest) (*types.PromoRule, error) { + if err := validateRuleInput(req.Type, req.Params, req.Priority, req.StartTime, req.EndTime); err != nil { + return nil, err + } + rule, err := l.svcCtx.PromoModel.FindRule(l.ctx, req.Id) + if err != nil { + l.Errorw("[UpdatePromoRule] Find Rule Error", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find promo rule error: %v", err.Error()) + } + params, err := paramsToString(req.Params) + if err != nil { + return nil, err + } + enabled := true + if rule.Enabled != nil { + enabled = *rule.Enabled + } + if req.Enabled != nil { + enabled = *req.Enabled + } + rule.Name = req.Name + rule.Type = req.Type + rule.Params = params + rule.Priority = req.Priority + rule.Enabled = &enabled + rule.StartTime = unixPtrToTimePtr(req.StartTime) + rule.EndTime = unixPtrToTimePtr(req.EndTime) + if err := l.svcCtx.PromoModel.UpdateRule(l.ctx, rule); err != nil { + l.Errorw("[UpdatePromoRule] Database Error", logger.Field("error", err.Error())) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update promo rule error: %v", err.Error()) + } + if err := l.svcCtx.Redis.Del(l.ctx, ruleCacheKey).Err(); err != nil { + l.Errorw("[UpdatePromoRule] Delete Cache Error", logger.Field("error", err.Error())) + } + resp := convertRule(rule) + return &resp, nil +} diff --git a/internal/model/promo/default.go b/internal/model/promo/default.go new file mode 100644 index 0000000..005a2e2 --- /dev/null +++ b/internal/model/promo/default.go @@ -0,0 +1,239 @@ +package promo + +import ( + "context" + "errors" + "fmt" + + "github.com/perfect-panel/server/pkg/cache" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +const ( + cachePromoRuleIdPrefix = "cache:promo_rule:id:" + cacheSubscribePromoIdPrefix = "cache:subscribe_promo:id:" +) + +type Model interface { + InsertRule(ctx context.Context, data *Rule) error + FindRule(ctx context.Context, id int64) (*Rule, error) + UpdateRule(ctx context.Context, data *Rule) error + DeleteRule(ctx context.Context, id int64) error + QueryRuleList(ctx context.Context, page, size int, ruleType string, enabled *bool, search string) (int64, []*Rule, error) + UpsertPrices(ctx context.Context, ruleId int64, items []*SubscribePromo) error + FindPrice(ctx context.Context, id int64) (*SubscribePromo, error) + DeletePrice(ctx context.Context, id int64) error + QueryPriceList(ctx context.Context, ruleId int64, page, size int) (int64, []*SubscribePromo, error) + QueryUsageList(ctx context.Context, params UsageFilter) (int64, []*Usage, error) + Transaction(ctx context.Context, fn func(db *gorm.DB) error) error +} + +type UsageFilter struct { + Page int + Size int + RuleId int64 + UserId int64 + SubscribeId int64 + OrderNo string +} + +type customPromoModel struct { + cache.CachedConn + table string +} + +func NewModel(db *gorm.DB, c *redis.Client) Model { + return &customPromoModel{ + CachedConn: cache.NewConn(db, c), + table: "`promo_rule`", + } +} + +func (m *customPromoModel) ruleCacheKey(id int64) string { + return fmt.Sprintf("%s%d", cachePromoRuleIdPrefix, id) +} + +func (m *customPromoModel) priceCacheKey(id int64) string { + return fmt.Sprintf("%s%d", cacheSubscribePromoIdPrefix, id) +} + +func (m *customPromoModel) InsertRule(ctx context.Context, data *Rule) error { + return m.ExecCtx(ctx, func(conn *gorm.DB) error { + return conn.Create(data).Error + }, m.ruleCacheKey(data.Id)) +} + +func (m *customPromoModel) FindRule(ctx context.Context, id int64) (*Rule, error) { + var resp Rule + err := m.QueryCtx(ctx, &resp, m.ruleCacheKey(id), func(conn *gorm.DB, v interface{}) error { + return conn.Model(&Rule{}).Where("id = ?", id).First(v).Error + }) + if err != nil { + return nil, err + } + return &resp, nil +} + +func (m *customPromoModel) UpdateRule(ctx context.Context, data *Rule) error { + old, err := m.FindRule(ctx, data.Id) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + keys := []string{m.ruleCacheKey(data.Id)} + if old != nil { + keys = append(keys, m.ruleCacheKey(old.Id)) + } + return m.ExecCtx(ctx, func(conn *gorm.DB) error { + return conn.Save(data).Error + }, keys...) +} + +func (m *customPromoModel) DeleteRule(ctx context.Context, id int64) error { + data, err := m.FindRule(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + return m.ExecCtx(ctx, func(conn *gorm.DB) error { + return conn.Delete(&Rule{}, id).Error + }, m.ruleCacheKey(data.Id)) +} + +func (m *customPromoModel) QueryRuleList(ctx context.Context, page, size int, ruleType string, enabled *bool, search string) (int64, []*Rule, error) { + if page <= 0 { + page = 1 + } + if size <= 0 { + size = 10 + } + var total int64 + var list []*Rule + err := m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error { + db := conn.Model(&Rule{}) + if ruleType != "" { + db = db.Where("type = ?", ruleType) + } + if enabled != nil { + db = db.Where("enabled = ?", *enabled) + } + if search != "" { + db = db.Where("name LIKE ?", "%"+search+"%") + } + if err := db.Count(&total).Error; err != nil { + return err + } + return db.Order("priority DESC").Order("id DESC").Limit(size).Offset((page - 1) * size).Find(v).Error + }) + return total, list, err +} + +func (m *customPromoModel) UpsertPrices(ctx context.Context, ruleId int64, items []*SubscribePromo) error { + return m.ExecCtx(ctx, func(conn *gorm.DB) error { + for _, item := range items { + if item == nil { + continue + } + item.PromoRuleId = ruleId + var existing SubscribePromo + err := conn.Model(&SubscribePromo{}). + Where("subscribe_id = ? AND quantity = ? AND promo_rule_id = ?", item.SubscribeId, item.Quantity, ruleId). + First(&existing).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if errors.Is(err, gorm.ErrRecordNotFound) { + if err := conn.Create(item).Error; err != nil { + return err + } + continue + } + existing.Quantity = item.Quantity + existing.PromoPrice = item.PromoPrice + if err := conn.Save(&existing).Error; err != nil { + return err + } + } + return nil + }) +} + +func (m *customPromoModel) FindPrice(ctx context.Context, id int64) (*SubscribePromo, error) { + var resp SubscribePromo + err := m.QueryCtx(ctx, &resp, m.priceCacheKey(id), func(conn *gorm.DB, v interface{}) error { + return conn.Model(&SubscribePromo{}).Where("id = ?", id).First(v).Error + }) + if err != nil { + return nil, err + } + return &resp, nil +} + +func (m *customPromoModel) DeletePrice(ctx context.Context, id int64) error { + data, err := m.FindPrice(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + return m.ExecCtx(ctx, func(conn *gorm.DB) error { + return conn.Delete(&SubscribePromo{}, id).Error + }, m.priceCacheKey(data.Id)) +} + +func (m *customPromoModel) QueryPriceList(ctx context.Context, ruleId int64, page, size int) (int64, []*SubscribePromo, error) { + if page <= 0 { + page = 1 + } + if size <= 0 { + size = 10 + } + var total int64 + var list []*SubscribePromo + err := m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error { + db := conn.Model(&SubscribePromo{}).Where("promo_rule_id = ?", ruleId) + if err := db.Count(&total).Error; err != nil { + return err + } + return db.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(v).Error + }) + return total, list, err +} + +func (m *customPromoModel) QueryUsageList(ctx context.Context, params UsageFilter) (int64, []*Usage, error) { + if params.Page <= 0 { + params.Page = 1 + } + if params.Size <= 0 { + params.Size = 10 + } + var total int64 + var list []*Usage + err := m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error { + db := conn.Model(&Usage{}) + if params.RuleId > 0 { + db = db.Where("promo_rule_id = ?", params.RuleId) + } + if params.UserId > 0 { + db = db.Where("user_id = ?", params.UserId) + } + if params.SubscribeId > 0 { + db = db.Where("subscribe_id = ?", params.SubscribeId) + } + if params.OrderNo != "" { + db = db.Where("order_no = ?", params.OrderNo) + } + if err := db.Count(&total).Error; err != nil { + return err + } + return db.Order("id DESC").Limit(params.Size).Offset((params.Page - 1) * params.Size).Find(v).Error + }) + return total, list, err +} + +func (m *customPromoModel) Transaction(ctx context.Context, fn func(db *gorm.DB) error) error { + return m.TransactCtx(ctx, fn) +} diff --git a/internal/model/promo/promo.go b/internal/model/promo/promo.go new file mode 100644 index 0000000..d312396 --- /dev/null +++ b/internal/model/promo/promo.go @@ -0,0 +1,53 @@ +package promo + +import ( + "time" + + "gorm.io/gorm" +) + +type Rule struct { + Id int64 `gorm:"primaryKey"` + Name string `gorm:"type:varchar(100);not null;default:'';comment:Promo Rule Name"` + Type string `gorm:"type:varchar(32);not null;default:'';comment:Promo Rule Type"` + Params string `gorm:"type:json;not null;comment:Rule Params"` + Priority int64 `gorm:"type:int;not null;default:0;comment:Priority"` + Enabled *bool `gorm:"type:tinyint(1);not null;default:1;comment:Enabled"` + StartTime *time.Time `gorm:"default:null;comment:Start Time"` + EndTime *time.Time `gorm:"default:null;comment:End Time"` + CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` + UpdatedAt time.Time `gorm:"comment:Update Time"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:Delete Time"` +} + +func (*Rule) TableName() string { + return "promo_rule" +} + +type SubscribePromo struct { + Id int64 `gorm:"primaryKey"` + SubscribeId int64 `gorm:"not null;index:idx_subscribe_qty_rule,unique;comment:Subscribe ID"` + Quantity int64 `gorm:"not null;default:0;index:idx_subscribe_qty_rule,unique;comment:Quantity"` + PromoRuleId int64 `gorm:"not null;index:idx_subscribe_qty_rule,unique;index:idx_promo_rule_id;comment:Promo Rule ID"` + PromoPrice int64 `gorm:"type:bigint;not null;default:0;comment:Promo Price"` + CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` + UpdatedAt time.Time `gorm:"comment:Update Time"` +} + +func (*SubscribePromo) TableName() string { + return "subscribe_promo" +} + +type Usage struct { + Id int64 `gorm:"primaryKey"` + UserId int64 `gorm:"not null;index:idx_user_rule;comment:User ID"` + PromoRuleId int64 `gorm:"not null;index:idx_user_rule;comment:Promo Rule ID"` + SubscribeId int64 `gorm:"not null;comment:Subscribe ID"` + OrderNo string `gorm:"type:varchar(255);not null;default:'';index:idx_order_no;comment:Order No"` + PromoPrice int64 `gorm:"type:bigint;not null;default:0;comment:Promo Price"` + CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` +} + +func (*Usage) TableName() string { + return "promo_usage" +} diff --git a/internal/svc/serviceContext.go b/internal/svc/serviceContext.go index e1c5aaa..168b626 100644 --- a/internal/svc/serviceContext.go +++ b/internal/svc/serviceContext.go @@ -21,6 +21,7 @@ import ( logmessage "github.com/perfect-panel/server/internal/model/logmessage" "github.com/perfect-panel/server/internal/model/order" "github.com/perfect-panel/server/internal/model/payment" + "github.com/perfect-panel/server/internal/model/promo" "github.com/perfect-panel/server/internal/model/subscribe" "github.com/perfect-panel/server/internal/model/system" "github.com/perfect-panel/server/internal/model/ticket" @@ -45,7 +46,7 @@ type ServiceContext struct { ExchangeRate float64 GeoIP *IPLocation SignatureValidator *signature.Validator - S3Store *storage.S3Store + S3Store *storage.S3Store //NodeCache *cache.NodeCacheClient AuthModel auth.Model @@ -63,6 +64,7 @@ type ServiceContext struct { RedemptionCodeModel redemption.RedemptionCodeModel RedemptionRecordModel redemption.RedemptionRecordModel PaymentModel payment.Model + PromoModel promo.Model DocumentModel document.Model SubscribeModel subscribe.Model TrafficLogModel traffic.Model @@ -138,6 +140,7 @@ func NewServiceContext(c config.Config) *ServiceContext { RedemptionCodeModel: redemption.NewRedemptionCodeModel(db, rds), RedemptionRecordModel: redemption.NewRedemptionRecordModel(db, rds), PaymentModel: payment.NewModel(db, rds), + PromoModel: promo.NewModel(db, rds), DocumentModel: document.NewModel(db, rds), SubscribeModel: subscribe.NewModel(db, rds), TrafficLogModel: traffic.NewModel(db), diff --git a/internal/types/types.go b/internal/types/types.go index 137a339..31c21ed 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -316,6 +316,45 @@ type ContactRequest struct { Notes string `json:"notes" validate:"max=2000"` } +type PromoPrice struct { + Id int64 `json:"id"` + SubscribeId int64 `json:"subscribe_id"` + PromoRuleId int64 `json:"promo_rule_id"` + Quantity int64 `json:"quantity"` + PromoPrice int64 `json:"promo_price"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type PromoPriceItem struct { + SubscribeId int64 `json:"subscribe_id" validate:"required,gt=0"` + Quantity int64 `json:"quantity" validate:"required,gt=0,lte=1000"` + PromoPrice int64 `json:"promo_price" validate:"required,gt=0"` +} + +type PromoRule struct { + Id int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Params map[string]interface{} `json:"params"` + Priority int64 `json:"priority"` + Enabled bool `json:"enabled"` + StartTime *int64 `json:"start_time"` + EndTime *int64 `json:"end_time"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type PromoUsage struct { + Id int64 `json:"id"` + UserId int64 `json:"user_id"` + PromoRuleId int64 `json:"promo_rule_id"` + SubscribeId int64 `json:"subscribe_id"` + OrderNo string `json:"order_no"` + PromoPrice int64 `json:"promo_price"` + CreatedAt int64 `json:"created_at"` +} + type Coupon struct { Id int64 `json:"id"` Name string `json:"name"` @@ -375,6 +414,16 @@ type CreateCouponRequest struct { Enable *bool `json:"enable,omitempty"` } +type CreatePromoRuleRequest struct { + Name string `json:"name" validate:"required,max=100"` + Type string `json:"type" validate:"required,oneof=new_user inactive_user campaign"` + Params map[string]interface{} `json:"params"` + Priority int64 `json:"priority" validate:"gte=0"` + Enabled *bool `json:"enabled"` + StartTime *int64 `json:"start_time"` + EndTime *int64 `json:"end_time"` +} + type CreateDocumentRequest struct { Title string `json:"title" validate:"required"` Content string `json:"content" validate:"required"` @@ -1107,6 +1156,48 @@ type GetCouponListResponse struct { List []Coupon `json:"list"` } +type GetPromoPriceListRequest struct { + PromoRuleId int64 `form:"promo_rule_id" validate:"required,gt=0"` + Page int64 `form:"page" validate:"required"` + Size int64 `form:"size" validate:"required"` +} + +type GetPromoPriceListResponse struct { + Total int64 `json:"total"` + List []PromoPrice `json:"list"` +} + +type GetPromoRuleDetailRequest struct { + Id int64 `uri:"id" validate:"required,gt=0"` +} + +type GetPromoRuleListRequest struct { + Page int64 `form:"page" validate:"required"` + Size int64 `form:"size" validate:"required"` + Type string `form:"type" validate:"omitempty,oneof=new_user inactive_user campaign"` + Enabled *bool `form:"enabled"` + Search string `form:"search,omitempty"` +} + +type GetPromoRuleListResponse struct { + Total int64 `json:"total"` + List []PromoRule `json:"list"` +} + +type GetPromoUsageListRequest struct { + Page int64 `form:"page" validate:"required"` + Size int64 `form:"size" validate:"required"` + RuleId int64 `form:"rule_id,omitempty"` + UserId int64 `form:"user_id,omitempty"` + SubscribeId int64 `form:"subscribe_id,omitempty"` + OrderNo string `form:"order_no,omitempty"` +} + +type GetPromoUsageListResponse struct { + Total int64 `json:"total"` + List []PromoUsage `json:"list"` +} + type GetDetailRequest struct { Id int64 `form:"id" validate:"required"` } @@ -3128,6 +3219,30 @@ type UpdateCouponRequest struct { Enable *bool `json:"enable,omitempty"` } +type SetPromoPriceRequest struct { + PromoRuleId int64 `json:"promo_rule_id" validate:"required,gt=0"` + Items []PromoPriceItem `json:"items" validate:"required,dive"` +} + +type DeletePromoPriceRequest struct { + Id int64 `uri:"id" validate:"required,gt=0"` +} + +type DeletePromoRuleRequest struct { + Id int64 `uri:"id" validate:"required,gt=0"` +} + +type UpdatePromoRuleRequest struct { + Id int64 `uri:"id" validate:"required,gt=0"` + Name string `json:"name" validate:"required,max=100"` + Type string `json:"type" validate:"required,oneof=new_user inactive_user campaign"` + Params map[string]interface{} `json:"params"` + Priority int64 `json:"priority" validate:"gte=0"` + Enabled *bool `json:"enabled"` + StartTime *int64 `json:"start_time"` + EndTime *int64 `json:"end_time"` +} + type UpdateDocumentRequest struct { Id int64 `json:"id" validate:"required"` Title string `json:"title" validate:"required"`