Merge pull request #2573 from wucm667/feat/redeem-code-expiry
feat(redeem): 兑换码支持设置使用有效期
This commit is contained in:
commit
2a242aec0f
@ -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]},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -533,11 +533,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
|
||||
|
||||
@ -338,6 +338,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"`
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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}))
|
||||
|
||||
@ -397,6 +397,7 @@ type GenerateRedeemCodesInput struct {
|
||||
Value float64
|
||||
GroupID *int64 // 订阅类型专用:关联的分组ID
|
||||
ValidityDays int // 订阅类型专用:有效天数
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
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) {
|
||||
if input.ExpiresAt != nil && !input.ExpiresAt.After(time.Now()) {
|
||||
return nil, ErrRedeemCodeExpired
|
||||
}
|
||||
|
||||
// 如果是订阅类型,验证必须有 GroupID
|
||||
if input.Type == RedeemTypeSubscription {
|
||||
if input.GroupID == nil {
|
||||
@ -2992,10 +2997,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 {
|
||||
|
||||
@ -72,7 +72,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
|
||||
@ -364,6 +364,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
|
||||
@ -374,7 +375,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()).
|
||||
@ -402,6 +407,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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -615,7 +615,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
|
||||
|
||||
@ -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) {
|
||||
|
||||
59
backend/internal/service/redeem_code_test.go
Normal file
59
backend/internal/service/redeem_code_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
8
backend/migrations/137_redeem_code_expires_at.sql
Normal file
8
backend/migrations/137_redeem_code_expires_at.sql
Normal 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);
|
||||
@ -60,6 +60,7 @@ export async function getById(id: number): Promise<RedeemCode> {
|
||||
* @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<RedeemCode[]> {
|
||||
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<RedeemCode[]>('/admin/redeem-codes/generate', payload)
|
||||
return data
|
||||
|
||||
@ -4115,6 +4115,7 @@ export default {
|
||||
status: 'Status',
|
||||
usedBy: 'Used By',
|
||||
usedAt: 'Used At',
|
||||
expiresAt: 'Expires At',
|
||||
actions: 'Actions'
|
||||
},
|
||||
userPrefix: 'User #{id}',
|
||||
@ -4160,6 +4161,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: {
|
||||
|
||||
@ -4229,6 +4229,7 @@ export default {
|
||||
status: '状态',
|
||||
usedBy: '使用者',
|
||||
usedAt: '使用时间',
|
||||
expiresAt: '过期时间',
|
||||
createdAt: '创建时间',
|
||||
actions: '操作'
|
||||
},
|
||||
@ -4278,6 +4279,12 @@ export default {
|
||||
selectGroup: '选择分组',
|
||||
selectGroupPlaceholder: '选择订阅分组',
|
||||
validityDays: '有效天数',
|
||||
codeExpiry: '兑换码过期',
|
||||
neverExpires: '永不过期',
|
||||
expiryPresetDays: '{days} 天',
|
||||
customExpiry: '自定义',
|
||||
customExpiryDays: '自定义天数',
|
||||
expiryDaysRequired: '请输入有效的过期天数',
|
||||
groupRequired: '请选择订阅分组',
|
||||
days: '天',
|
||||
status: {
|
||||
|
||||
@ -1293,6 +1293,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 // 订阅类型专用
|
||||
@ -1306,6 +1307,8 @@ export interface GenerateRedeemCodesRequest {
|
||||
value: number
|
||||
group_id?: number | null // 订阅类型专用
|
||||
validity_days?: number // 订阅类型专用
|
||||
expires_at?: string | null
|
||||
expires_in_days?: number
|
||||
}
|
||||
|
||||
export interface RedeemCodeRequest {
|
||||
|
||||
@ -137,6 +137,19 @@
|
||||
}}</span>
|
||||
</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 }">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@ -287,6 +300,35 @@
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
<label class="input-label">{{ t('admin.redeem.count') }}</label>
|
||||
<input
|
||||
@ -504,6 +546,7 @@ const columns = computed<Column[]>(() => [
|
||||
{ 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<RedeemCode | 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({
|
||||
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'))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user