From e4aaf0af297abe70122a73891aeb3d1fc0064d19 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Tue, 19 May 2026 15:53:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(redeem):=20=E5=85=91=E6=8D=A2=E7=A0=81?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=AE=BE=E7=BD=AE=E4=BD=BF=E7=94=A8=E6=9C=89?= =?UTF-8?q?=E6=95=88=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/ent/migrate/schema.go | 14 +++- backend/ent/mutation.go | 75 ++++++++++++++++- backend/ent/redeemcode.go | 16 +++- backend/ent/redeemcode/redeemcode.go | 8 ++ backend/ent/redeemcode/where.go | 55 ++++++++++++ backend/ent/redeemcode_create.go | 78 +++++++++++++++++ backend/ent/redeemcode_update.go | 52 ++++++++++++ backend/ent/runtime/runtime.go | 2 +- backend/ent/schema/redeem_code.go | 5 ++ .../internal/handler/admin/redeem_handler.go | 77 ++++++++++++++--- .../handler/admin/redeem_handler_test.go | 31 +++++++ backend/internal/handler/dto/mappers.go | 4 + backend/internal/handler/dto/types.go | 1 + .../internal/repository/redeem_code_repo.go | 33 +++++++- .../redeem_code_repo_integration_test.go | 29 ++++++- backend/internal/service/admin_service.go | 14 +++- .../internal/service/auth_oauth_email_flow.go | 14 +++- backend/internal/service/auth_service.go | 4 +- backend/internal/service/redeem_code.go | 17 +++- backend/internal/service/redeem_code_test.go | 59 +++++++++++++ backend/internal/service/redeem_service.go | 10 ++- .../migrations/137_redeem_code_expires_at.sql | 8 ++ frontend/src/api/admin/redeem.ts | 7 +- frontend/src/i18n/locales/en.ts | 7 ++ frontend/src/i18n/locales/zh.ts | 7 ++ frontend/src/types/index.ts | 3 + frontend/src/views/admin/RedeemView.vue | 84 ++++++++++++++++++- 27 files changed, 676 insertions(+), 38 deletions(-) create mode 100644 backend/internal/service/redeem_code_test.go create mode 100644 backend/migrations/137_redeem_code_expires_at.sql diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 5e768e2c..f98761ea 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -1120,6 +1120,7 @@ var ( {Name: "used_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "notes", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}}, {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, + {Name: "expires_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "validity_days", Type: field.TypeInt, Default: 30}, {Name: "group_id", Type: field.TypeInt64, Nullable: true}, {Name: "used_by", Type: field.TypeInt64, Nullable: true}, @@ -1132,13 +1133,13 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "redeem_codes_groups_redeem_codes", - Columns: []*schema.Column{RedeemCodesColumns[9]}, + Columns: []*schema.Column{RedeemCodesColumns[10]}, RefColumns: []*schema.Column{GroupsColumns[0]}, OnDelete: schema.SetNull, }, { Symbol: "redeem_codes_users_redeem_codes", - Columns: []*schema.Column{RedeemCodesColumns[10]}, + Columns: []*schema.Column{RedeemCodesColumns[11]}, RefColumns: []*schema.Column{UsersColumns[0]}, OnDelete: schema.SetNull, }, @@ -1152,12 +1153,17 @@ var ( { Name: "redeemcode_used_by", Unique: false, - Columns: []*schema.Column{RedeemCodesColumns[10]}, + Columns: []*schema.Column{RedeemCodesColumns[11]}, }, { Name: "redeemcode_group_id", Unique: false, - Columns: []*schema.Column{RedeemCodesColumns[9]}, + Columns: []*schema.Column{RedeemCodesColumns[10]}, + }, + { + Name: "redeemcode_expires_at", + Unique: false, + Columns: []*schema.Column{RedeemCodesColumns[8]}, }, }, } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index db7b8f49..45c56314 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -28602,6 +28602,7 @@ type RedeemCodeMutation struct { used_at *time.Time notes *string created_at *time.Time + expires_at *time.Time validity_days *int addvalidity_days *int clearedFields map[string]struct{} @@ -29059,6 +29060,55 @@ func (m *RedeemCodeMutation) ResetCreatedAt() { m.created_at = nil } +// SetExpiresAt sets the "expires_at" field. +func (m *RedeemCodeMutation) SetExpiresAt(t time.Time) { + m.expires_at = &t +} + +// ExpiresAt returns the value of the "expires_at" field in the mutation. +func (m *RedeemCodeMutation) ExpiresAt() (r time.Time, exists bool) { + v := m.expires_at + if v == nil { + return + } + return *v, true +} + +// OldExpiresAt returns the old "expires_at" field's value of the RedeemCode entity. +// If the RedeemCode object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *RedeemCodeMutation) OldExpiresAt(ctx context.Context) (v *time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldExpiresAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldExpiresAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldExpiresAt: %w", err) + } + return oldValue.ExpiresAt, nil +} + +// ClearExpiresAt clears the value of the "expires_at" field. +func (m *RedeemCodeMutation) ClearExpiresAt() { + m.expires_at = nil + m.clearedFields[redeemcode.FieldExpiresAt] = struct{}{} +} + +// ExpiresAtCleared returns if the "expires_at" field was cleared in this mutation. +func (m *RedeemCodeMutation) ExpiresAtCleared() bool { + _, ok := m.clearedFields[redeemcode.FieldExpiresAt] + return ok +} + +// ResetExpiresAt resets all changes to the "expires_at" field. +func (m *RedeemCodeMutation) ResetExpiresAt() { + m.expires_at = nil + delete(m.clearedFields, redeemcode.FieldExpiresAt) +} + // SetGroupID sets the "group_id" field. func (m *RedeemCodeMutation) SetGroupID(i int64) { m.group = &i @@ -29265,7 +29315,7 @@ func (m *RedeemCodeMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *RedeemCodeMutation) Fields() []string { - fields := make([]string, 0, 10) + fields := make([]string, 0, 11) if m.code != nil { fields = append(fields, redeemcode.FieldCode) } @@ -29290,6 +29340,9 @@ func (m *RedeemCodeMutation) Fields() []string { if m.created_at != nil { fields = append(fields, redeemcode.FieldCreatedAt) } + if m.expires_at != nil { + fields = append(fields, redeemcode.FieldExpiresAt) + } if m.group != nil { fields = append(fields, redeemcode.FieldGroupID) } @@ -29320,6 +29373,8 @@ func (m *RedeemCodeMutation) Field(name string) (ent.Value, bool) { return m.Notes() case redeemcode.FieldCreatedAt: return m.CreatedAt() + case redeemcode.FieldExpiresAt: + return m.ExpiresAt() case redeemcode.FieldGroupID: return m.GroupID() case redeemcode.FieldValidityDays: @@ -29349,6 +29404,8 @@ func (m *RedeemCodeMutation) OldField(ctx context.Context, name string) (ent.Val return m.OldNotes(ctx) case redeemcode.FieldCreatedAt: return m.OldCreatedAt(ctx) + case redeemcode.FieldExpiresAt: + return m.OldExpiresAt(ctx) case redeemcode.FieldGroupID: return m.OldGroupID(ctx) case redeemcode.FieldValidityDays: @@ -29418,6 +29475,13 @@ func (m *RedeemCodeMutation) SetField(name string, value ent.Value) error { } m.SetCreatedAt(v) return nil + case redeemcode.FieldExpiresAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetExpiresAt(v) + return nil case redeemcode.FieldGroupID: v, ok := value.(int64) if !ok { @@ -29498,6 +29562,9 @@ func (m *RedeemCodeMutation) ClearedFields() []string { if m.FieldCleared(redeemcode.FieldNotes) { fields = append(fields, redeemcode.FieldNotes) } + if m.FieldCleared(redeemcode.FieldExpiresAt) { + fields = append(fields, redeemcode.FieldExpiresAt) + } if m.FieldCleared(redeemcode.FieldGroupID) { fields = append(fields, redeemcode.FieldGroupID) } @@ -29524,6 +29591,9 @@ func (m *RedeemCodeMutation) ClearField(name string) error { case redeemcode.FieldNotes: m.ClearNotes() return nil + case redeemcode.FieldExpiresAt: + m.ClearExpiresAt() + return nil case redeemcode.FieldGroupID: m.ClearGroupID() return nil @@ -29559,6 +29629,9 @@ func (m *RedeemCodeMutation) ResetField(name string) error { case redeemcode.FieldCreatedAt: m.ResetCreatedAt() return nil + case redeemcode.FieldExpiresAt: + m.ResetExpiresAt() + return nil case redeemcode.FieldGroupID: m.ResetGroupID() return nil diff --git a/backend/ent/redeemcode.go b/backend/ent/redeemcode.go index 24cd4231..34b55f6b 100644 --- a/backend/ent/redeemcode.go +++ b/backend/ent/redeemcode.go @@ -35,6 +35,8 @@ type RedeemCode struct { Notes *string `json:"notes,omitempty"` // CreatedAt holds the value of the "created_at" field. CreatedAt time.Time `json:"created_at,omitempty"` + // ExpiresAt holds the value of the "expires_at" field. + ExpiresAt *time.Time `json:"expires_at,omitempty"` // GroupID holds the value of the "group_id" field. GroupID *int64 `json:"group_id,omitempty"` // ValidityDays holds the value of the "validity_days" field. @@ -89,7 +91,7 @@ func (*RedeemCode) scanValues(columns []string) ([]any, error) { values[i] = new(sql.NullInt64) case redeemcode.FieldCode, redeemcode.FieldType, redeemcode.FieldStatus, redeemcode.FieldNotes: values[i] = new(sql.NullString) - case redeemcode.FieldUsedAt, redeemcode.FieldCreatedAt: + case redeemcode.FieldUsedAt, redeemcode.FieldCreatedAt, redeemcode.FieldExpiresAt: values[i] = new(sql.NullTime) default: values[i] = new(sql.UnknownType) @@ -163,6 +165,13 @@ func (_m *RedeemCode) assignValues(columns []string, values []any) error { } else if value.Valid { _m.CreatedAt = value.Time } + case redeemcode.FieldExpiresAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field expires_at", values[i]) + } else if value.Valid { + _m.ExpiresAt = new(time.Time) + *_m.ExpiresAt = value.Time + } case redeemcode.FieldGroupID: if value, ok := values[i].(*sql.NullInt64); !ok { return fmt.Errorf("unexpected type %T for field group_id", values[i]) @@ -252,6 +261,11 @@ func (_m *RedeemCode) String() string { builder.WriteString("created_at=") builder.WriteString(_m.CreatedAt.Format(time.ANSIC)) builder.WriteString(", ") + if v := _m.ExpiresAt; v != nil { + builder.WriteString("expires_at=") + builder.WriteString(v.Format(time.ANSIC)) + } + builder.WriteString(", ") if v := _m.GroupID; v != nil { builder.WriteString("group_id=") builder.WriteString(fmt.Sprintf("%v", *v)) diff --git a/backend/ent/redeemcode/redeemcode.go b/backend/ent/redeemcode/redeemcode.go index b010476c..c7b30c15 100644 --- a/backend/ent/redeemcode/redeemcode.go +++ b/backend/ent/redeemcode/redeemcode.go @@ -30,6 +30,8 @@ const ( FieldNotes = "notes" // FieldCreatedAt holds the string denoting the created_at field in the database. FieldCreatedAt = "created_at" + // FieldExpiresAt holds the string denoting the expires_at field in the database. + FieldExpiresAt = "expires_at" // FieldGroupID holds the string denoting the group_id field in the database. FieldGroupID = "group_id" // FieldValidityDays holds the string denoting the validity_days field in the database. @@ -67,6 +69,7 @@ var Columns = []string{ FieldUsedAt, FieldNotes, FieldCreatedAt, + FieldExpiresAt, FieldGroupID, FieldValidityDays, } @@ -148,6 +151,11 @@ func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldCreatedAt, opts...).ToFunc() } +// ByExpiresAt orders the results by the expires_at field. +func ByExpiresAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldExpiresAt, opts...).ToFunc() +} + // ByGroupID orders the results by the group_id field. func ByGroupID(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldGroupID, opts...).ToFunc() diff --git a/backend/ent/redeemcode/where.go b/backend/ent/redeemcode/where.go index 1fdedba5..8325b9fc 100644 --- a/backend/ent/redeemcode/where.go +++ b/backend/ent/redeemcode/where.go @@ -95,6 +95,11 @@ func CreatedAt(v time.Time) predicate.RedeemCode { return predicate.RedeemCode(sql.FieldEQ(FieldCreatedAt, v)) } +// ExpiresAt applies equality check predicate on the "expires_at" field. It's identical to ExpiresAtEQ. +func ExpiresAt(v time.Time) predicate.RedeemCode { + return predicate.RedeemCode(sql.FieldEQ(FieldExpiresAt, v)) +} + // GroupID applies equality check predicate on the "group_id" field. It's identical to GroupIDEQ. func GroupID(v int64) predicate.RedeemCode { return predicate.RedeemCode(sql.FieldEQ(FieldGroupID, v)) @@ -535,6 +540,56 @@ func CreatedAtLTE(v time.Time) predicate.RedeemCode { return predicate.RedeemCode(sql.FieldLTE(FieldCreatedAt, v)) } +// ExpiresAtEQ applies the EQ predicate on the "expires_at" field. +func ExpiresAtEQ(v time.Time) predicate.RedeemCode { + return predicate.RedeemCode(sql.FieldEQ(FieldExpiresAt, v)) +} + +// ExpiresAtNEQ applies the NEQ predicate on the "expires_at" field. +func ExpiresAtNEQ(v time.Time) predicate.RedeemCode { + return predicate.RedeemCode(sql.FieldNEQ(FieldExpiresAt, v)) +} + +// ExpiresAtIn applies the In predicate on the "expires_at" field. +func ExpiresAtIn(vs ...time.Time) predicate.RedeemCode { + return predicate.RedeemCode(sql.FieldIn(FieldExpiresAt, vs...)) +} + +// ExpiresAtNotIn applies the NotIn predicate on the "expires_at" field. +func ExpiresAtNotIn(vs ...time.Time) predicate.RedeemCode { + return predicate.RedeemCode(sql.FieldNotIn(FieldExpiresAt, vs...)) +} + +// ExpiresAtGT applies the GT predicate on the "expires_at" field. +func ExpiresAtGT(v time.Time) predicate.RedeemCode { + return predicate.RedeemCode(sql.FieldGT(FieldExpiresAt, v)) +} + +// ExpiresAtGTE applies the GTE predicate on the "expires_at" field. +func ExpiresAtGTE(v time.Time) predicate.RedeemCode { + return predicate.RedeemCode(sql.FieldGTE(FieldExpiresAt, v)) +} + +// ExpiresAtLT applies the LT predicate on the "expires_at" field. +func ExpiresAtLT(v time.Time) predicate.RedeemCode { + return predicate.RedeemCode(sql.FieldLT(FieldExpiresAt, v)) +} + +// ExpiresAtLTE applies the LTE predicate on the "expires_at" field. +func ExpiresAtLTE(v time.Time) predicate.RedeemCode { + return predicate.RedeemCode(sql.FieldLTE(FieldExpiresAt, v)) +} + +// ExpiresAtIsNil applies the IsNil predicate on the "expires_at" field. +func ExpiresAtIsNil() predicate.RedeemCode { + return predicate.RedeemCode(sql.FieldIsNull(FieldExpiresAt)) +} + +// ExpiresAtNotNil applies the NotNil predicate on the "expires_at" field. +func ExpiresAtNotNil() predicate.RedeemCode { + return predicate.RedeemCode(sql.FieldNotNull(FieldExpiresAt)) +} + // GroupIDEQ applies the EQ predicate on the "group_id" field. func GroupIDEQ(v int64) predicate.RedeemCode { return predicate.RedeemCode(sql.FieldEQ(FieldGroupID, v)) diff --git a/backend/ent/redeemcode_create.go b/backend/ent/redeemcode_create.go index efdcee40..1bba027b 100644 --- a/backend/ent/redeemcode_create.go +++ b/backend/ent/redeemcode_create.go @@ -128,6 +128,20 @@ func (_c *RedeemCodeCreate) SetNillableCreatedAt(v *time.Time) *RedeemCodeCreate return _c } +// SetExpiresAt sets the "expires_at" field. +func (_c *RedeemCodeCreate) SetExpiresAt(v time.Time) *RedeemCodeCreate { + _c.mutation.SetExpiresAt(v) + return _c +} + +// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil. +func (_c *RedeemCodeCreate) SetNillableExpiresAt(v *time.Time) *RedeemCodeCreate { + if v != nil { + _c.SetExpiresAt(*v) + } + return _c +} + // SetGroupID sets the "group_id" field. func (_c *RedeemCodeCreate) SetGroupID(v int64) *RedeemCodeCreate { _c.mutation.SetGroupID(v) @@ -327,6 +341,10 @@ func (_c *RedeemCodeCreate) createSpec() (*RedeemCode, *sqlgraph.CreateSpec) { _spec.SetField(redeemcode.FieldCreatedAt, field.TypeTime, value) _node.CreatedAt = value } + if value, ok := _c.mutation.ExpiresAt(); ok { + _spec.SetField(redeemcode.FieldExpiresAt, field.TypeTime, value) + _node.ExpiresAt = &value + } if value, ok := _c.mutation.ValidityDays(); ok { _spec.SetField(redeemcode.FieldValidityDays, field.TypeInt, value) _node.ValidityDays = value @@ -525,6 +543,24 @@ func (u *RedeemCodeUpsert) ClearNotes() *RedeemCodeUpsert { return u } +// SetExpiresAt sets the "expires_at" field. +func (u *RedeemCodeUpsert) SetExpiresAt(v time.Time) *RedeemCodeUpsert { + u.Set(redeemcode.FieldExpiresAt, v) + return u +} + +// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create. +func (u *RedeemCodeUpsert) UpdateExpiresAt() *RedeemCodeUpsert { + u.SetExcluded(redeemcode.FieldExpiresAt) + return u +} + +// ClearExpiresAt clears the value of the "expires_at" field. +func (u *RedeemCodeUpsert) ClearExpiresAt() *RedeemCodeUpsert { + u.SetNull(redeemcode.FieldExpiresAt) + return u +} + // SetGroupID sets the "group_id" field. func (u *RedeemCodeUpsert) SetGroupID(v int64) *RedeemCodeUpsert { u.Set(redeemcode.FieldGroupID, v) @@ -732,6 +768,27 @@ func (u *RedeemCodeUpsertOne) ClearNotes() *RedeemCodeUpsertOne { }) } +// SetExpiresAt sets the "expires_at" field. +func (u *RedeemCodeUpsertOne) SetExpiresAt(v time.Time) *RedeemCodeUpsertOne { + return u.Update(func(s *RedeemCodeUpsert) { + s.SetExpiresAt(v) + }) +} + +// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create. +func (u *RedeemCodeUpsertOne) UpdateExpiresAt() *RedeemCodeUpsertOne { + return u.Update(func(s *RedeemCodeUpsert) { + s.UpdateExpiresAt() + }) +} + +// ClearExpiresAt clears the value of the "expires_at" field. +func (u *RedeemCodeUpsertOne) ClearExpiresAt() *RedeemCodeUpsertOne { + return u.Update(func(s *RedeemCodeUpsert) { + s.ClearExpiresAt() + }) +} + // SetGroupID sets the "group_id" field. func (u *RedeemCodeUpsertOne) SetGroupID(v int64) *RedeemCodeUpsertOne { return u.Update(func(s *RedeemCodeUpsert) { @@ -1111,6 +1168,27 @@ func (u *RedeemCodeUpsertBulk) ClearNotes() *RedeemCodeUpsertBulk { }) } +// SetExpiresAt sets the "expires_at" field. +func (u *RedeemCodeUpsertBulk) SetExpiresAt(v time.Time) *RedeemCodeUpsertBulk { + return u.Update(func(s *RedeemCodeUpsert) { + s.SetExpiresAt(v) + }) +} + +// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create. +func (u *RedeemCodeUpsertBulk) UpdateExpiresAt() *RedeemCodeUpsertBulk { + return u.Update(func(s *RedeemCodeUpsert) { + s.UpdateExpiresAt() + }) +} + +// ClearExpiresAt clears the value of the "expires_at" field. +func (u *RedeemCodeUpsertBulk) ClearExpiresAt() *RedeemCodeUpsertBulk { + return u.Update(func(s *RedeemCodeUpsert) { + s.ClearExpiresAt() + }) +} + // SetGroupID sets the "group_id" field. func (u *RedeemCodeUpsertBulk) SetGroupID(v int64) *RedeemCodeUpsertBulk { return u.Update(func(s *RedeemCodeUpsert) { diff --git a/backend/ent/redeemcode_update.go b/backend/ent/redeemcode_update.go index 0f05e06d..1e0ec1e6 100644 --- a/backend/ent/redeemcode_update.go +++ b/backend/ent/redeemcode_update.go @@ -153,6 +153,26 @@ func (_u *RedeemCodeUpdate) ClearNotes() *RedeemCodeUpdate { return _u } +// SetExpiresAt sets the "expires_at" field. +func (_u *RedeemCodeUpdate) SetExpiresAt(v time.Time) *RedeemCodeUpdate { + _u.mutation.SetExpiresAt(v) + return _u +} + +// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil. +func (_u *RedeemCodeUpdate) SetNillableExpiresAt(v *time.Time) *RedeemCodeUpdate { + if v != nil { + _u.SetExpiresAt(*v) + } + return _u +} + +// ClearExpiresAt clears the value of the "expires_at" field. +func (_u *RedeemCodeUpdate) ClearExpiresAt() *RedeemCodeUpdate { + _u.mutation.ClearExpiresAt() + return _u +} + // SetGroupID sets the "group_id" field. func (_u *RedeemCodeUpdate) SetGroupID(v int64) *RedeemCodeUpdate { _u.mutation.SetGroupID(v) @@ -321,6 +341,12 @@ func (_u *RedeemCodeUpdate) sqlSave(ctx context.Context) (_node int, err error) if _u.mutation.NotesCleared() { _spec.ClearField(redeemcode.FieldNotes, field.TypeString) } + if value, ok := _u.mutation.ExpiresAt(); ok { + _spec.SetField(redeemcode.FieldExpiresAt, field.TypeTime, value) + } + if _u.mutation.ExpiresAtCleared() { + _spec.ClearField(redeemcode.FieldExpiresAt, field.TypeTime) + } if value, ok := _u.mutation.ValidityDays(); ok { _spec.SetField(redeemcode.FieldValidityDays, field.TypeInt, value) } @@ -528,6 +554,26 @@ func (_u *RedeemCodeUpdateOne) ClearNotes() *RedeemCodeUpdateOne { return _u } +// SetExpiresAt sets the "expires_at" field. +func (_u *RedeemCodeUpdateOne) SetExpiresAt(v time.Time) *RedeemCodeUpdateOne { + _u.mutation.SetExpiresAt(v) + return _u +} + +// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil. +func (_u *RedeemCodeUpdateOne) SetNillableExpiresAt(v *time.Time) *RedeemCodeUpdateOne { + if v != nil { + _u.SetExpiresAt(*v) + } + return _u +} + +// ClearExpiresAt clears the value of the "expires_at" field. +func (_u *RedeemCodeUpdateOne) ClearExpiresAt() *RedeemCodeUpdateOne { + _u.mutation.ClearExpiresAt() + return _u +} + // SetGroupID sets the "group_id" field. func (_u *RedeemCodeUpdateOne) SetGroupID(v int64) *RedeemCodeUpdateOne { _u.mutation.SetGroupID(v) @@ -726,6 +772,12 @@ func (_u *RedeemCodeUpdateOne) sqlSave(ctx context.Context) (_node *RedeemCode, if _u.mutation.NotesCleared() { _spec.ClearField(redeemcode.FieldNotes, field.TypeString) } + if value, ok := _u.mutation.ExpiresAt(); ok { + _spec.SetField(redeemcode.FieldExpiresAt, field.TypeTime, value) + } + if _u.mutation.ExpiresAtCleared() { + _spec.ClearField(redeemcode.FieldExpiresAt, field.TypeTime) + } if value, ok := _u.mutation.ValidityDays(); ok { _spec.SetField(redeemcode.FieldValidityDays, field.TypeInt, value) } diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index e48dc781..b1899173 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1386,7 +1386,7 @@ func init() { // redeemcode.DefaultCreatedAt holds the default value on creation for the created_at field. redeemcode.DefaultCreatedAt = redeemcodeDescCreatedAt.Default.(func() time.Time) // redeemcodeDescValidityDays is the schema descriptor for validity_days field. - redeemcodeDescValidityDays := redeemcodeFields[9].Descriptor() + redeemcodeDescValidityDays := redeemcodeFields[10].Descriptor() // redeemcode.DefaultValidityDays holds the default value on creation for the validity_days field. redeemcode.DefaultValidityDays = redeemcodeDescValidityDays.Default.(int) securitysecretMixin := schema.SecuritySecret{}.Mixin() diff --git a/backend/ent/schema/redeem_code.go b/backend/ent/schema/redeem_code.go index 6fb86148..fdaf0808 100644 --- a/backend/ent/schema/redeem_code.go +++ b/backend/ent/schema/redeem_code.go @@ -63,6 +63,10 @@ func (RedeemCode) Fields() []ent.Field { Immutable(). Default(time.Now). SchemaType(map[string]string{dialect.Postgres: "timestamptz"}), + field.Time("expires_at"). + Optional(). + Nillable(). + SchemaType(map[string]string{dialect.Postgres: "timestamptz"}), field.Int64("group_id"). Optional(). Nillable(), @@ -90,5 +94,6 @@ func (RedeemCode) Indexes() []ent.Index { index.Fields("status"), index.Fields("used_by"), index.Fields("group_id"), + index.Fields("expires_at"), } } diff --git a/backend/internal/handler/admin/redeem_handler.go b/backend/internal/handler/admin/redeem_handler.go index 24365f3d..7b4300b1 100644 --- a/backend/internal/handler/admin/redeem_handler.go +++ b/backend/internal/handler/admin/redeem_handler.go @@ -8,6 +8,7 @@ import ( "fmt" "strconv" "strings" + "time" "github.com/Wei-Shaw/sub2api/internal/handler/dto" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" @@ -33,23 +34,51 @@ func NewRedeemHandler(adminService service.AdminService, redeemService *service. // GenerateRedeemCodesRequest represents generate redeem codes request type GenerateRedeemCodesRequest struct { - Count int `json:"count" binding:"required,min=1,max=100"` - Type string `json:"type" binding:"required,oneof=balance concurrency subscription invitation"` - Value float64 `json:"value"` - GroupID *int64 `json:"group_id"` // 订阅类型必填 - ValidityDays int `json:"validity_days"` // 订阅类型使用,正数增加/负数退款扣减 + Count int `json:"count" binding:"required,min=1,max=100"` + Type string `json:"type" binding:"required,oneof=balance concurrency subscription invitation"` + Value float64 `json:"value"` + GroupID *int64 `json:"group_id"` // 订阅类型必填 + ValidityDays int `json:"validity_days"` // 订阅类型使用,正数增加/负数退款扣减 + ExpiresAt *time.Time `json:"expires_at"` + ExpiresInDays *int `json:"expires_in_days" binding:"omitempty,min=1,max=3650"` } // CreateAndRedeemCodeRequest represents creating a fixed code and redeeming it for a target user. // Type 为 omitempty 而非 required 是为了向后兼容旧版调用方(不传 type 时默认 balance)。 type CreateAndRedeemCodeRequest struct { - Code string `json:"code" binding:"required,min=3,max=128"` - Type string `json:"type" binding:"omitempty,oneof=balance concurrency subscription invitation"` // 不传时默认 balance(向后兼容) - Value float64 `json:"value" binding:"required"` - UserID int64 `json:"user_id" binding:"required,gt=0"` - GroupID *int64 `json:"group_id"` // subscription 类型必填 - ValidityDays int `json:"validity_days"` // subscription 类型:正数增加,负数退款扣减 - Notes string `json:"notes"` + Code string `json:"code" binding:"required,min=3,max=128"` + Type string `json:"type" binding:"omitempty,oneof=balance concurrency subscription invitation"` // 不传时默认 balance(向后兼容) + Value float64 `json:"value" binding:"required"` + UserID int64 `json:"user_id" binding:"required,gt=0"` + GroupID *int64 `json:"group_id"` // subscription 类型必填 + ValidityDays int `json:"validity_days"` // subscription 类型:正数增加,负数退款扣减 + Notes string `json:"notes"` + ExpiresAt *time.Time `json:"expires_at"` + ExpiresInDays *int `json:"expires_in_days" binding:"omitempty,min=1,max=3650"` +} + +func resolveRedeemCodeExpiresAt(expiresAt *time.Time, expiresInDays *int) (*time.Time, error) { + if expiresAt != nil && expiresInDays != nil { + return nil, infraerrors.BadRequest("REDEEM_CODE_EXPIRY_CONFLICT", "expires_at and expires_in_days cannot both be set") + } + + now := time.Now().UTC() + if expiresInDays != nil { + if *expiresInDays <= 0 { + return nil, infraerrors.BadRequest("REDEEM_CODE_EXPIRES_IN_DAYS_INVALID", "expires_in_days must be greater than zero") + } + expires := now.AddDate(0, 0, *expiresInDays) + return &expires, nil + } + if expiresAt == nil { + return nil, nil + } + + expires := expiresAt.UTC() + if !expires.After(now) { + return nil, infraerrors.BadRequest("REDEEM_CODE_EXPIRES_AT_INVALID", "expires_at must be in the future") + } + return &expires, nil } // List handles listing all redeem codes with pagination @@ -107,6 +136,12 @@ func (h *RedeemHandler) Generate(c *gin.Context) { return } + expiresAt, err := resolveRedeemCodeExpiresAt(req.ExpiresAt, req.ExpiresInDays) + if err != nil { + response.ErrorFrom(c, err) + return + } + executeAdminIdempotentJSON(c, "admin.redeem_codes.generate", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) { codes, execErr := h.adminService.GenerateRedeemCodes(ctx, &service.GenerateRedeemCodesInput{ Count: req.Count, @@ -114,6 +149,7 @@ func (h *RedeemHandler) Generate(c *gin.Context) { Value: req.Value, GroupID: req.GroupID, ValidityDays: req.ValidityDays, + ExpiresAt: expiresAt, }) if execErr != nil { return nil, execErr @@ -158,6 +194,12 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) { } } + expiresAt, err := resolveRedeemCodeExpiresAt(req.ExpiresAt, req.ExpiresInDays) + if err != nil { + response.ErrorFrom(c, err) + return + } + executeAdminIdempotentJSON(c, "admin.redeem_codes.create_and_redeem", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) { existing, err := h.redeemService.GetByCode(ctx, req.Code) if err == nil { @@ -175,6 +217,7 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) { Notes: req.Notes, GroupID: req.GroupID, ValidityDays: req.ValidityDays, + ExpiresAt: expiresAt, }) if createErr != nil { // Unique code race: if code now exists, use idempotent semantics by used_by. @@ -199,6 +242,9 @@ func (h *RedeemHandler) resolveCreateAndRedeemExisting(ctx context.Context, exis } // If previous run created the code but crashed before redeem, redeem it now. + if existing.IsExpired() { + return nil, service.ErrRedeemCodeExpired + } if existing.CanUse() { redeemed, err := h.redeemService.Redeem(ctx, userID, existing.Code) if err == nil { @@ -321,7 +367,7 @@ func (h *RedeemHandler) Export(c *gin.Context) { writer := csv.NewWriter(&buf) // Write header - if err := writer.Write([]string{"id", "code", "type", "value", "status", "used_by", "used_by_email", "used_at", "created_at"}); err != nil { + if err := writer.Write([]string{"id", "code", "type", "value", "status", "used_by", "used_by_email", "used_at", "expires_at", "created_at"}); err != nil { response.InternalError(c, "Failed to export redeem codes: "+err.Error()) return } @@ -340,6 +386,10 @@ func (h *RedeemHandler) Export(c *gin.Context) { if code.UsedAt != nil { usedAt = code.UsedAt.Format("2006-01-02 15:04:05") } + expiresAt := "" + if code.ExpiresAt != nil { + expiresAt = code.ExpiresAt.Format("2006-01-02 15:04:05") + } if err := writer.Write([]string{ fmt.Sprintf("%d", code.ID), code.Code, @@ -349,6 +399,7 @@ func (h *RedeemHandler) Export(c *gin.Context) { usedBy, usedByEmail, usedAt, + expiresAt, code.CreatedAt.Format("2006-01-02 15:04:05"), }); err != nil { response.InternalError(c, "Failed to export redeem codes: "+err.Error()) diff --git a/backend/internal/handler/admin/redeem_handler_test.go b/backend/internal/handler/admin/redeem_handler_test.go index f1f7778f..d6972460 100644 --- a/backend/internal/handler/admin/redeem_handler_test.go +++ b/backend/internal/handler/admin/redeem_handler_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -139,3 +140,33 @@ func TestCreateAndRedeem_BalanceIgnoresSubscriptionFields(t *testing.T) { assert.NotEqual(t, http.StatusBadRequest, code, "balance type should not require group_id or validity_days") } + +func TestResolveRedeemCodeExpiresAt_FromDays(t *testing.T) { + days := 3 + expiresAt, err := resolveRedeemCodeExpiresAt(nil, &days) + require.NoError(t, err) + require.NotNil(t, expiresAt) + require.WithinDuration(t, time.Now().UTC().AddDate(0, 0, days), *expiresAt, 2*time.Second) +} + +func TestResolveRedeemCodeExpiresAt_RejectsPastAbsoluteTime(t *testing.T) { + past := time.Now().UTC().Add(-time.Minute) + expiresAt, err := resolveRedeemCodeExpiresAt(&past, nil) + require.Error(t, err) + require.Nil(t, expiresAt) +} + +func TestResolveRedeemCodeExpiresAt_RejectsNonPositiveDays(t *testing.T) { + days := 0 + expiresAt, err := resolveRedeemCodeExpiresAt(nil, &days) + require.Error(t, err) + require.Nil(t, expiresAt) +} + +func TestResolveRedeemCodeExpiresAt_RejectsConflictingInputs(t *testing.T) { + future := time.Now().UTC().Add(time.Hour) + days := 3 + expiresAt, err := resolveRedeemCodeExpiresAt(&future, &days) + require.Error(t, err) + require.Nil(t, expiresAt) +} diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index f4f1d036..b721de06 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -531,11 +531,15 @@ func redeemCodeFromServiceBase(rc *service.RedeemCode) RedeemCode { UsedBy: rc.UsedBy, UsedAt: rc.UsedAt, CreatedAt: rc.CreatedAt, + ExpiresAt: rc.ExpiresAt, GroupID: rc.GroupID, ValidityDays: rc.ValidityDays, User: UserFromServiceShallow(rc.User), Group: GroupFromServiceShallow(rc.Group), } + if rc.IsExpired() { + out.Status = service.StatusExpired + } // For admin_balance/admin_concurrency types, include notes so users can see // why they were charged or credited by admin diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 957d4fa3..2b816651 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -335,6 +335,7 @@ type RedeemCode struct { UsedBy *int64 `json:"used_by"` UsedAt *time.Time `json:"used_at"` CreatedAt time.Time `json:"created_at"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` GroupID *int64 `json:"group_id"` ValidityDays int `json:"validity_days"` diff --git a/backend/internal/repository/redeem_code_repo.go b/backend/internal/repository/redeem_code_repo.go index 07975970..47c38d3e 100644 --- a/backend/internal/repository/redeem_code_repo.go +++ b/backend/internal/repository/redeem_code_repo.go @@ -30,6 +30,7 @@ func (r *redeemCodeRepository) Create(ctx context.Context, code *service.RedeemC SetStatus(code.Status). SetNotes(code.Notes). SetValidityDays(code.ValidityDays). + SetNillableExpiresAt(code.ExpiresAt). SetNillableUsedBy(code.UsedBy). SetNillableUsedAt(code.UsedAt). SetNillableGroupID(code.GroupID). @@ -56,6 +57,7 @@ func (r *redeemCodeRepository) CreateBatch(ctx context.Context, codes []service. SetStatus(c.Status). SetNotes(c.Notes). SetValidityDays(c.ValidityDays). + SetNillableExpiresAt(c.ExpiresAt). SetNillableUsedBy(c.UsedBy). SetNillableUsedAt(c.UsedAt). SetNillableGroupID(c.GroupID) @@ -107,7 +109,28 @@ func (r *redeemCodeRepository) ListWithFilters(ctx context.Context, params pagin q = q.Where(redeemcode.TypeEQ(codeType)) } if status != "" { - q = q.Where(redeemcode.StatusEQ(status)) + now := time.Now() + switch status { + case service.StatusExpired: + q = q.Where(redeemcode.Or( + redeemcode.StatusEQ(service.StatusExpired), + redeemcode.And( + redeemcode.StatusEQ(service.StatusUnused), + redeemcode.ExpiresAtNotNil(), + redeemcode.ExpiresAtLTE(now), + ), + )) + case service.StatusUnused: + q = q.Where( + redeemcode.StatusEQ(service.StatusUnused), + redeemcode.Or( + redeemcode.ExpiresAtIsNil(), + redeemcode.ExpiresAtGT(now), + ), + ) + default: + q = q.Where(redeemcode.StatusEQ(status)) + } } if search != "" { q = q.Where( @@ -158,6 +181,8 @@ func redeemCodeListOrder(params pagination.PaginationParams) []func(*entsql.Sele field = redeemcode.FieldUsedAt case "created_at": field = redeemcode.FieldCreatedAt + case "expires_at": + field = redeemcode.FieldExpiresAt case "code": field = redeemcode.FieldCode default: @@ -194,6 +219,11 @@ func (r *redeemCodeRepository) Update(ctx context.Context, code *service.RedeemC } else { up.ClearGroupID() } + if code.ExpiresAt != nil { + up.SetExpiresAt(*code.ExpiresAt) + } else { + up.ClearExpiresAt() + } updated, err := up.Save(ctx) if err != nil { @@ -307,6 +337,7 @@ func redeemCodeEntityToService(m *dbent.RedeemCode) *service.RedeemCode { UsedAt: m.UsedAt, Notes: derefString(m.Notes), CreatedAt: m.CreatedAt, + ExpiresAt: m.ExpiresAt, GroupID: m.GroupID, ValidityDays: m.ValidityDays, } diff --git a/backend/internal/repository/redeem_code_repo_integration_test.go b/backend/internal/repository/redeem_code_repo_integration_test.go index 39674b52..24e5910e 100644 --- a/backend/internal/repository/redeem_code_repo_integration_test.go +++ b/backend/internal/repository/redeem_code_repo_integration_test.go @@ -51,11 +51,13 @@ func (s *RedeemCodeRepoSuite) createGroup(name string) *dbent.Group { // --- Create / CreateBatch / GetByID / GetByCode --- func (s *RedeemCodeRepoSuite) TestCreate() { + expiresAt := time.Now().UTC().Add(2 * time.Hour) code := &service.RedeemCode{ - Code: "TEST-CREATE", - Type: service.RedeemTypeBalance, - Value: 100, - Status: service.StatusUnused, + Code: "TEST-CREATE", + Type: service.RedeemTypeBalance, + Value: 100, + Status: service.StatusUnused, + ExpiresAt: &expiresAt, } err := s.repo.Create(s.ctx, code) @@ -65,6 +67,8 @@ func (s *RedeemCodeRepoSuite) TestCreate() { got, err := s.repo.GetByID(s.ctx, code.ID) s.Require().NoError(err, "GetByID") s.Require().Equal("TEST-CREATE", got.Code) + s.Require().NotNil(got.ExpiresAt) + s.Require().WithinDuration(expiresAt, *got.ExpiresAt, time.Second) } func (s *RedeemCodeRepoSuite) TestCreateBatch() { @@ -166,6 +170,23 @@ func (s *RedeemCodeRepoSuite) TestListWithFilters_Status() { s.Require().Equal(service.StatusUsed, codes[0].Status) } +func (s *RedeemCodeRepoSuite) TestListWithFilters_StatusExpiredByExpiresAt() { + past := time.Now().UTC().Add(-time.Hour) + future := time.Now().UTC().Add(time.Hour) + s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "STAT-EXPIRED-BY-TIME", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUnused, ExpiresAt: &past})) + s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "STAT-UNUSED-FUTURE", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUnused, ExpiresAt: &future})) + + expired, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, "", service.StatusExpired, "") + s.Require().NoError(err) + s.Require().Len(expired, 1) + s.Require().Equal("STAT-EXPIRED-BY-TIME", expired[0].Code) + + unused, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, "", service.StatusUnused, "") + s.Require().NoError(err) + s.Require().Len(unused, 1) + s.Require().Equal("STAT-UNUSED-FUTURE", unused[0].Code) +} + func (s *RedeemCodeRepoSuite) TestListWithFilters_Search() { s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "ALPHA-CODE", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUnused})) s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "BETA-CODE", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUnused})) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index eb5994d5..9cd60cfb 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -397,6 +397,7 @@ type GenerateRedeemCodesInput struct { Value float64 GroupID *int64 // 订阅类型专用:关联的分组ID ValidityDays int // 订阅类型专用:有效天数 + ExpiresAt *time.Time } type ProxyBatchDeleteResult struct { @@ -2966,6 +2967,10 @@ func (s *adminServiceImpl) GetRedeemCode(ctx context.Context, id int64) (*Redeem } func (s *adminServiceImpl) GenerateRedeemCodes(ctx context.Context, input *GenerateRedeemCodesInput) ([]RedeemCode, error) { + if input.ExpiresAt != nil && !input.ExpiresAt.After(time.Now()) { + return nil, ErrRedeemCodeExpired + } + // 如果是订阅类型,验证必须有 GroupID if input.Type == RedeemTypeSubscription { if input.GroupID == nil { @@ -2988,10 +2993,11 @@ func (s *adminServiceImpl) GenerateRedeemCodes(ctx context.Context, input *Gener return nil, err } code := RedeemCode{ - Code: codeValue, - Type: input.Type, - Value: input.Value, - Status: StatusUnused, + Code: codeValue, + Type: input.Type, + Value: input.Value, + Status: StatusUnused, + ExpiresAt: input.ExpiresAt, } // 订阅类型专用字段 if input.Type == RedeemTypeSubscription { diff --git a/backend/internal/service/auth_oauth_email_flow.go b/backend/internal/service/auth_oauth_email_flow.go index e3c8298c..30ad285a 100644 --- a/backend/internal/service/auth_oauth_email_flow.go +++ b/backend/internal/service/auth_oauth_email_flow.go @@ -71,7 +71,7 @@ func (s *AuthService) validateOAuthRegistrationInvitation(ctx context.Context, i if err != nil { return nil, ErrInvitationCodeInvalid } - if redeemCode.Type != RedeemTypeInvitation || redeemCode.Status != StatusUnused { + if redeemCode.Type != RedeemTypeInvitation || !redeemCode.CanUse() { return nil, ErrInvitationCodeInvalid } return redeemCode, nil @@ -358,6 +358,7 @@ func (s *AuthService) loadOAuthRegistrationInvitation(ctx context.Context, invit UsedAt: entity.UsedAt, Notes: oauthEmailFlowStringValue(entity.Notes), CreatedAt: entity.CreatedAt, + ExpiresAt: entity.ExpiresAt, GroupID: entity.GroupID, ValidityDays: entity.ValidityDays, }, nil @@ -368,7 +369,11 @@ func (s *AuthService) loadOAuthRegistrationInvitation(ctx context.Context, invit func (s *AuthService) useOAuthRegistrationInvitation(ctx context.Context, invitationID, userID int64) error { if client := s.oauthEmailFlowClient(ctx); client != nil { affected, err := client.RedeemCode.Update(). - Where(redeemcode.IDEQ(invitationID), redeemcode.StatusEQ(StatusUnused)). + Where( + redeemcode.IDEQ(invitationID), + redeemcode.StatusEQ(StatusUnused), + redeemcode.Or(redeemcode.ExpiresAtIsNil(), redeemcode.ExpiresAtGT(time.Now().UTC())), + ). SetStatus(StatusUsed). SetUsedBy(userID). SetUsedAt(time.Now().UTC()). @@ -396,6 +401,11 @@ func (s *AuthService) updateOAuthRegistrationInvitation(ctx context.Context, cod SetStatus(code.Status). SetNotes(code.Notes). SetValidityDays(code.ValidityDays) + if code.ExpiresAt != nil { + update = update.SetExpiresAt(*code.ExpiresAt) + } else { + update = update.ClearExpiresAt() + } if code.UsedBy != nil { update = update.SetUsedBy(*code.UsedBy) } else { diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index e01e8217..d2d7247e 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -157,7 +157,7 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw return "", nil, ErrInvitationCodeInvalid } // 检查类型和状态 - if redeemCode.Type != RedeemTypeInvitation || redeemCode.Status != StatusUnused { + if redeemCode.Type != RedeemTypeInvitation || !redeemCode.CanUse() { logger.LegacyPrintf("service.auth", "[Auth] Invitation code invalid: type=%s, status=%s", redeemCode.Type, redeemCode.Status) return "", nil, ErrInvitationCodeInvalid } @@ -601,7 +601,7 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema if err != nil { return nil, nil, ErrInvitationCodeInvalid } - if redeemCode.Type != RedeemTypeInvitation || redeemCode.Status != StatusUnused { + if redeemCode.Type != RedeemTypeInvitation || !redeemCode.CanUse() { return nil, nil, ErrInvitationCodeInvalid } invitationRedeemCode = redeemCode diff --git a/backend/internal/service/redeem_code.go b/backend/internal/service/redeem_code.go index a66b53ba..55abcfb3 100644 --- a/backend/internal/service/redeem_code.go +++ b/backend/internal/service/redeem_code.go @@ -16,6 +16,7 @@ type RedeemCode struct { UsedAt *time.Time Notes string CreatedAt time.Time + ExpiresAt *time.Time GroupID *int64 ValidityDays int @@ -28,8 +29,22 @@ func (r *RedeemCode) IsUsed() bool { return r.Status == StatusUsed } +func (r *RedeemCode) IsExpired() bool { + return r.IsExpiredAt(time.Now()) +} + +func (r *RedeemCode) IsExpiredAt(now time.Time) bool { + if r == nil { + return false + } + if r.Status == StatusExpired { + return true + } + return r.Status == StatusUnused && r.ExpiresAt != nil && !r.ExpiresAt.After(now) +} + func (r *RedeemCode) CanUse() bool { - return r.Status == StatusUnused + return r.Status == StatusUnused && !r.IsExpired() } func GenerateRedeemCode() (string, error) { diff --git a/backend/internal/service/redeem_code_test.go b/backend/internal/service/redeem_code_test.go new file mode 100644 index 00000000..ba5c7e7c --- /dev/null +++ b/backend/internal/service/redeem_code_test.go @@ -0,0 +1,59 @@ +package service + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestRedeemCodeExpiry(t *testing.T) { + now := time.Now().UTC() + past := now.Add(-time.Hour) + future := now.Add(time.Hour) + + tests := []struct { + name string + code RedeemCode + wantExpired bool + wantCanUse bool + }{ + { + name: "unused without expiry can be used", + code: RedeemCode{Status: StatusUnused}, + wantExpired: false, + wantCanUse: true, + }, + { + name: "unused before expiry can be used", + code: RedeemCode{Status: StatusUnused, ExpiresAt: &future}, + wantExpired: false, + wantCanUse: true, + }, + { + name: "unused after expiry cannot be used", + code: RedeemCode{Status: StatusUnused, ExpiresAt: &past}, + wantExpired: true, + wantCanUse: false, + }, + { + name: "explicit expired status is expired", + code: RedeemCode{Status: StatusExpired}, + wantExpired: true, + wantCanUse: false, + }, + { + name: "used code remains used even after expiry time", + code: RedeemCode{Status: StatusUsed, ExpiresAt: &past}, + wantExpired: false, + wantCanUse: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.wantExpired, tt.code.IsExpiredAt(now)) + require.Equal(t, tt.wantCanUse, tt.code.CanUse()) + }) + } +} diff --git a/backend/internal/service/redeem_service.go b/backend/internal/service/redeem_service.go index dcf293c5..73aa02b1 100644 --- a/backend/internal/service/redeem_service.go +++ b/backend/internal/service/redeem_service.go @@ -18,6 +18,7 @@ import ( var ( ErrRedeemCodeNotFound = infraerrors.NotFound("REDEEM_CODE_NOT_FOUND", "redeem code not found") ErrRedeemCodeUsed = infraerrors.Conflict("REDEEM_CODE_USED", "redeem code already used") + ErrRedeemCodeExpired = infraerrors.Conflict("REDEEM_CODE_EXPIRED", "redeem code expired") ErrInsufficientBalance = infraerrors.BadRequest("INSUFFICIENT_BALANCE", "insufficient balance") ErrRedeemRateLimited = infraerrors.TooManyRequests("REDEEM_RATE_LIMITED", "too many failed attempts, please try again later") ErrRedeemCodeLocked = infraerrors.Conflict("REDEEM_CODE_LOCKED", "redeem code is being processed, please try again") @@ -207,6 +208,9 @@ func (s *RedeemService) CreateCode(ctx context.Context, code *RedeemCode) error if code.Status == "" { code.Status = StatusUnused } + if code.IsExpired() { + return ErrRedeemCodeExpired + } if err := s.redeemRepo.Create(ctx, code); err != nil { return fmt.Errorf("create redeem code: %w", err) @@ -289,7 +293,11 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) ( return nil, fmt.Errorf("get redeem code: %w", err) } - // 检查兑换码状态 + // 检查兑换码状态和码本身的过期时间 + if redeemCode.IsExpired() { + s.incrementRedeemErrorCount(ctx, userID) + return nil, ErrRedeemCodeExpired + } if !redeemCode.CanUse() { s.incrementRedeemErrorCount(ctx, userID) return nil, ErrRedeemCodeUsed diff --git a/backend/migrations/137_redeem_code_expires_at.sql b/backend/migrations/137_redeem_code_expires_at.sql new file mode 100644 index 00000000..4fa27927 --- /dev/null +++ b/backend/migrations/137_redeem_code_expires_at.sql @@ -0,0 +1,8 @@ +-- Add optional expiry time for redeem codes themselves. +-- `validity_days` remains the subscription duration granted after redeeming. + +ALTER TABLE redeem_codes + ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_redeem_codes_expires_at + ON redeem_codes (expires_at); diff --git a/frontend/src/api/admin/redeem.ts b/frontend/src/api/admin/redeem.ts index 57626b1e..398d68a4 100644 --- a/frontend/src/api/admin/redeem.ts +++ b/frontend/src/api/admin/redeem.ts @@ -60,6 +60,7 @@ export async function getById(id: number): Promise { * @param value - Value of the code * @param groupId - Group ID (required for subscription type) * @param validityDays - Validity days (for subscription type) + * @param expiresInDays - Days before the code itself expires * @returns Array of generated redeem codes */ export async function generate( @@ -67,7 +68,8 @@ export async function generate( type: RedeemCodeType, value: number, groupId?: number | null, - validityDays?: number + validityDays?: number, + expiresInDays?: number | null ): Promise { const payload: GenerateRedeemCodesRequest = { count, @@ -82,6 +84,9 @@ export async function generate( payload.validity_days = validityDays } } + if (expiresInDays && expiresInDays > 0) { + payload.expires_in_days = expiresInDays + } const { data } = await apiClient.post('/admin/redeem-codes/generate', payload) return data diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index eb373e16..1e607ec2 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4069,6 +4069,7 @@ export default { status: 'Status', usedBy: 'Used By', usedAt: 'Used At', + expiresAt: 'Expires At', actions: 'Actions' }, userPrefix: 'User #{id}', @@ -4114,6 +4115,12 @@ export default { selectGroup: 'Select Group', selectGroupPlaceholder: 'Choose a subscription group', validityDays: 'Validity Days', + codeExpiry: 'Code Expiry', + neverExpires: 'Never expires', + expiryPresetDays: '{days} days', + customExpiry: 'Custom', + customExpiryDays: 'Custom days', + expiryDaysRequired: 'Please enter a valid expiry day count', groupRequired: 'Please select a subscription group', days: ' days', status: { diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index ca3b4879..be1dec1a 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -4183,6 +4183,7 @@ export default { status: '状态', usedBy: '使用者', usedAt: '使用时间', + expiresAt: '过期时间', createdAt: '创建时间', actions: '操作' }, @@ -4232,6 +4233,12 @@ export default { selectGroup: '选择分组', selectGroupPlaceholder: '选择订阅分组', validityDays: '有效天数', + codeExpiry: '兑换码过期', + neverExpires: '永不过期', + expiryPresetDays: '{days} 天', + customExpiry: '自定义', + customExpiryDays: '自定义天数', + expiryDaysRequired: '请输入有效的过期天数', groupRequired: '请选择订阅分组', days: '天', status: { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 94c6da57..bbaf047d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1287,6 +1287,7 @@ export interface RedeemCode { used_by: number | null used_at: string | null created_at: string + expires_at?: string | null updated_at?: string group_id?: number | null // 订阅类型专用 validity_days?: number // 订阅类型专用 @@ -1300,6 +1301,8 @@ export interface GenerateRedeemCodesRequest { value: number group_id?: number | null // 订阅类型专用 validity_days?: number // 订阅类型专用 + expires_at?: string | null + expires_in_days?: number } export interface RedeemCodeRequest { diff --git a/frontend/src/views/admin/RedeemView.vue b/frontend/src/views/admin/RedeemView.vue index 0fc8a24f..b8e0e936 100644 --- a/frontend/src/views/admin/RedeemView.vue +++ b/frontend/src/views/admin/RedeemView.vue @@ -137,6 +137,19 @@ }} + + +
+ +
+ +
+ +
(() => [ { key: 'status', label: t('admin.redeem.columns.status'), sortable: true }, { key: 'used_by', label: t('admin.redeem.columns.usedBy') }, { key: 'used_at', label: t('admin.redeem.columns.usedAt'), sortable: true }, + { key: 'expires_at', label: t('admin.redeem.columns.expiresAt'), sortable: true }, { key: 'actions', label: t('admin.redeem.columns.actions') } ]) @@ -555,12 +598,24 @@ const showDeleteUnusedDialog = ref(false) const deletingCode = ref(null) const copiedCode = ref(null) +type RedeemCodeExpiryOption = 'never' | '1' | '3' | '7' | 'custom' + +const redeemCodeExpiryOptions = computed<{ value: RedeemCodeExpiryOption; label: string }[]>(() => [ + { value: 'never', label: t('admin.redeem.neverExpires') }, + { value: '1', label: t('admin.redeem.expiryPresetDays', { days: 1 }) }, + { value: '3', label: t('admin.redeem.expiryPresetDays', { days: 3 }) }, + { value: '7', label: t('admin.redeem.expiryPresetDays', { days: 7 }) }, + { value: 'custom', label: t('admin.redeem.customExpiry') } +]) + const generateForm = reactive({ type: 'balance' as RedeemCodeType, value: 10, count: 1, group_id: null as number | null, - validity_days: 30 + validity_days: 30, + expiry_option: 'never' as RedeemCodeExpiryOption, + custom_expiry_days: 7 }) // 监听类型变化,邀请码类型时自动设置 value 为 0 @@ -650,6 +705,22 @@ const handleSort = (key: string, order: 'asc' | 'desc') => { loadCodes() } +const getRedeemCodeExpiresInDays = () => { + if (generateForm.expiry_option === 'never') { + return undefined + } + if (generateForm.expiry_option === 'custom') { + if ( + !Number.isFinite(generateForm.custom_expiry_days) || + generateForm.custom_expiry_days < 1 + ) { + return null + } + return Math.floor(generateForm.custom_expiry_days) + } + return Number(generateForm.expiry_option) +} + const handleGenerateCodes = async () => { // 订阅类型必须选择分组 if (generateForm.type === 'subscription' && !generateForm.group_id) { @@ -657,6 +728,12 @@ const handleGenerateCodes = async () => { return } + const expiresInDays = getRedeemCodeExpiresInDays() + if (expiresInDays === null) { + appStore.showError(t('admin.redeem.expiryDaysRequired')) + return + } + generating.value = true try { const result = await adminAPI.redeem.generate( @@ -664,7 +741,8 @@ const handleGenerateCodes = async () => { generateForm.type, generateForm.value, generateForm.type === 'subscription' ? generateForm.group_id : undefined, - generateForm.type === 'subscription' ? generateForm.validity_days : undefined + generateForm.type === 'subscription' ? generateForm.validity_days : undefined, + expiresInDays ) showGenerateDialog.value = false generatedCodes.value = result @@ -672,6 +750,8 @@ const handleGenerateCodes = async () => { // 重置表单 generateForm.group_id = null generateForm.validity_days = 30 + generateForm.expiry_option = 'never' + generateForm.custom_expiry_days = 7 loadCodes() } catch (error: any) { appStore.showError(error.response?.data?.detail || t('admin.redeem.failedToGenerate'))