diff --git a/internal/logic/admin/promo/delete_not_found_test.go b/internal/logic/admin/promo/delete_not_found_test.go index add9eb1..6e8c071 100644 --- a/internal/logic/admin/promo/delete_not_found_test.go +++ b/internal/logic/admin/promo/delete_not_found_test.go @@ -12,7 +12,11 @@ import ( "gorm.io/gorm" ) -type fakePromoModel struct{} +type fakePromoModel struct { + insertRule func(context.Context, *promomodel.Rule) error + findRule func(context.Context, int64) (*promomodel.Rule, error) + updateRule func(context.Context, *promomodel.Rule) error +} func (fakePromoModel) QueryEligibleRules(context.Context, int64, int64) ([]*promomodel.RuleWithPrice, error) { return nil, nil @@ -22,15 +26,24 @@ func (fakePromoModel) InsertUsage(context.Context, *promomodel.Usage, ...*gorm.D return nil } -func (fakePromoModel) InsertRule(context.Context, *promomodel.Rule) error { +func (m fakePromoModel) InsertRule(ctx context.Context, rule *promomodel.Rule) error { + if m.insertRule != nil { + return m.insertRule(ctx, rule) + } return nil } -func (fakePromoModel) FindRule(context.Context, int64) (*promomodel.Rule, error) { +func (m fakePromoModel) FindRule(ctx context.Context, id int64) (*promomodel.Rule, error) { + if m.findRule != nil { + return m.findRule(ctx, id) + } return nil, gorm.ErrRecordNotFound } -func (fakePromoModel) UpdateRule(context.Context, *promomodel.Rule) error { +func (m fakePromoModel) UpdateRule(ctx context.Context, rule *promomodel.Rule) error { + if m.updateRule != nil { + return m.updateRule(ctx, rule) + } return nil } diff --git a/internal/logic/admin/promo/rule_time_test.go b/internal/logic/admin/promo/rule_time_test.go new file mode 100644 index 0000000..d4fe4d9 --- /dev/null +++ b/internal/logic/admin/promo/rule_time_test.go @@ -0,0 +1,133 @@ +package promo + +import ( + "context" + "testing" + "time" + + 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" + "github.com/redis/go-redis/v9" +) + +func TestCreateRuleAcceptsMillisecondTimestamps(t *testing.T) { + startTime := int64(1777618800000) + endTime := int64(1782802800000) + var inserted *promomodel.Rule + + svcCtx := &svc.ServiceContext{ + Redis: redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"}), + PromoModel: fakePromoModel{ + insertRule: func(_ context.Context, rule *promomodel.Rule) error { + inserted = rule + return nil + }, + }, + } + + _, err := NewCreateRuleLogic(context.Background(), svcCtx).CreateRule(&types.CreatePromoRuleRequest{ + Name: "618活动", + Type: promomodel.RuleTypeInactiveUser, + Params: map[string]interface{}{"inactive_months": float64(1)}, + Priority: 0, + StartTime: &startTime, + EndTime: &endTime, + }) + if err != nil { + t.Fatalf("CreateRule returned error: %v", err) + } + if inserted == nil { + t.Fatal("rule was not inserted") + } + assertPromoRuleTime(t, inserted.StartTime, time.UnixMilli(startTime)) + assertPromoRuleTime(t, inserted.EndTime, time.UnixMilli(endTime)) +} + +func TestUpdateRuleAcceptsMillisecondTimestamps(t *testing.T) { + startTime := int64(1777618800000) + endTime := int64(1782802800000) + existing := &promomodel.Rule{Id: 9, Enabled: true} + var updated *promomodel.Rule + + svcCtx := &svc.ServiceContext{ + Redis: redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"}), + PromoModel: fakePromoModel{ + findRule: func(_ context.Context, id int64) (*promomodel.Rule, error) { + if id != existing.Id { + t.Fatalf("FindRule id = %d, want %d", id, existing.Id) + } + return existing, nil + }, + updateRule: func(_ context.Context, rule *promomodel.Rule) error { + updated = rule + return nil + }, + }, + } + + _, err := NewUpdateRuleLogic(context.Background(), svcCtx).UpdateRule(&types.UpdatePromoRuleRequest{ + Id: existing.Id, + Name: "618活动", + Type: promomodel.RuleTypeInactiveUser, + Params: map[string]interface{}{"inactive_months": float64(1)}, + Priority: 0, + StartTime: &startTime, + EndTime: &endTime, + }) + if err != nil { + t.Fatalf("UpdateRule returned error: %v", err) + } + if updated == nil { + t.Fatal("rule was not updated") + } + assertPromoRuleTime(t, updated.StartTime, time.UnixMilli(startTime)) + assertPromoRuleTime(t, updated.EndTime, time.UnixMilli(endTime)) +} + +func TestRuleRejectsOutOfRangeTimestamp(t *testing.T) { + startTime := int64(253402300800000) + endTime := int64(253402304400000) + svcCtx := &svc.ServiceContext{PromoModel: fakePromoModel{ + insertRule: func(context.Context, *promomodel.Rule) error { + t.Fatal("InsertRule should not be called for invalid timestamp") + return nil + }, + }} + + _, err := NewCreateRuleLogic(context.Background(), svcCtx).CreateRule(&types.CreatePromoRuleRequest{ + Name: "bad time", + Type: promomodel.RuleTypeInactiveUser, + Params: map[string]interface{}{"inactive_months": float64(1)}, + Priority: 0, + StartTime: &startTime, + EndTime: &endTime, + }) + assertInvalidParams(t, err) +} + +func assertPromoRuleTime(t *testing.T, got *time.Time, want time.Time) { + t.Helper() + if got == nil { + t.Fatalf("time is nil, want %v", want) + } + if !got.Equal(want) { + t.Fatalf("time = %v, want %v", *got, want) + } +} + +func assertInvalidParams(t *testing.T, err error) { + 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 != xerr.InvalidParams { + t.Fatalf("error code = %d, want %d", got, xerr.InvalidParams) + } +} diff --git a/internal/logic/admin/promo/tool.go b/internal/logic/admin/promo/tool.go index 6c72881..c2c3532 100644 --- a/internal/logic/admin/promo/tool.go +++ b/internal/logic/admin/promo/tool.go @@ -17,11 +17,24 @@ const ( subscribeCachePref = "promo:subscribe:" ) +var ( + minPromoRuleTime = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + maxPromoRuleTime = time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC) +) + 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 { + startAt, err := normalizeRuleTimestamp(startTime) + if err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "invalid start_time") + } + endAt, err := normalizeRuleTimestamp(endTime) + if err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "invalid end_time") + } + if startAt != nil && endAt != nil && !startAt.Before(*endAt) { return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "start_time must be less than end_time") } switch ruleType { @@ -93,12 +106,29 @@ func parseParams(data string) map[string]interface{} { return params } -func unixPtrToTimePtr(ts *int64) *time.Time { +func normalizeRuleTimestamp(ts *int64) (*time.Time, error) { if ts == nil || *ts == 0 { + return nil, nil + } + value := *ts + var t time.Time + if value >= 1_000_000_000_000 || value <= -1_000_000_000_000 { + t = time.UnixMilli(value) + } else { + t = time.Unix(value, 0) + } + if t.Before(minPromoRuleTime) || t.After(maxPromoRuleTime) { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "timestamp out of range") + } + return &t, nil +} + +func unixPtrToTimePtr(ts *int64) *time.Time { + t, err := normalizeRuleTimestamp(ts) + if err != nil { return nil } - t := time.Unix(*ts, 0) - return &t + return t } func timePtrToUnixPtr(t *time.Time) *int64 {