修复(#79): 修复促销管理审查问题

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-05-27 06:09:22 -07:00
parent 9de7a69756
commit c18a6788c4
9 changed files with 217 additions and 25 deletions
+7 -7
View File
@@ -37,8 +37,8 @@ type (
Id int64 `uri:"id" validate:"required,gt=0"` Id int64 `uri:"id" validate:"required,gt=0"`
} }
GetPromoRuleListRequest { GetPromoRuleListRequest {
Page int64 `form:"page" validate:"required"` Page int64 `form:"page" validate:"required,gt=0"`
Size int64 `form:"size" validate:"required"` Size int64 `form:"size" validate:"required,gt=0,lte=200"`
Type string `form:"type" validate:"omitempty,oneof=new_user inactive_user campaign"` Type string `form:"type" validate:"omitempty,oneof=new_user inactive_user campaign"`
Enabled *bool `form:"enabled"` Enabled *bool `form:"enabled"`
Search string `form:"search,omitempty"` Search string `form:"search,omitempty"`
@@ -49,12 +49,12 @@ type (
} }
SetPromoPriceRequest { SetPromoPriceRequest {
PromoRuleId int64 `json:"promo_rule_id" validate:"required,gt=0"` 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 { GetPromoPriceListRequest {
PromoRuleId int64 `form:"promo_rule_id" validate:"required,gt=0"` PromoRuleId int64 `form:"promo_rule_id" validate:"required,gt=0"`
Page int64 `form:"page" validate:"required"` Page int64 `form:"page" validate:"required,gt=0"`
Size int64 `form:"size" validate:"required"` Size int64 `form:"size" validate:"required,gt=0,lte=200"`
} }
GetPromoPriceListResponse { GetPromoPriceListResponse {
Total int64 `json:"total"` Total int64 `json:"total"`
@@ -64,8 +64,8 @@ type (
Id int64 `uri:"id" validate:"required,gt=0"` Id int64 `uri:"id" validate:"required,gt=0"`
} }
GetPromoUsageListRequest { GetPromoUsageListRequest {
Page int64 `form:"page" validate:"required"` Page int64 `form:"page" validate:"required,gt=0"`
Size int64 `form:"size" validate:"required"` Size int64 `form:"size" validate:"required,gt=0,lte=200"`
RuleId int64 `form:"rule_id,omitempty"` RuleId int64 `form:"rule_id,omitempty"`
UserId int64 `form:"user_id,omitempty"` UserId int64 `form:"user_id,omitempty"`
SubscribeId int64 `form:"subscribe_id,omitempty"` SubscribeId int64 `form:"subscribe_id,omitempty"`
@@ -2,12 +2,14 @@ package promo
import ( import (
"context" "context"
stderrors "errors"
"github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr" "github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors" "github.com/pkg/errors"
"gorm.io/gorm"
) )
type DeletePriceLogic struct { type DeletePriceLogic struct {
@@ -27,6 +29,10 @@ func NewDeletePriceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Delet
func (l *DeletePriceLogic) DeletePrice(req *types.DeletePromoPriceRequest) error { func (l *DeletePriceLogic) DeletePrice(req *types.DeletePromoPriceRequest) error {
price, err := l.svcCtx.PromoModel.FindPrice(l.ctx, req.Id) price, err := l.svcCtx.PromoModel.FindPrice(l.ctx, req.Id)
if err != nil { 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())) 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()) return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find promo price error: %v", err.Error())
} }
@@ -2,12 +2,14 @@ package promo
import ( import (
"context" "context"
stderrors "errors"
"github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr" "github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors" "github.com/pkg/errors"
"gorm.io/gorm"
) )
type DeleteRuleLogic struct { type DeleteRuleLogic struct {
@@ -25,6 +27,14 @@ func NewDeleteRuleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Delete
} }
func (l *DeleteRuleLogic) DeleteRule(req *types.DeletePromoRuleRequest) error { 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 { if err := l.svcCtx.PromoModel.DeleteRule(l.ctx, req.Id); err != nil {
l.Errorw("[DeletePromoRule] Database Error", logger.Field("error", err.Error())) l.Errorw("[DeletePromoRule] Database Error", logger.Field("error", err.Error()))
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "delete promo rule error: %v", err.Error()) return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "delete promo rule error: %v", err.Error())
@@ -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)
}
}
+21 -7
View File
@@ -4,12 +4,12 @@ import (
"context" "context"
promomodel "github.com/perfect-panel/server/internal/model/promo" 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/svc"
"github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr" "github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors" "github.com/pkg/errors"
"gorm.io/gorm"
) )
type SetPriceLogic struct { type SetPriceLogic struct {
@@ -31,17 +31,31 @@ func (l *SetPriceLogic) SetPrice(req *types.SetPromoPriceRequest) error {
l.Errorw("[SetPromoPrice] Find Rule Error", logger.Field("error", err.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()) 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)) items := make([]*promomodel.SubscribePromo, 0, len(req.Items))
cacheKeys := make([]string, 0, len(req.Items)) cacheKeys := make([]string, 0, len(req.Items))
for _, item := range req.Items { for _, item := range req.Items {
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, item.SubscribeId) sub, ok := subscribeById[item.SubscribeId]
if err != nil { if !ok {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "subscribe plan not found") 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 originPrice := sub.UnitPrice * item.Quantity
if item.PromoPrice >= originPrice { if item.PromoPrice >= originPrice {
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "promo_price must be less than unit_price * quantity") return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "promo_price must be less than unit_price * quantity")
+13 -2
View File
@@ -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 { 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 { 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 return err
} }
if errors.Is(err, gorm.ErrRecordNotFound) { 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.Quantity = item.Quantity
existing.PromoPrice = item.PromoPrice existing.PromoPrice = item.PromoPrice
+1 -1
View File
@@ -33,7 +33,7 @@ func (Rule) TableName() string {
type SubscribePromo struct { type SubscribePromo struct {
Id int64 `gorm:"primaryKey"` Id int64 `gorm:"primaryKey"`
SubscribeId int64 `gorm:"type:bigint unsigned;not null;comment:Subscribe ID"` 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"` PromoRuleId int64 `gorm:"type:bigint unsigned;not null;comment:Promo Rule ID"`
PromoPrice int64 `gorm:"type:bigint;not null;default:0;comment:Promo Price"` PromoPrice int64 `gorm:"type:bigint;not null;default:0;comment:Promo Price"`
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
+58
View File
@@ -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")
}
})
}
}
+7 -7
View File
@@ -1147,8 +1147,8 @@ type GetCouponListResponse struct {
type GetPromoPriceListRequest struct { type GetPromoPriceListRequest struct {
PromoRuleId int64 `form:"promo_rule_id" validate:"required,gt=0"` PromoRuleId int64 `form:"promo_rule_id" validate:"required,gt=0"`
Page int64 `form:"page" validate:"required"` Page int64 `form:"page" validate:"required,gt=0"`
Size int64 `form:"size" validate:"required"` Size int64 `form:"size" validate:"required,gt=0,lte=200"`
} }
type GetPromoPriceListResponse struct { type GetPromoPriceListResponse struct {
@@ -1161,8 +1161,8 @@ type GetPromoRuleDetailRequest struct {
} }
type GetPromoRuleListRequest struct { type GetPromoRuleListRequest struct {
Page int64 `form:"page" validate:"required"` Page int64 `form:"page" validate:"required,gt=0"`
Size int64 `form:"size" validate:"required"` Size int64 `form:"size" validate:"required,gt=0,lte=200"`
Type string `form:"type" validate:"omitempty,oneof=new_user inactive_user campaign"` Type string `form:"type" validate:"omitempty,oneof=new_user inactive_user campaign"`
Enabled *bool `form:"enabled"` Enabled *bool `form:"enabled"`
Search string `form:"search,omitempty"` Search string `form:"search,omitempty"`
@@ -1174,8 +1174,8 @@ type GetPromoRuleListResponse struct {
} }
type GetPromoUsageListRequest struct { type GetPromoUsageListRequest struct {
Page int64 `form:"page" validate:"required"` Page int64 `form:"page" validate:"required,gt=0"`
Size int64 `form:"size" validate:"required"` Size int64 `form:"size" validate:"required,gt=0,lte=200"`
RuleId int64 `form:"rule_id,omitempty"` RuleId int64 `form:"rule_id,omitempty"`
UserId int64 `form:"user_id,omitempty"` UserId int64 `form:"user_id,omitempty"`
SubscribeId int64 `form:"subscribe_id,omitempty"` SubscribeId int64 `form:"subscribe_id,omitempty"`
@@ -3223,7 +3223,7 @@ type UpdateCouponRequest struct {
type SetPromoPriceRequest struct { type SetPromoPriceRequest struct {
PromoRuleId int64 `json:"promo_rule_id" validate:"required,gt=0"` 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 { type DeletePromoPriceRequest struct {