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: "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]},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"`
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}))
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
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 (
|
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
|
||||||
|
|||||||
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 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
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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'))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user