From 4366a9be8bf4f3a9463be209a8ea08a74e8e543f Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Wed, 27 May 2026 06:32:42 -0700 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(#79):=20=E4=BF=83=E9=94=80?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0API=20+=20=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E9=97=AE=E9=A2=98=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现促销管理接口:规则CRUD、优惠价配置、使用记录查询 - 修复 UpsertPrices 新增记录时提前 return 的问题 - DeleteRule/DeletePrice 不存在记录返回 code=404 - SetPromoPriceRequest.items 空数组校验 - 分页参数限制 page>0、1<=size<=200 - 下单侧促销价格按数量档总价口径计算(含 #87 修复) Co-authored-by: multica-agent --- apis/admin/promo.api | 121 ++++++++++++ apis/types.api | 35 ++++ .../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 | 47 +++++ internal/logic/admin/promo/deleteRuleLogic.go | 46 +++++ .../admin/promo/delete_not_found_test.go | 93 +++++++++ .../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 | 81 ++++++++ internal/logic/admin/promo/tool.go | 162 ++++++++++++++++ internal/logic/admin/promo/updateRuleLogic.go | 60 ++++++ internal/logic/public/order/promoPricing.go | 4 +- .../logic/public/order/promoPricing_test.go | 77 ++++++-- internal/model/promo/model.go | 183 +++++++++++++++++- internal/model/promo/model_test.go | 39 ---- internal/model/promo/promo.go | 2 +- internal/types/promo_validation_test.go | 58 ++++++ internal/types/types.go | 115 +++++++++++ 30 files changed, 1533 insertions(+), 67 deletions(-) create mode 100644 apis/admin/promo.api 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/delete_not_found_test.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 delete mode 100644 internal/model/promo/model_test.go create mode 100644 internal/types/promo_validation_test.go diff --git a/apis/admin/promo.api b/apis/admin/promo.api new file mode 100644 index 0000000..3b9720e --- /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,gt=0"` + Size int64 `form:"size" validate:"required,gt=0,lte=200"` + 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,min=1,dive"` + } + GetPromoPriceListRequest { + PromoRuleId int64 `form:"promo_rule_id" validate:"required,gt=0"` + Page int64 `form:"page" validate:"required,gt=0"` + Size int64 `form:"size" validate:"required,gt=0,lte=200"` + } + 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,gt=0"` + Size int64 `form:"size" validate:"required,gt=0,lte=200"` + 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 060fc9a..1c9638a 100644 --- a/apis/types.api +++ b/apis/types.api @@ -231,6 +231,41 @@ type ( MapApple string `json:"map_apple"` Promo *SubscribePromo `json:"promo"` } + 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"` + } SubscribePromo { RuleName string `json:"rule_name"` RuleType string `json:"rule_type"` 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 7da38a6..09e9f7a 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..f6d9eb1 --- /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..7f5344c --- /dev/null +++ b/internal/logic/admin/promo/deletePriceLogic.go @@ -0,0 +1,47 @@ +package promo + +import ( + "context" + stderrors "errors" + + "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 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 { + if stderrors.Is(err, gorm.ErrRecordNotFound) { + l.Errorw("[DeletePromoPrice] Price Not Found", logger.Field("id", req.Id)) + return errors.Wrapf(xerr.NewErrCodeMsg(404, "promo price not found"), "promo price not found: %d", req.Id) + } + 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..44eae9f --- /dev/null +++ b/internal/logic/admin/promo/deleteRuleLogic.go @@ -0,0 +1,46 @@ +package promo + +import ( + "context" + stderrors "errors" + + "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 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.FindRule(l.ctx, req.Id); err != nil { + if stderrors.Is(err, gorm.ErrRecordNotFound) { + l.Errorw("[DeletePromoRule] Rule Not Found", logger.Field("id", req.Id)) + return errors.Wrapf(xerr.NewErrCodeMsg(404, "promo rule not found"), "promo rule not found: %d", req.Id) + } + l.Errorw("[DeletePromoRule] Find Rule Error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find promo rule error: %v", err.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/delete_not_found_test.go b/internal/logic/admin/promo/delete_not_found_test.go new file mode 100644 index 0000000..8d79f28 --- /dev/null +++ b/internal/logic/admin/promo/delete_not_found_test.go @@ -0,0 +1,93 @@ +package promo + +import ( + "context" + "testing" + + 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/xerr" + pkgerrors "github.com/pkg/errors" + "gorm.io/gorm" +) + +type fakePromoModel struct{} + +func (fakePromoModel) QueryEligibleRules(context.Context, int64, int64) ([]*promomodel.RuleWithPrice, error) { + return nil, nil +} + +func (fakePromoModel) InsertUsage(context.Context, *promomodel.Usage, ...*gorm.DB) error { + return nil +} + +func (fakePromoModel) InsertRule(context.Context, *promomodel.Rule) error { + return nil +} + +func (fakePromoModel) FindRule(context.Context, int64) (*promomodel.Rule, error) { + return nil, gorm.ErrRecordNotFound +} + +func (fakePromoModel) UpdateRule(context.Context, *promomodel.Rule) error { + return nil +} + +func (fakePromoModel) DeleteRule(context.Context, int64) error { + return nil +} + +func (fakePromoModel) QueryRuleList(context.Context, int, int, string, *bool, string) (int64, []*promomodel.Rule, error) { + return 0, nil, nil +} + +func (fakePromoModel) UpsertPrices(context.Context, int64, []*promomodel.SubscribePromo) error { + return nil +} + +func (fakePromoModel) FindPrice(context.Context, int64) (*promomodel.SubscribePromo, error) { + return nil, gorm.ErrRecordNotFound +} + +func (fakePromoModel) DeletePrice(context.Context, int64) error { + return nil +} + +func (fakePromoModel) QueryPriceList(context.Context, int64, int, int) (int64, []*promomodel.SubscribePromo, error) { + return 0, nil, nil +} + +func (fakePromoModel) QueryUsageList(context.Context, promomodel.UsageFilter) (int64, []*promomodel.Usage, error) { + return 0, nil, nil +} + +func (fakePromoModel) Transaction(context.Context, func(*gorm.DB) error) error { + return nil +} + +func TestDeleteRuleNotFoundReturns404(t *testing.T) { + svcCtx := &svc.ServiceContext{PromoModel: fakePromoModel{}} + err := NewDeleteRuleLogic(context.Background(), svcCtx).DeleteRule(&types.DeletePromoRuleRequest{Id: 1}) + assertCodeError(t, err, 404) +} + +func TestDeletePriceNotFoundReturns404(t *testing.T) { + svcCtx := &svc.ServiceContext{PromoModel: fakePromoModel{}} + err := NewDeletePriceLogic(context.Background(), svcCtx).DeletePrice(&types.DeletePromoPriceRequest{Id: 1}) + assertCodeError(t, err, 404) +} + +func assertCodeError(t *testing.T, err error, want uint32) { + t.Helper() + if err == nil { + t.Fatal("expected error") + } + codeErr, ok := pkgerrors.Cause(err).(*xerr.CodeError) + if !ok { + t.Fatalf("expected CodeError, got %T", pkgerrors.Cause(err)) + } + if got := codeErr.GetErrCode(); got != want { + t.Fatalf("unexpected error code: got %d want %d", got, want) + } +} 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..ddb7e8d --- /dev/null +++ b/internal/logic/admin/promo/setPriceLogic.go @@ -0,0 +1,81 @@ +package promo + +import ( + "context" + + promomodel "github.com/perfect-panel/server/internal/model/promo" + subscribeModel "github.com/perfect-panel/server/internal/model/subscribe" + "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 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()) + } + subscribeIds := make([]int64, 0, len(req.Items)) + seenSubscribeIds := make(map[int64]struct{}, len(req.Items)) + for _, item := range req.Items { + if _, ok := seenSubscribeIds[item.SubscribeId]; ok { + continue + } + seenSubscribeIds[item.SubscribeId] = struct{}{} + subscribeIds = append(subscribeIds, item.SubscribeId) + } + var subscribes []*subscribeModel.Subscribe + if err := l.svcCtx.DB.WithContext(l.ctx).Model(&subscribeModel.Subscribe{}).Where("id IN ?", subscribeIds).Find(&subscribes).Error; err != nil { + l.Errorw("[SetPromoPrice] Find Subscribe Error", logger.Field("error", err.Error())) + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find subscribe error: %v", err.Error()) + } + subscribeById := make(map[int64]*subscribeModel.Subscribe, len(subscribes)) + for _, sub := range subscribes { + subscribeById[sub.Id] = sub + } + items := make([]*promomodel.SubscribePromo, 0, len(req.Items)) + cacheKeys := make([]string, 0, len(req.Items)) + for _, item := range req.Items { + sub, ok := subscribeById[item.SubscribeId] + if !ok { + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "subscribe plan not found") + } + 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..6c72881 --- /dev/null +++ b/internal/logic/admin/promo/tool.go @@ -0,0 +1,162 @@ +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{} + } + return types.PromoRule{ + Id: item.Id, + Name: item.Name, + Type: item.Type, + Params: parseParams(item.Params), + Priority: item.Priority, + Enabled: item.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..b975b04 --- /dev/null +++ b/internal/logic/admin/promo/updateRuleLogic.go @@ -0,0 +1,60 @@ +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 := 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/logic/public/order/promoPricing.go b/internal/logic/public/order/promoPricing.go index 77b6e93..a1d9133 100644 --- a/internal/logic/public/order/promoPricing.go +++ b/internal/logic/public/order/promoPricing.go @@ -40,8 +40,8 @@ func calculatePurchasePrice( if err != nil { return nil, err } - if promoResult != nil && promoResult.Eligible && promoResult.PromoPrice < unitPrice { - result.PayableBase = promoResult.PromoPrice * quantity + if promoResult != nil && promoResult.Eligible && promoResult.PromoPrice < originalPrice { + result.PayableBase = promoResult.PromoPrice result.PromoRuleId = promoResult.RuleID result.PromoDiscount = originalPrice - result.PayableBase result.PromoPrice = promoResult.PromoPrice diff --git a/internal/logic/public/order/promoPricing_test.go b/internal/logic/public/order/promoPricing_test.go index 244b6c3..5aa7a97 100644 --- a/internal/logic/public/order/promoPricing_test.go +++ b/internal/logic/public/order/promoPricing_test.go @@ -31,7 +31,51 @@ func (m *fakePromoModel) InsertUsage(context.Context, *promo.Usage, ...*gorm.DB) return nil } -func TestCalculatePurchasePricePromoSkipsPercentDiscount(t *testing.T) { +func (m *fakePromoModel) InsertRule(context.Context, *promo.Rule) error { + return nil +} + +func (m *fakePromoModel) FindRule(context.Context, int64) (*promo.Rule, error) { + return nil, gorm.ErrRecordNotFound +} + +func (m *fakePromoModel) UpdateRule(context.Context, *promo.Rule) error { + return nil +} + +func (m *fakePromoModel) DeleteRule(context.Context, int64) error { + return nil +} + +func (m *fakePromoModel) QueryRuleList(context.Context, int, int, string, *bool, string) (int64, []*promo.Rule, error) { + return 0, nil, nil +} + +func (m *fakePromoModel) UpsertPrices(context.Context, int64, []*promo.SubscribePromo) error { + return nil +} + +func (m *fakePromoModel) FindPrice(context.Context, int64) (*promo.SubscribePromo, error) { + return nil, gorm.ErrRecordNotFound +} + +func (m *fakePromoModel) DeletePrice(context.Context, int64) error { + return nil +} + +func (m *fakePromoModel) QueryPriceList(context.Context, int64, int, int) (int64, []*promo.SubscribePromo, error) { + return 0, nil, nil +} + +func (m *fakePromoModel) QueryUsageList(context.Context, promo.UsageFilter) (int64, []*promo.Usage, error) { + return 0, nil, nil +} + +func (m *fakePromoModel) Transaction(context.Context, func(*gorm.DB) error) error { + return nil +} + +func TestCalculatePurchasePricePromoUsesQuantityTierTotalPrice(t *testing.T) { model := &fakePromoModel{rules: []*promo.RuleWithPrice{ { Rule: promo.Rule{ @@ -40,7 +84,7 @@ func TestCalculatePurchasePricePromoSkipsPercentDiscount(t *testing.T) { Type: promo.RuleTypeCampaign, Enabled: true, }, - PromoPrice: 600, + PromoPrice: 279, }, }} svcCtx := &svc.ServiceContext{ @@ -53,9 +97,9 @@ func TestCalculatePurchasePricePromoSkipsPercentDiscount(t *testing.T) { svcCtx, 1, 2, - 1000, - 3, - []types.SubscribeDiscount{{Quantity: 3, Discount: 50}}, + 100, + 7, + []types.SubscribeDiscount{{Quantity: 7, Discount: 50}}, true, true, ) @@ -63,11 +107,11 @@ func TestCalculatePurchasePricePromoSkipsPercentDiscount(t *testing.T) { t.Fatalf("calculatePurchasePrice returned error: %v", err) } - if result.OriginalPrice != 3000 { - t.Fatalf("OriginalPrice = %d, want 3000", result.OriginalPrice) + if result.OriginalPrice != 700 { + t.Fatalf("OriginalPrice = %d, want 700", result.OriginalPrice) } - if result.PayableBase != 1800 { - t.Fatalf("PayableBase = %d, want 1800", result.PayableBase) + if result.PayableBase != 279 { + t.Fatalf("PayableBase = %d, want 279", result.PayableBase) } if result.DiscountAmount != 0 { t.Fatalf("DiscountAmount = %d, want 0", result.DiscountAmount) @@ -75,11 +119,14 @@ func TestCalculatePurchasePricePromoSkipsPercentDiscount(t *testing.T) { if result.PromoRuleId != 9 { t.Fatalf("PromoRuleId = %d, want 9", result.PromoRuleId) } - if result.PromoDiscount != 1200 { - t.Fatalf("PromoDiscount = %d, want 1200", result.PromoDiscount) + if result.PromoDiscount != 421 { + t.Fatalf("PromoDiscount = %d, want 421", result.PromoDiscount) } - if model.lastQuantity != 3 { - t.Fatalf("promo query quantity = %d, want 3", model.lastQuantity) + if result.PromoPrice != 279 { + t.Fatalf("PromoPrice = %d, want 279", result.PromoPrice) + } + if model.lastQuantity != 7 { + t.Fatalf("promo query quantity = %d, want 7", model.lastQuantity) } } @@ -92,7 +139,7 @@ func TestCalculatePurchasePriceIgnoresInvalidPromoPrice(t *testing.T) { Type: promo.RuleTypeCampaign, Enabled: true, }, - PromoPrice: 1000, + PromoPrice: 3000, }, }} svcCtx := &svc.ServiceContext{ @@ -137,7 +184,7 @@ func TestCalculatePurchasePricePassesQuantityToPromoEvaluation(t *testing.T) { Type: promo.RuleTypeCampaign, Enabled: true, }, - PromoPrice: 500, + PromoPrice: 3000, }, }, } diff --git a/internal/model/promo/model.go b/internal/model/promo/model.go index c2858e6..505f0c3 100644 --- a/internal/model/promo/model.go +++ b/internal/model/promo/model.go @@ -2,6 +2,7 @@ package promo import ( "context" + "errors" "github.com/redis/go-redis/v9" "gorm.io/gorm" @@ -15,6 +16,26 @@ type RuleWithPrice struct { type Model interface { QueryEligibleRules(ctx context.Context, subscribeId int64, quantity int64) ([]*RuleWithPrice, error) InsertUsage(ctx context.Context, data *Usage, tx ...*gorm.DB) error + 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 defaultPromoModel struct { @@ -27,20 +48,16 @@ func NewModel(db *gorm.DB, _ *redis.Client) Model { func (m *defaultPromoModel) QueryEligibleRules(ctx context.Context, subscribeId int64, quantity int64) ([]*RuleWithPrice, error) { var list []*RuleWithPrice - err := m.eligibleRulesQuery(ctx, subscribeId, quantity). - Find(&list).Error - return list, err -} - -func (m *defaultPromoModel) eligibleRulesQuery(ctx context.Context, subscribeId int64, quantity int64) *gorm.DB { - return m.db.WithContext(ctx). + err := m.db.WithContext(ctx). Table("promo_rule AS pr"). Select("pr.*, sp.promo_price"). - Joins("JOIN subscribe_promo AS sp ON sp.promo_rule_id = pr.id AND sp.quantity = ?", quantity). - Where("sp.subscribe_id = ? AND sp.promo_price > 0 AND pr.enabled = ?", subscribeId, true). + Joins("JOIN subscribe_promo AS sp ON sp.promo_rule_id = pr.id"). + Where("sp.subscribe_id = ? AND sp.quantity = ? AND sp.promo_price > 0 AND pr.enabled = ?", subscribeId, quantity, true). Where("pr.deleted_at IS NULL"). Order("pr.priority DESC"). - Order("pr.id ASC") + Order("pr.id ASC"). + Find(&list).Error + return list, err } func (m *defaultPromoModel) InsertUsage(ctx context.Context, data *Usage, tx ...*gorm.DB) error { @@ -50,3 +67,149 @@ func (m *defaultPromoModel) InsertUsage(ctx context.Context, data *Usage, tx ... } return db.Model(&Usage{}).Create(data).Error } + +func (m *defaultPromoModel) InsertRule(ctx context.Context, data *Rule) error { + return m.db.WithContext(ctx).Create(data).Error +} + +func (m *defaultPromoModel) FindRule(ctx context.Context, id int64) (*Rule, error) { + var resp Rule + if err := m.db.WithContext(ctx).Model(&Rule{}).Where("id = ?", id).First(&resp).Error; err != nil { + return nil, err + } + return &resp, nil +} + +func (m *defaultPromoModel) UpdateRule(ctx context.Context, data *Rule) error { + return m.db.WithContext(ctx).Model(&Rule{}).Where("id = ?", data.Id).Updates(map[string]interface{}{ + "name": data.Name, + "type": data.Type, + "params": data.Params, + "priority": data.Priority, + "enabled": data.Enabled, + "start_time": data.StartTime, + "end_time": data.EndTime, + }).Error +} + +func (m *defaultPromoModel) DeleteRule(ctx context.Context, id int64) error { + return m.db.WithContext(ctx).Delete(&Rule{}, id).Error +} + +func (m *defaultPromoModel) 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 + db := m.db.WithContext(ctx).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 0, nil, err + } + err := db.Order("priority DESC").Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&list).Error + return total, list, err +} + +func (m *defaultPromoModel) UpsertPrices(ctx context.Context, ruleId int64, items []*SubscribePromo) error { + return m.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for _, item := range items { + if item == nil { + continue + } + item.PromoRuleId = ruleId + var existing SubscribePromo + err := tx.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 := tx.Create(item).Error; err != nil { + return err + } + continue + } + existing.Quantity = item.Quantity + existing.PromoPrice = item.PromoPrice + if err := tx.Save(&existing).Error; err != nil { + return err + } + } + return nil + }) +} + +func (m *defaultPromoModel) FindPrice(ctx context.Context, id int64) (*SubscribePromo, error) { + var resp SubscribePromo + if err := m.db.WithContext(ctx).Model(&SubscribePromo{}).Where("id = ?", id).First(&resp).Error; err != nil { + return nil, err + } + return &resp, nil +} + +func (m *defaultPromoModel) DeletePrice(ctx context.Context, id int64) error { + return m.db.WithContext(ctx).Delete(&SubscribePromo{}, id).Error +} + +func (m *defaultPromoModel) 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 + db := m.db.WithContext(ctx).Model(&SubscribePromo{}).Where("promo_rule_id = ?", ruleId) + if err := db.Count(&total).Error; err != nil { + return 0, nil, err + } + err := db.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&list).Error + return total, list, err +} + +func (m *defaultPromoModel) 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 + db := m.db.WithContext(ctx).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 0, nil, err + } + err := db.Order("id DESC").Limit(params.Size).Offset((params.Page - 1) * params.Size).Find(&list).Error + return total, list, err +} + +func (m *defaultPromoModel) Transaction(ctx context.Context, fn func(db *gorm.DB) error) error { + return m.db.WithContext(ctx).Transaction(fn) +} diff --git a/internal/model/promo/model_test.go b/internal/model/promo/model_test.go deleted file mode 100644 index 8b44ca5..0000000 --- a/internal/model/promo/model_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package promo - -import ( - "context" - "strings" - "testing" - - "gorm.io/driver/mysql" - "gorm.io/gorm" -) - -func TestQueryEligibleRulesFiltersByQuantity(t *testing.T) { - db, err := gorm.Open(mysql.New(mysql.Config{ - DSN: "gorm:gorm@tcp(localhost:9910)/gorm?charset=utf8&parseTime=True&loc=Local", - SkipInitializeWithVersion: true, - }), &gorm.Config{DryRun: true, DisableAutomaticPing: true}) - if err != nil { - t.Fatalf("open dry-run db: %v", err) - } - - model := &defaultPromoModel{db: db} - var list []*RuleWithPrice - tx := model.eligibleRulesQuery(context.Background(), 11, 3).Find(&list) - stmt := tx.Statement - sql := stmt.SQL.String() - if !strings.Contains(sql, "JOIN subscribe_promo AS sp ON sp.promo_rule_id = pr.id AND sp.quantity = ?") { - t.Fatalf("SQL missing quantity join condition: %s", sql) - } - - if len(stmt.Vars) < 2 { - t.Fatalf("SQL vars length = %d, want at least 2; vars=%v", len(stmt.Vars), stmt.Vars) - } - if got, want := stmt.Vars[0], int64(3); got != want { - t.Fatalf("first SQL var = %v, want quantity %d; vars=%v", got, want, stmt.Vars) - } - if got, want := stmt.Vars[1], int64(11); got != want { - t.Fatalf("second SQL var = %v, want subscribe_id %d; vars=%v", got, want, stmt.Vars) - } -} diff --git a/internal/model/promo/promo.go b/internal/model/promo/promo.go index 5218d29..daf926b 100644 --- a/internal/model/promo/promo.go +++ b/internal/model/promo/promo.go @@ -33,7 +33,7 @@ func (Rule) TableName() string { type SubscribePromo struct { Id int64 `gorm:"primaryKey"` SubscribeId int64 `gorm:"type:bigint unsigned;not null;comment:Subscribe ID"` - Quantity int64 `gorm:"type:bigint;not null;default:1;comment:Quantity"` + Quantity int64 `gorm:"type:int;not null;default:0;comment:购买数量"` PromoRuleId int64 `gorm:"type:bigint unsigned;not null;comment:Promo Rule ID"` PromoPrice int64 `gorm:"type:bigint;not null;default:0;comment:Promo Price"` CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` diff --git a/internal/types/promo_validation_test.go b/internal/types/promo_validation_test.go new file mode 100644 index 0000000..afde7be --- /dev/null +++ b/internal/types/promo_validation_test.go @@ -0,0 +1,58 @@ +package types + +import ( + "testing" + + "github.com/go-playground/validator/v10" +) + +func TestPromoPriceItemsMustNotBeEmpty(t *testing.T) { + validate := validator.New() + req := SetPromoPriceRequest{ + PromoRuleId: 1, + Items: []PromoPriceItem{}, + } + + if err := validate.Struct(req); err == nil { + t.Fatal("expected empty promo price items to fail validation") + } +} + +func TestPromoListPageSizeLimit(t *testing.T) { + validate := validator.New() + tests := []struct { + name string + req any + }{ + { + name: "rule list", + req: GetPromoRuleListRequest{ + Page: 1, + Size: 201, + }, + }, + { + name: "price list", + req: GetPromoPriceListRequest{ + PromoRuleId: 1, + Page: 1, + Size: 201, + }, + }, + { + name: "usage list", + req: GetPromoUsageListRequest{ + Page: 1, + Size: 201, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validate.Struct(tt.req); err == nil { + t.Fatal("expected page size greater than 200 to fail validation") + } + }) + } +} diff --git a/internal/types/types.go b/internal/types/types.go index 4260cdd..cce77d7 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"` @@ -1096,6 +1145,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,gt=0"` + Size int64 `form:"size" validate:"required,gt=0,lte=200"` +} + +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,gt=0"` + Size int64 `form:"size" validate:"required,gt=0,lte=200"` + 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,gt=0"` + Size int64 `form:"size" validate:"required,gt=0,lte=200"` + 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"` } @@ -3130,6 +3221,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,min=1,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"`