Merge pull request #2573 from wucm667/feat/redeem-code-expiry

feat(redeem): 兑换码支持设置使用有效期
This commit is contained in:
Wesley Liddick 2026-05-19 16:25:12 +08:00 committed by GitHub
commit 2a242aec0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 676 additions and 38 deletions

View File

@ -1120,6 +1120,7 @@ var (
{Name: "used_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, {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: "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: "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: "validity_days", Type: field.TypeInt, Default: 30},
{Name: "group_id", Type: field.TypeInt64, Nullable: true}, {Name: "group_id", Type: field.TypeInt64, Nullable: true},
{Name: "used_by", Type: field.TypeInt64, Nullable: true}, {Name: "used_by", Type: field.TypeInt64, Nullable: true},
@ -1132,13 +1133,13 @@ var (
ForeignKeys: []*schema.ForeignKey{ ForeignKeys: []*schema.ForeignKey{
{ {
Symbol: "redeem_codes_groups_redeem_codes", Symbol: "redeem_codes_groups_redeem_codes",
Columns: []*schema.Column{RedeemCodesColumns[9]}, Columns: []*schema.Column{RedeemCodesColumns[10]},
RefColumns: []*schema.Column{GroupsColumns[0]}, RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.SetNull, OnDelete: schema.SetNull,
}, },
{ {
Symbol: "redeem_codes_users_redeem_codes", Symbol: "redeem_codes_users_redeem_codes",
Columns: []*schema.Column{RedeemCodesColumns[10]}, Columns: []*schema.Column{RedeemCodesColumns[11]},
RefColumns: []*schema.Column{UsersColumns[0]}, RefColumns: []*schema.Column{UsersColumns[0]},
OnDelete: schema.SetNull, OnDelete: schema.SetNull,
}, },
@ -1152,12 +1153,17 @@ var (
{ {
Name: "redeemcode_used_by", Name: "redeemcode_used_by",
Unique: false, Unique: false,
Columns: []*schema.Column{RedeemCodesColumns[10]}, Columns: []*schema.Column{RedeemCodesColumns[11]},
}, },
{ {
Name: "redeemcode_group_id", Name: "redeemcode_group_id",
Unique: false, Unique: false,
Columns: []*schema.Column{RedeemCodesColumns[9]}, Columns: []*schema.Column{RedeemCodesColumns[10]},
},
{
Name: "redeemcode_expires_at",
Unique: false,
Columns: []*schema.Column{RedeemCodesColumns[8]},
}, },
}, },
} }

View File

@ -28602,6 +28602,7 @@ type RedeemCodeMutation struct {
used_at *time.Time used_at *time.Time
notes *string notes *string
created_at *time.Time created_at *time.Time
expires_at *time.Time
validity_days *int validity_days *int
addvalidity_days *int addvalidity_days *int
clearedFields map[string]struct{} clearedFields map[string]struct{}
@ -29059,6 +29060,55 @@ func (m *RedeemCodeMutation) ResetCreatedAt() {
m.created_at = nil 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. // SetGroupID sets the "group_id" field.
func (m *RedeemCodeMutation) SetGroupID(i int64) { func (m *RedeemCodeMutation) SetGroupID(i int64) {
m.group = &i m.group = &i
@ -29265,7 +29315,7 @@ func (m *RedeemCodeMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call // order to get all numeric fields that were incremented/decremented, call
// AddedFields(). // AddedFields().
func (m *RedeemCodeMutation) Fields() []string { func (m *RedeemCodeMutation) Fields() []string {
fields := make([]string, 0, 10) fields := make([]string, 0, 11)
if m.code != nil { if m.code != nil {
fields = append(fields, redeemcode.FieldCode) fields = append(fields, redeemcode.FieldCode)
} }
@ -29290,6 +29340,9 @@ func (m *RedeemCodeMutation) Fields() []string {
if m.created_at != nil { if m.created_at != nil {
fields = append(fields, redeemcode.FieldCreatedAt) fields = append(fields, redeemcode.FieldCreatedAt)
} }
if m.expires_at != nil {
fields = append(fields, redeemcode.FieldExpiresAt)
}
if m.group != nil { if m.group != nil {
fields = append(fields, redeemcode.FieldGroupID) fields = append(fields, redeemcode.FieldGroupID)
} }
@ -29320,6 +29373,8 @@ func (m *RedeemCodeMutation) Field(name string) (ent.Value, bool) {
return m.Notes() return m.Notes()
case redeemcode.FieldCreatedAt: case redeemcode.FieldCreatedAt:
return m.CreatedAt() return m.CreatedAt()
case redeemcode.FieldExpiresAt:
return m.ExpiresAt()
case redeemcode.FieldGroupID: case redeemcode.FieldGroupID:
return m.GroupID() return m.GroupID()
case redeemcode.FieldValidityDays: case redeemcode.FieldValidityDays:
@ -29349,6 +29404,8 @@ func (m *RedeemCodeMutation) OldField(ctx context.Context, name string) (ent.Val
return m.OldNotes(ctx) return m.OldNotes(ctx)
case redeemcode.FieldCreatedAt: case redeemcode.FieldCreatedAt:
return m.OldCreatedAt(ctx) return m.OldCreatedAt(ctx)
case redeemcode.FieldExpiresAt:
return m.OldExpiresAt(ctx)
case redeemcode.FieldGroupID: case redeemcode.FieldGroupID:
return m.OldGroupID(ctx) return m.OldGroupID(ctx)
case redeemcode.FieldValidityDays: case redeemcode.FieldValidityDays:
@ -29418,6 +29475,13 @@ func (m *RedeemCodeMutation) SetField(name string, value ent.Value) error {
} }
m.SetCreatedAt(v) m.SetCreatedAt(v)
return nil 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: case redeemcode.FieldGroupID:
v, ok := value.(int64) v, ok := value.(int64)
if !ok { if !ok {
@ -29498,6 +29562,9 @@ func (m *RedeemCodeMutation) ClearedFields() []string {
if m.FieldCleared(redeemcode.FieldNotes) { if m.FieldCleared(redeemcode.FieldNotes) {
fields = append(fields, redeemcode.FieldNotes) fields = append(fields, redeemcode.FieldNotes)
} }
if m.FieldCleared(redeemcode.FieldExpiresAt) {
fields = append(fields, redeemcode.FieldExpiresAt)
}
if m.FieldCleared(redeemcode.FieldGroupID) { if m.FieldCleared(redeemcode.FieldGroupID) {
fields = append(fields, redeemcode.FieldGroupID) fields = append(fields, redeemcode.FieldGroupID)
} }
@ -29524,6 +29591,9 @@ func (m *RedeemCodeMutation) ClearField(name string) error {
case redeemcode.FieldNotes: case redeemcode.FieldNotes:
m.ClearNotes() m.ClearNotes()
return nil return nil
case redeemcode.FieldExpiresAt:
m.ClearExpiresAt()
return nil
case redeemcode.FieldGroupID: case redeemcode.FieldGroupID:
m.ClearGroupID() m.ClearGroupID()
return nil return nil
@ -29559,6 +29629,9 @@ func (m *RedeemCodeMutation) ResetField(name string) error {
case redeemcode.FieldCreatedAt: case redeemcode.FieldCreatedAt:
m.ResetCreatedAt() m.ResetCreatedAt()
return nil return nil
case redeemcode.FieldExpiresAt:
m.ResetExpiresAt()
return nil
case redeemcode.FieldGroupID: case redeemcode.FieldGroupID:
m.ResetGroupID() m.ResetGroupID()
return nil return nil

View File

@ -35,6 +35,8 @@ type RedeemCode struct {
Notes *string `json:"notes,omitempty"` Notes *string `json:"notes,omitempty"`
// CreatedAt holds the value of the "created_at" field. // CreatedAt holds the value of the "created_at" field.
CreatedAt time.Time `json:"created_at,omitempty"` 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 holds the value of the "group_id" field.
GroupID *int64 `json:"group_id,omitempty"` GroupID *int64 `json:"group_id,omitempty"`
// ValidityDays holds the value of the "validity_days" field. // 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) values[i] = new(sql.NullInt64)
case redeemcode.FieldCode, redeemcode.FieldType, redeemcode.FieldStatus, redeemcode.FieldNotes: case redeemcode.FieldCode, redeemcode.FieldType, redeemcode.FieldStatus, redeemcode.FieldNotes:
values[i] = new(sql.NullString) values[i] = new(sql.NullString)
case redeemcode.FieldUsedAt, redeemcode.FieldCreatedAt: case redeemcode.FieldUsedAt, redeemcode.FieldCreatedAt, redeemcode.FieldExpiresAt:
values[i] = new(sql.NullTime) values[i] = new(sql.NullTime)
default: default:
values[i] = new(sql.UnknownType) values[i] = new(sql.UnknownType)
@ -163,6 +165,13 @@ func (_m *RedeemCode) assignValues(columns []string, values []any) error {
} else if value.Valid { } else if value.Valid {
_m.CreatedAt = value.Time _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: case redeemcode.FieldGroupID:
if value, ok := values[i].(*sql.NullInt64); !ok { if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field group_id", values[i]) 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("created_at=")
builder.WriteString(_m.CreatedAt.Format(time.ANSIC)) builder.WriteString(_m.CreatedAt.Format(time.ANSIC))
builder.WriteString(", ") 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 { if v := _m.GroupID; v != nil {
builder.WriteString("group_id=") builder.WriteString("group_id=")
builder.WriteString(fmt.Sprintf("%v", *v)) builder.WriteString(fmt.Sprintf("%v", *v))

View File

@ -30,6 +30,8 @@ const (
FieldNotes = "notes" FieldNotes = "notes"
// FieldCreatedAt holds the string denoting the created_at field in the database. // FieldCreatedAt holds the string denoting the created_at field in the database.
FieldCreatedAt = "created_at" 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 holds the string denoting the group_id field in the database.
FieldGroupID = "group_id" FieldGroupID = "group_id"
// FieldValidityDays holds the string denoting the validity_days field in the database. // FieldValidityDays holds the string denoting the validity_days field in the database.
@ -67,6 +69,7 @@ var Columns = []string{
FieldUsedAt, FieldUsedAt,
FieldNotes, FieldNotes,
FieldCreatedAt, FieldCreatedAt,
FieldExpiresAt,
FieldGroupID, FieldGroupID,
FieldValidityDays, FieldValidityDays,
} }
@ -148,6 +151,11 @@ func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldCreatedAt, opts...).ToFunc() 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. // ByGroupID orders the results by the group_id field.
func ByGroupID(opts ...sql.OrderTermOption) OrderOption { func ByGroupID(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldGroupID, opts...).ToFunc() return sql.OrderByField(FieldGroupID, opts...).ToFunc()

View File

@ -95,6 +95,11 @@ func CreatedAt(v time.Time) predicate.RedeemCode {
return predicate.RedeemCode(sql.FieldEQ(FieldCreatedAt, v)) 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. // GroupID applies equality check predicate on the "group_id" field. It's identical to GroupIDEQ.
func GroupID(v int64) predicate.RedeemCode { func GroupID(v int64) predicate.RedeemCode {
return predicate.RedeemCode(sql.FieldEQ(FieldGroupID, v)) 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)) 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. // GroupIDEQ applies the EQ predicate on the "group_id" field.
func GroupIDEQ(v int64) predicate.RedeemCode { func GroupIDEQ(v int64) predicate.RedeemCode {
return predicate.RedeemCode(sql.FieldEQ(FieldGroupID, v)) return predicate.RedeemCode(sql.FieldEQ(FieldGroupID, v))

View File

@ -128,6 +128,20 @@ func (_c *RedeemCodeCreate) SetNillableCreatedAt(v *time.Time) *RedeemCodeCreate
return _c 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. // SetGroupID sets the "group_id" field.
func (_c *RedeemCodeCreate) SetGroupID(v int64) *RedeemCodeCreate { func (_c *RedeemCodeCreate) SetGroupID(v int64) *RedeemCodeCreate {
_c.mutation.SetGroupID(v) _c.mutation.SetGroupID(v)
@ -327,6 +341,10 @@ func (_c *RedeemCodeCreate) createSpec() (*RedeemCode, *sqlgraph.CreateSpec) {
_spec.SetField(redeemcode.FieldCreatedAt, field.TypeTime, value) _spec.SetField(redeemcode.FieldCreatedAt, field.TypeTime, value)
_node.CreatedAt = 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 { if value, ok := _c.mutation.ValidityDays(); ok {
_spec.SetField(redeemcode.FieldValidityDays, field.TypeInt, value) _spec.SetField(redeemcode.FieldValidityDays, field.TypeInt, value)
_node.ValidityDays = value _node.ValidityDays = value
@ -525,6 +543,24 @@ func (u *RedeemCodeUpsert) ClearNotes() *RedeemCodeUpsert {
return u 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. // SetGroupID sets the "group_id" field.
func (u *RedeemCodeUpsert) SetGroupID(v int64) *RedeemCodeUpsert { func (u *RedeemCodeUpsert) SetGroupID(v int64) *RedeemCodeUpsert {
u.Set(redeemcode.FieldGroupID, v) 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. // SetGroupID sets the "group_id" field.
func (u *RedeemCodeUpsertOne) SetGroupID(v int64) *RedeemCodeUpsertOne { func (u *RedeemCodeUpsertOne) SetGroupID(v int64) *RedeemCodeUpsertOne {
return u.Update(func(s *RedeemCodeUpsert) { 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. // SetGroupID sets the "group_id" field.
func (u *RedeemCodeUpsertBulk) SetGroupID(v int64) *RedeemCodeUpsertBulk { func (u *RedeemCodeUpsertBulk) SetGroupID(v int64) *RedeemCodeUpsertBulk {
return u.Update(func(s *RedeemCodeUpsert) { return u.Update(func(s *RedeemCodeUpsert) {

View File

@ -153,6 +153,26 @@ func (_u *RedeemCodeUpdate) ClearNotes() *RedeemCodeUpdate {
return _u 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. // SetGroupID sets the "group_id" field.
func (_u *RedeemCodeUpdate) SetGroupID(v int64) *RedeemCodeUpdate { func (_u *RedeemCodeUpdate) SetGroupID(v int64) *RedeemCodeUpdate {
_u.mutation.SetGroupID(v) _u.mutation.SetGroupID(v)
@ -321,6 +341,12 @@ func (_u *RedeemCodeUpdate) sqlSave(ctx context.Context) (_node int, err error)
if _u.mutation.NotesCleared() { if _u.mutation.NotesCleared() {
_spec.ClearField(redeemcode.FieldNotes, field.TypeString) _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 { if value, ok := _u.mutation.ValidityDays(); ok {
_spec.SetField(redeemcode.FieldValidityDays, field.TypeInt, value) _spec.SetField(redeemcode.FieldValidityDays, field.TypeInt, value)
} }
@ -528,6 +554,26 @@ func (_u *RedeemCodeUpdateOne) ClearNotes() *RedeemCodeUpdateOne {
return _u 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. // SetGroupID sets the "group_id" field.
func (_u *RedeemCodeUpdateOne) SetGroupID(v int64) *RedeemCodeUpdateOne { func (_u *RedeemCodeUpdateOne) SetGroupID(v int64) *RedeemCodeUpdateOne {
_u.mutation.SetGroupID(v) _u.mutation.SetGroupID(v)
@ -726,6 +772,12 @@ func (_u *RedeemCodeUpdateOne) sqlSave(ctx context.Context) (_node *RedeemCode,
if _u.mutation.NotesCleared() { if _u.mutation.NotesCleared() {
_spec.ClearField(redeemcode.FieldNotes, field.TypeString) _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 { if value, ok := _u.mutation.ValidityDays(); ok {
_spec.SetField(redeemcode.FieldValidityDays, field.TypeInt, value) _spec.SetField(redeemcode.FieldValidityDays, field.TypeInt, value)
} }

View File

@ -1386,7 +1386,7 @@ func init() {
// redeemcode.DefaultCreatedAt holds the default value on creation for the created_at field. // redeemcode.DefaultCreatedAt holds the default value on creation for the created_at field.
redeemcode.DefaultCreatedAt = redeemcodeDescCreatedAt.Default.(func() time.Time) redeemcode.DefaultCreatedAt = redeemcodeDescCreatedAt.Default.(func() time.Time)
// redeemcodeDescValidityDays is the schema descriptor for validity_days field. // 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 holds the default value on creation for the validity_days field.
redeemcode.DefaultValidityDays = redeemcodeDescValidityDays.Default.(int) redeemcode.DefaultValidityDays = redeemcodeDescValidityDays.Default.(int)
securitysecretMixin := schema.SecuritySecret{}.Mixin() securitysecretMixin := schema.SecuritySecret{}.Mixin()

View File

@ -63,6 +63,10 @@ func (RedeemCode) Fields() []ent.Field {
Immutable(). Immutable().
Default(time.Now). Default(time.Now).
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}), SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
field.Time("expires_at").
Optional().
Nillable().
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
field.Int64("group_id"). field.Int64("group_id").
Optional(). Optional().
Nillable(), Nillable(),
@ -90,5 +94,6 @@ func (RedeemCode) Indexes() []ent.Index {
index.Fields("status"), index.Fields("status"),
index.Fields("used_by"), index.Fields("used_by"),
index.Fields("group_id"), index.Fields("group_id"),
index.Fields("expires_at"),
} }
} }

View File

@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/handler/dto"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" 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 // GenerateRedeemCodesRequest represents generate redeem codes request
type GenerateRedeemCodesRequest struct { type GenerateRedeemCodesRequest struct {
Count int `json:"count" binding:"required,min=1,max=100"` Count int `json:"count" binding:"required,min=1,max=100"`
Type string `json:"type" binding:"required,oneof=balance concurrency subscription invitation"` Type string `json:"type" binding:"required,oneof=balance concurrency subscription invitation"`
Value float64 `json:"value"` Value float64 `json:"value"`
GroupID *int64 `json:"group_id"` // 订阅类型必填 GroupID *int64 `json:"group_id"` // 订阅类型必填
ValidityDays int `json:"validity_days"` // 订阅类型使用,正数增加/负数退款扣减 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. // CreateAndRedeemCodeRequest represents creating a fixed code and redeeming it for a target user.
// Type 为 omitempty 而非 required 是为了向后兼容旧版调用方(不传 type 时默认 balance // Type 为 omitempty 而非 required 是为了向后兼容旧版调用方(不传 type 时默认 balance
type CreateAndRedeemCodeRequest struct { type CreateAndRedeemCodeRequest struct {
Code string `json:"code" binding:"required,min=3,max=128"` Code string `json:"code" binding:"required,min=3,max=128"`
Type string `json:"type" binding:"omitempty,oneof=balance concurrency subscription invitation"` // 不传时默认 balance向后兼容 Type string `json:"type" binding:"omitempty,oneof=balance concurrency subscription invitation"` // 不传时默认 balance向后兼容
Value float64 `json:"value" binding:"required"` Value float64 `json:"value" binding:"required"`
UserID int64 `json:"user_id" binding:"required,gt=0"` UserID int64 `json:"user_id" binding:"required,gt=0"`
GroupID *int64 `json:"group_id"` // subscription 类型必填 GroupID *int64 `json:"group_id"` // subscription 类型必填
ValidityDays int `json:"validity_days"` // subscription 类型:正数增加,负数退款扣减 ValidityDays int `json:"validity_days"` // subscription 类型:正数增加,负数退款扣减
Notes string `json:"notes"` 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 // List handles listing all redeem codes with pagination
@ -107,6 +136,12 @@ func (h *RedeemHandler) Generate(c *gin.Context) {
return 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) { executeAdminIdempotentJSON(c, "admin.redeem_codes.generate", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
codes, execErr := h.adminService.GenerateRedeemCodes(ctx, &service.GenerateRedeemCodesInput{ codes, execErr := h.adminService.GenerateRedeemCodes(ctx, &service.GenerateRedeemCodesInput{
Count: req.Count, Count: req.Count,
@ -114,6 +149,7 @@ func (h *RedeemHandler) Generate(c *gin.Context) {
Value: req.Value, Value: req.Value,
GroupID: req.GroupID, GroupID: req.GroupID,
ValidityDays: req.ValidityDays, ValidityDays: req.ValidityDays,
ExpiresAt: expiresAt,
}) })
if execErr != nil { if execErr != nil {
return nil, execErr 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) { 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) existing, err := h.redeemService.GetByCode(ctx, req.Code)
if err == nil { if err == nil {
@ -175,6 +217,7 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) {
Notes: req.Notes, Notes: req.Notes,
GroupID: req.GroupID, GroupID: req.GroupID,
ValidityDays: req.ValidityDays, ValidityDays: req.ValidityDays,
ExpiresAt: expiresAt,
}) })
if createErr != nil { if createErr != nil {
// Unique code race: if code now exists, use idempotent semantics by used_by. // 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 previous run created the code but crashed before redeem, redeem it now.
if existing.IsExpired() {
return nil, service.ErrRedeemCodeExpired
}
if existing.CanUse() { if existing.CanUse() {
redeemed, err := h.redeemService.Redeem(ctx, userID, existing.Code) redeemed, err := h.redeemService.Redeem(ctx, userID, existing.Code)
if err == nil { if err == nil {
@ -321,7 +367,7 @@ func (h *RedeemHandler) Export(c *gin.Context) {
writer := csv.NewWriter(&buf) writer := csv.NewWriter(&buf)
// Write header // 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()) response.InternalError(c, "Failed to export redeem codes: "+err.Error())
return return
} }
@ -340,6 +386,10 @@ func (h *RedeemHandler) Export(c *gin.Context) {
if code.UsedAt != nil { if code.UsedAt != nil {
usedAt = code.UsedAt.Format("2006-01-02 15:04:05") 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{ if err := writer.Write([]string{
fmt.Sprintf("%d", code.ID), fmt.Sprintf("%d", code.ID),
code.Code, code.Code,
@ -349,6 +399,7 @@ func (h *RedeemHandler) Export(c *gin.Context) {
usedBy, usedBy,
usedByEmail, usedByEmail,
usedAt, usedAt,
expiresAt,
code.CreatedAt.Format("2006-01-02 15:04:05"), code.CreatedAt.Format("2006-01-02 15:04:05"),
}); err != nil { }); err != nil {
response.InternalError(c, "Failed to export redeem codes: "+err.Error()) response.InternalError(c, "Failed to export redeem codes: "+err.Error())

View File

@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -139,3 +140,33 @@ func TestCreateAndRedeem_BalanceIgnoresSubscriptionFields(t *testing.T) {
assert.NotEqual(t, http.StatusBadRequest, code, assert.NotEqual(t, http.StatusBadRequest, code,
"balance type should not require group_id or validity_days") "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)
}

View File

@ -533,11 +533,15 @@ func redeemCodeFromServiceBase(rc *service.RedeemCode) RedeemCode {
UsedBy: rc.UsedBy, UsedBy: rc.UsedBy,
UsedAt: rc.UsedAt, UsedAt: rc.UsedAt,
CreatedAt: rc.CreatedAt, CreatedAt: rc.CreatedAt,
ExpiresAt: rc.ExpiresAt,
GroupID: rc.GroupID, GroupID: rc.GroupID,
ValidityDays: rc.ValidityDays, ValidityDays: rc.ValidityDays,
User: UserFromServiceShallow(rc.User), User: UserFromServiceShallow(rc.User),
Group: GroupFromServiceShallow(rc.Group), Group: GroupFromServiceShallow(rc.Group),
} }
if rc.IsExpired() {
out.Status = service.StatusExpired
}
// For admin_balance/admin_concurrency types, include notes so users can see // For admin_balance/admin_concurrency types, include notes so users can see
// why they were charged or credited by admin // why they were charged or credited by admin

View File

@ -338,6 +338,7 @@ type RedeemCode struct {
UsedBy *int64 `json:"used_by"` UsedBy *int64 `json:"used_by"`
UsedAt *time.Time `json:"used_at"` UsedAt *time.Time `json:"used_at"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
GroupID *int64 `json:"group_id"` GroupID *int64 `json:"group_id"`
ValidityDays int `json:"validity_days"` ValidityDays int `json:"validity_days"`

View File

@ -30,6 +30,7 @@ func (r *redeemCodeRepository) Create(ctx context.Context, code *service.RedeemC
SetStatus(code.Status). SetStatus(code.Status).
SetNotes(code.Notes). SetNotes(code.Notes).
SetValidityDays(code.ValidityDays). SetValidityDays(code.ValidityDays).
SetNillableExpiresAt(code.ExpiresAt).
SetNillableUsedBy(code.UsedBy). SetNillableUsedBy(code.UsedBy).
SetNillableUsedAt(code.UsedAt). SetNillableUsedAt(code.UsedAt).
SetNillableGroupID(code.GroupID). SetNillableGroupID(code.GroupID).
@ -56,6 +57,7 @@ func (r *redeemCodeRepository) CreateBatch(ctx context.Context, codes []service.
SetStatus(c.Status). SetStatus(c.Status).
SetNotes(c.Notes). SetNotes(c.Notes).
SetValidityDays(c.ValidityDays). SetValidityDays(c.ValidityDays).
SetNillableExpiresAt(c.ExpiresAt).
SetNillableUsedBy(c.UsedBy). SetNillableUsedBy(c.UsedBy).
SetNillableUsedAt(c.UsedAt). SetNillableUsedAt(c.UsedAt).
SetNillableGroupID(c.GroupID) SetNillableGroupID(c.GroupID)
@ -107,7 +109,28 @@ func (r *redeemCodeRepository) ListWithFilters(ctx context.Context, params pagin
q = q.Where(redeemcode.TypeEQ(codeType)) q = q.Where(redeemcode.TypeEQ(codeType))
} }
if status != "" { 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 != "" { if search != "" {
q = q.Where( q = q.Where(
@ -158,6 +181,8 @@ func redeemCodeListOrder(params pagination.PaginationParams) []func(*entsql.Sele
field = redeemcode.FieldUsedAt field = redeemcode.FieldUsedAt
case "created_at": case "created_at":
field = redeemcode.FieldCreatedAt field = redeemcode.FieldCreatedAt
case "expires_at":
field = redeemcode.FieldExpiresAt
case "code": case "code":
field = redeemcode.FieldCode field = redeemcode.FieldCode
default: default:
@ -194,6 +219,11 @@ func (r *redeemCodeRepository) Update(ctx context.Context, code *service.RedeemC
} else { } else {
up.ClearGroupID() up.ClearGroupID()
} }
if code.ExpiresAt != nil {
up.SetExpiresAt(*code.ExpiresAt)
} else {
up.ClearExpiresAt()
}
updated, err := up.Save(ctx) updated, err := up.Save(ctx)
if err != nil { if err != nil {
@ -307,6 +337,7 @@ func redeemCodeEntityToService(m *dbent.RedeemCode) *service.RedeemCode {
UsedAt: m.UsedAt, UsedAt: m.UsedAt,
Notes: derefString(m.Notes), Notes: derefString(m.Notes),
CreatedAt: m.CreatedAt, CreatedAt: m.CreatedAt,
ExpiresAt: m.ExpiresAt,
GroupID: m.GroupID, GroupID: m.GroupID,
ValidityDays: m.ValidityDays, ValidityDays: m.ValidityDays,
} }

View File

@ -51,11 +51,13 @@ func (s *RedeemCodeRepoSuite) createGroup(name string) *dbent.Group {
// --- Create / CreateBatch / GetByID / GetByCode --- // --- Create / CreateBatch / GetByID / GetByCode ---
func (s *RedeemCodeRepoSuite) TestCreate() { func (s *RedeemCodeRepoSuite) TestCreate() {
expiresAt := time.Now().UTC().Add(2 * time.Hour)
code := &service.RedeemCode{ code := &service.RedeemCode{
Code: "TEST-CREATE", Code: "TEST-CREATE",
Type: service.RedeemTypeBalance, Type: service.RedeemTypeBalance,
Value: 100, Value: 100,
Status: service.StatusUnused, Status: service.StatusUnused,
ExpiresAt: &expiresAt,
} }
err := s.repo.Create(s.ctx, code) err := s.repo.Create(s.ctx, code)
@ -65,6 +67,8 @@ func (s *RedeemCodeRepoSuite) TestCreate() {
got, err := s.repo.GetByID(s.ctx, code.ID) got, err := s.repo.GetByID(s.ctx, code.ID)
s.Require().NoError(err, "GetByID") s.Require().NoError(err, "GetByID")
s.Require().Equal("TEST-CREATE", got.Code) s.Require().Equal("TEST-CREATE", got.Code)
s.Require().NotNil(got.ExpiresAt)
s.Require().WithinDuration(expiresAt, *got.ExpiresAt, time.Second)
} }
func (s *RedeemCodeRepoSuite) TestCreateBatch() { func (s *RedeemCodeRepoSuite) TestCreateBatch() {
@ -166,6 +170,23 @@ func (s *RedeemCodeRepoSuite) TestListWithFilters_Status() {
s.Require().Equal(service.StatusUsed, codes[0].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() { 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: "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})) s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "BETA-CODE", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUnused}))

View File

@ -397,6 +397,7 @@ type GenerateRedeemCodesInput struct {
Value float64 Value float64
GroupID *int64 // 订阅类型专用关联的分组ID GroupID *int64 // 订阅类型专用关联的分组ID
ValidityDays int // 订阅类型专用:有效天数 ValidityDays int // 订阅类型专用:有效天数
ExpiresAt *time.Time
} }
type ProxyBatchDeleteResult struct { type ProxyBatchDeleteResult struct {
@ -2970,6 +2971,10 @@ func (s *adminServiceImpl) GetRedeemCode(ctx context.Context, id int64) (*Redeem
} }
func (s *adminServiceImpl) GenerateRedeemCodes(ctx context.Context, input *GenerateRedeemCodesInput) ([]RedeemCode, error) { func (s *adminServiceImpl) GenerateRedeemCodes(ctx context.Context, input *GenerateRedeemCodesInput) ([]RedeemCode, error) {
if input.ExpiresAt != nil && !input.ExpiresAt.After(time.Now()) {
return nil, ErrRedeemCodeExpired
}
// 如果是订阅类型,验证必须有 GroupID // 如果是订阅类型,验证必须有 GroupID
if input.Type == RedeemTypeSubscription { if input.Type == RedeemTypeSubscription {
if input.GroupID == nil { if input.GroupID == nil {
@ -2992,10 +2997,11 @@ func (s *adminServiceImpl) GenerateRedeemCodes(ctx context.Context, input *Gener
return nil, err return nil, err
} }
code := RedeemCode{ code := RedeemCode{
Code: codeValue, Code: codeValue,
Type: input.Type, Type: input.Type,
Value: input.Value, Value: input.Value,
Status: StatusUnused, Status: StatusUnused,
ExpiresAt: input.ExpiresAt,
} }
// 订阅类型专用字段 // 订阅类型专用字段
if input.Type == RedeemTypeSubscription { if input.Type == RedeemTypeSubscription {

View File

@ -72,7 +72,7 @@ func (s *AuthService) validateOAuthRegistrationInvitation(ctx context.Context, i
if err != nil { if err != nil {
return nil, ErrInvitationCodeInvalid return nil, ErrInvitationCodeInvalid
} }
if redeemCode.Type != RedeemTypeInvitation || redeemCode.Status != StatusUnused { if redeemCode.Type != RedeemTypeInvitation || !redeemCode.CanUse() {
return nil, ErrInvitationCodeInvalid return nil, ErrInvitationCodeInvalid
} }
return redeemCode, nil return redeemCode, nil
@ -364,6 +364,7 @@ func (s *AuthService) loadOAuthRegistrationInvitation(ctx context.Context, invit
UsedAt: entity.UsedAt, UsedAt: entity.UsedAt,
Notes: oauthEmailFlowStringValue(entity.Notes), Notes: oauthEmailFlowStringValue(entity.Notes),
CreatedAt: entity.CreatedAt, CreatedAt: entity.CreatedAt,
ExpiresAt: entity.ExpiresAt,
GroupID: entity.GroupID, GroupID: entity.GroupID,
ValidityDays: entity.ValidityDays, ValidityDays: entity.ValidityDays,
}, nil }, nil
@ -374,7 +375,11 @@ func (s *AuthService) loadOAuthRegistrationInvitation(ctx context.Context, invit
func (s *AuthService) useOAuthRegistrationInvitation(ctx context.Context, invitationID, userID int64) error { func (s *AuthService) useOAuthRegistrationInvitation(ctx context.Context, invitationID, userID int64) error {
if client := s.oauthEmailFlowClient(ctx); client != nil { if client := s.oauthEmailFlowClient(ctx); client != nil {
affected, err := client.RedeemCode.Update(). 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). SetStatus(StatusUsed).
SetUsedBy(userID). SetUsedBy(userID).
SetUsedAt(time.Now().UTC()). SetUsedAt(time.Now().UTC()).
@ -402,6 +407,11 @@ func (s *AuthService) updateOAuthRegistrationInvitation(ctx context.Context, cod
SetStatus(code.Status). SetStatus(code.Status).
SetNotes(code.Notes). SetNotes(code.Notes).
SetValidityDays(code.ValidityDays) SetValidityDays(code.ValidityDays)
if code.ExpiresAt != nil {
update = update.SetExpiresAt(*code.ExpiresAt)
} else {
update = update.ClearExpiresAt()
}
if code.UsedBy != nil { if code.UsedBy != nil {
update = update.SetUsedBy(*code.UsedBy) update = update.SetUsedBy(*code.UsedBy)
} else { } else {

View File

@ -157,7 +157,7 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
return "", nil, ErrInvitationCodeInvalid 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) logger.LegacyPrintf("service.auth", "[Auth] Invitation code invalid: type=%s, status=%s", redeemCode.Type, redeemCode.Status)
return "", nil, ErrInvitationCodeInvalid return "", nil, ErrInvitationCodeInvalid
} }
@ -615,7 +615,7 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
if err != nil { if err != nil {
return nil, nil, ErrInvitationCodeInvalid return nil, nil, ErrInvitationCodeInvalid
} }
if redeemCode.Type != RedeemTypeInvitation || redeemCode.Status != StatusUnused { if redeemCode.Type != RedeemTypeInvitation || !redeemCode.CanUse() {
return nil, nil, ErrInvitationCodeInvalid return nil, nil, ErrInvitationCodeInvalid
} }
invitationRedeemCode = redeemCode invitationRedeemCode = redeemCode

View File

@ -16,6 +16,7 @@ type RedeemCode struct {
UsedAt *time.Time UsedAt *time.Time
Notes string Notes string
CreatedAt time.Time CreatedAt time.Time
ExpiresAt *time.Time
GroupID *int64 GroupID *int64
ValidityDays int ValidityDays int
@ -28,8 +29,22 @@ func (r *RedeemCode) IsUsed() bool {
return r.Status == StatusUsed 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 { func (r *RedeemCode) CanUse() bool {
return r.Status == StatusUnused return r.Status == StatusUnused && !r.IsExpired()
} }
func GenerateRedeemCode() (string, error) { func GenerateRedeemCode() (string, error) {

View File

@ -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())
})
}
}

View File

@ -18,6 +18,7 @@ import (
var ( var (
ErrRedeemCodeNotFound = infraerrors.NotFound("REDEEM_CODE_NOT_FOUND", "redeem code not found") ErrRedeemCodeNotFound = infraerrors.NotFound("REDEEM_CODE_NOT_FOUND", "redeem code not found")
ErrRedeemCodeUsed = infraerrors.Conflict("REDEEM_CODE_USED", "redeem code already used") 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") ErrInsufficientBalance = infraerrors.BadRequest("INSUFFICIENT_BALANCE", "insufficient balance")
ErrRedeemRateLimited = infraerrors.TooManyRequests("REDEEM_RATE_LIMITED", "too many failed attempts, please try again later") 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") 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 == "" { if code.Status == "" {
code.Status = StatusUnused code.Status = StatusUnused
} }
if code.IsExpired() {
return ErrRedeemCodeExpired
}
if err := s.redeemRepo.Create(ctx, code); err != nil { if err := s.redeemRepo.Create(ctx, code); err != nil {
return fmt.Errorf("create redeem code: %w", err) 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) return nil, fmt.Errorf("get redeem code: %w", err)
} }
// 检查兑换码状态 // 检查兑换码状态和码本身的过期时间
if redeemCode.IsExpired() {
s.incrementRedeemErrorCount(ctx, userID)
return nil, ErrRedeemCodeExpired
}
if !redeemCode.CanUse() { if !redeemCode.CanUse() {
s.incrementRedeemErrorCount(ctx, userID) s.incrementRedeemErrorCount(ctx, userID)
return nil, ErrRedeemCodeUsed return nil, ErrRedeemCodeUsed

View File

@ -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);

View File

@ -60,6 +60,7 @@ export async function getById(id: number): Promise<RedeemCode> {
* @param value - Value of the code * @param value - Value of the code
* @param groupId - Group ID (required for subscription type) * @param groupId - Group ID (required for subscription type)
* @param validityDays - Validity days (for subscription type) * @param validityDays - Validity days (for subscription type)
* @param expiresInDays - Days before the code itself expires
* @returns Array of generated redeem codes * @returns Array of generated redeem codes
*/ */
export async function generate( export async function generate(
@ -67,7 +68,8 @@ export async function generate(
type: RedeemCodeType, type: RedeemCodeType,
value: number, value: number,
groupId?: number | null, groupId?: number | null,
validityDays?: number validityDays?: number,
expiresInDays?: number | null
): Promise<RedeemCode[]> { ): Promise<RedeemCode[]> {
const payload: GenerateRedeemCodesRequest = { const payload: GenerateRedeemCodesRequest = {
count, count,
@ -82,6 +84,9 @@ export async function generate(
payload.validity_days = validityDays payload.validity_days = validityDays
} }
} }
if (expiresInDays && expiresInDays > 0) {
payload.expires_in_days = expiresInDays
}
const { data } = await apiClient.post<RedeemCode[]>('/admin/redeem-codes/generate', payload) const { data } = await apiClient.post<RedeemCode[]>('/admin/redeem-codes/generate', payload)
return data return data

View File

@ -4115,6 +4115,7 @@ export default {
status: 'Status', status: 'Status',
usedBy: 'Used By', usedBy: 'Used By',
usedAt: 'Used At', usedAt: 'Used At',
expiresAt: 'Expires At',
actions: 'Actions' actions: 'Actions'
}, },
userPrefix: 'User #{id}', userPrefix: 'User #{id}',
@ -4160,6 +4161,12 @@ export default {
selectGroup: 'Select Group', selectGroup: 'Select Group',
selectGroupPlaceholder: 'Choose a subscription group', selectGroupPlaceholder: 'Choose a subscription group',
validityDays: 'Validity Days', 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', groupRequired: 'Please select a subscription group',
days: ' days', days: ' days',
status: { status: {

View File

@ -4229,6 +4229,7 @@ export default {
status: '状态', status: '状态',
usedBy: '使用者', usedBy: '使用者',
usedAt: '使用时间', usedAt: '使用时间',
expiresAt: '过期时间',
createdAt: '创建时间', createdAt: '创建时间',
actions: '操作' actions: '操作'
}, },
@ -4278,6 +4279,12 @@ export default {
selectGroup: '选择分组', selectGroup: '选择分组',
selectGroupPlaceholder: '选择订阅分组', selectGroupPlaceholder: '选择订阅分组',
validityDays: '有效天数', validityDays: '有效天数',
codeExpiry: '兑换码过期',
neverExpires: '永不过期',
expiryPresetDays: '{days} 天',
customExpiry: '自定义',
customExpiryDays: '自定义天数',
expiryDaysRequired: '请输入有效的过期天数',
groupRequired: '请选择订阅分组', groupRequired: '请选择订阅分组',
days: '天', days: '天',
status: { status: {

View File

@ -1293,6 +1293,7 @@ export interface RedeemCode {
used_by: number | null used_by: number | null
used_at: string | null used_at: string | null
created_at: string created_at: string
expires_at?: string | null
updated_at?: string updated_at?: string
group_id?: number | null // 订阅类型专用 group_id?: number | null // 订阅类型专用
validity_days?: number // 订阅类型专用 validity_days?: number // 订阅类型专用
@ -1306,6 +1307,8 @@ export interface GenerateRedeemCodesRequest {
value: number value: number
group_id?: number | null // 订阅类型专用 group_id?: number | null // 订阅类型专用
validity_days?: number // 订阅类型专用 validity_days?: number // 订阅类型专用
expires_at?: string | null
expires_in_days?: number
} }
export interface RedeemCodeRequest { export interface RedeemCodeRequest {

View File

@ -137,6 +137,19 @@
}}</span> }}</span>
</template> </template>
<template #cell-expires_at="{ value, row }">
<span
:class="[
'text-sm',
row.status === 'expired'
? 'text-red-600 dark:text-red-400'
: 'text-gray-500 dark:text-dark-400'
]"
>
{{ value ? formatDateTime(value) : t('admin.redeem.neverExpires') }}
</span>
</template>
<template #cell-actions="{ row }"> <template #cell-actions="{ row }">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<button <button
@ -287,6 +300,35 @@
/> />
</div> </div>
</template> </template>
<div>
<label class="input-label">{{ t('admin.redeem.codeExpiry') }}</label>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-5">
<button
v-for="option in redeemCodeExpiryOptions"
:key="option.value"
type="button"
@click="generateForm.expiry_option = option.value"
:class="[
'rounded-lg border px-3 py-2 text-sm transition-colors',
generateForm.expiry_option === option.value
? 'border-primary-500 bg-primary-50 text-primary-700 dark:border-primary-400 dark:bg-primary-900/20 dark:text-primary-300'
: 'border-gray-200 text-gray-700 hover:bg-gray-50 dark:border-dark-600 dark:text-gray-300 dark:hover:bg-dark-700'
]"
>
{{ option.label }}
</button>
</div>
<input
v-if="generateForm.expiry_option === 'custom'"
v-model.number="generateForm.custom_expiry_days"
type="number"
min="1"
max="3650"
required
class="input mt-2"
:placeholder="t('admin.redeem.customExpiryDays')"
/>
</div>
<div> <div>
<label class="input-label">{{ t('admin.redeem.count') }}</label> <label class="input-label">{{ t('admin.redeem.count') }}</label>
<input <input
@ -504,6 +546,7 @@ const columns = computed<Column[]>(() => [
{ key: 'status', label: t('admin.redeem.columns.status'), sortable: true }, { key: 'status', label: t('admin.redeem.columns.status'), sortable: true },
{ key: 'used_by', label: t('admin.redeem.columns.usedBy') }, { key: 'used_by', label: t('admin.redeem.columns.usedBy') },
{ key: 'used_at', label: t('admin.redeem.columns.usedAt'), sortable: true }, { 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') } { key: 'actions', label: t('admin.redeem.columns.actions') }
]) ])
@ -555,12 +598,24 @@ const showDeleteUnusedDialog = ref(false)
const deletingCode = ref<RedeemCode | null>(null) const deletingCode = ref<RedeemCode | null>(null)
const copiedCode = ref<string | null>(null) const copiedCode = ref<string | null>(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({ const generateForm = reactive({
type: 'balance' as RedeemCodeType, type: 'balance' as RedeemCodeType,
value: 10, value: 10,
count: 1, count: 1,
group_id: null as number | null, group_id: null as number | null,
validity_days: 30 validity_days: 30,
expiry_option: 'never' as RedeemCodeExpiryOption,
custom_expiry_days: 7
}) })
// value 0 // value 0
@ -650,6 +705,22 @@ const handleSort = (key: string, order: 'asc' | 'desc') => {
loadCodes() 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 () => { const handleGenerateCodes = async () => {
// //
if (generateForm.type === 'subscription' && !generateForm.group_id) { if (generateForm.type === 'subscription' && !generateForm.group_id) {
@ -657,6 +728,12 @@ const handleGenerateCodes = async () => {
return return
} }
const expiresInDays = getRedeemCodeExpiresInDays()
if (expiresInDays === null) {
appStore.showError(t('admin.redeem.expiryDaysRequired'))
return
}
generating.value = true generating.value = true
try { try {
const result = await adminAPI.redeem.generate( const result = await adminAPI.redeem.generate(
@ -664,7 +741,8 @@ const handleGenerateCodes = async () => {
generateForm.type, generateForm.type,
generateForm.value, generateForm.value,
generateForm.type === 'subscription' ? generateForm.group_id : undefined, 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 showGenerateDialog.value = false
generatedCodes.value = result generatedCodes.value = result
@ -672,6 +750,8 @@ const handleGenerateCodes = async () => {
// //
generateForm.group_id = null generateForm.group_id = null
generateForm.validity_days = 30 generateForm.validity_days = 30
generateForm.expiry_option = 'never'
generateForm.custom_expiry_days = 7
loadCodes() loadCodes()
} catch (error: any) { } catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.redeem.failedToGenerate')) appStore.showError(error.response?.data?.detail || t('admin.redeem.failedToGenerate'))