From c18a6788c407f1cbc841993c98236ea16a609758 Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Wed, 27 May 2026 06:09:22 -0700 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(#79):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=BF=83=E9=94=80=E7=AE=A1=E7=90=86=E5=AE=A1=E6=9F=A5=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- apis/admin/promo.api | 14 +-- .../logic/admin/promo/deletePriceLogic.go | 6 ++ internal/logic/admin/promo/deleteRuleLogic.go | 10 ++ .../admin/promo/delete_not_found_test.go | 93 +++++++++++++++++++ internal/logic/admin/promo/setPriceLogic.go | 30 ++++-- internal/model/promo/model.go | 15 ++- internal/model/promo/promo.go | 2 +- internal/types/promo_validation_test.go | 58 ++++++++++++ internal/types/types.go | 14 +-- 9 files changed, 217 insertions(+), 25 deletions(-) create mode 100644 internal/logic/admin/promo/delete_not_found_test.go create mode 100644 internal/types/promo_validation_test.go diff --git a/apis/admin/promo.api b/apis/admin/promo.api index ab456e9..3b9720e 100644 --- a/apis/admin/promo.api +++ b/apis/admin/promo.api @@ -37,8 +37,8 @@ type ( Id int64 `uri:"id" validate:"required,gt=0"` } GetPromoRuleListRequest { - Page int64 `form:"page" validate:"required"` - Size int64 `form:"size" validate:"required"` + 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"` @@ -49,12 +49,12 @@ type ( } SetPromoPriceRequest { PromoRuleId int64 `json:"promo_rule_id" validate:"required,gt=0"` - Items []PromoPriceItem `json:"items" validate:"required,dive"` + 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"` - Size int64 `form:"size" validate:"required"` + Page int64 `form:"page" validate:"required,gt=0"` + Size int64 `form:"size" validate:"required,gt=0,lte=200"` } GetPromoPriceListResponse { Total int64 `json:"total"` @@ -64,8 +64,8 @@ type ( Id int64 `uri:"id" validate:"required,gt=0"` } GetPromoUsageListRequest { - Page int64 `form:"page" validate:"required"` - Size int64 `form:"size" validate:"required"` + 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"` diff --git a/internal/logic/admin/promo/deletePriceLogic.go b/internal/logic/admin/promo/deletePriceLogic.go index 9964d50..7f5344c 100644 --- a/internal/logic/admin/promo/deletePriceLogic.go +++ b/internal/logic/admin/promo/deletePriceLogic.go @@ -2,12 +2,14 @@ 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 { @@ -27,6 +29,10 @@ func NewDeletePriceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Delet 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()) } diff --git a/internal/logic/admin/promo/deleteRuleLogic.go b/internal/logic/admin/promo/deleteRuleLogic.go index c09a574..44eae9f 100644 --- a/internal/logic/admin/promo/deleteRuleLogic.go +++ b/internal/logic/admin/promo/deleteRuleLogic.go @@ -2,12 +2,14 @@ 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 { @@ -25,6 +27,14 @@ func NewDeleteRuleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Delete } 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()) 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/setPriceLogic.go b/internal/logic/admin/promo/setPriceLogic.go index 1c83d2f..ddb7e8d 100644 --- a/internal/logic/admin/promo/setPriceLogic.go +++ b/internal/logic/admin/promo/setPriceLogic.go @@ -4,12 +4,12 @@ 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" - "gorm.io/gorm" ) type SetPriceLogic struct { @@ -31,16 +31,30 @@ func (l *SetPriceLogic) SetPrice(req *types.SetPromoPriceRequest) error { 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, 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()) + 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 { diff --git a/internal/model/promo/model.go b/internal/model/promo/model.go index c1cc682..505f0c3 100644 --- a/internal/model/promo/model.go +++ b/internal/model/promo/model.go @@ -81,7 +81,15 @@ func (m *defaultPromoModel) FindRule(ctx context.Context, id int64) (*Rule, erro } func (m *defaultPromoModel) UpdateRule(ctx context.Context, data *Rule) error { - return m.db.WithContext(ctx).Save(data).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 { @@ -129,7 +137,10 @@ func (m *defaultPromoModel) UpsertPrices(ctx context.Context, ruleId int64, item return err } if errors.Is(err, gorm.ErrRecordNotFound) { - return tx.Create(item).Error + if err := tx.Create(item).Error; err != nil { + return err + } + continue } existing.Quantity = item.Quantity existing.PromoPrice = item.PromoPrice 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 6bd4265..cce77d7 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -1147,8 +1147,8 @@ type GetCouponListResponse struct { 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"` + Page int64 `form:"page" validate:"required,gt=0"` + Size int64 `form:"size" validate:"required,gt=0,lte=200"` } type GetPromoPriceListResponse struct { @@ -1161,8 +1161,8 @@ type GetPromoRuleDetailRequest struct { } type GetPromoRuleListRequest struct { - Page int64 `form:"page" validate:"required"` - Size int64 `form:"size" validate:"required"` + 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"` @@ -1174,8 +1174,8 @@ type GetPromoRuleListResponse struct { } type GetPromoUsageListRequest struct { - Page int64 `form:"page" validate:"required"` - Size int64 `form:"size" validate:"required"` + 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"` @@ -3223,7 +3223,7 @@ type UpdateCouponRequest struct { type SetPromoPriceRequest struct { PromoRuleId int64 `json:"promo_rule_id" validate:"required,gt=0"` - Items []PromoPriceItem `json:"items" validate:"required,dive"` + Items []PromoPriceItem `json:"items" validate:"required,min=1,dive"` } type DeletePromoPriceRequest struct {