diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index ceefcc684f..17ee59bdca 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -1128,6 +1128,7 @@ var ( {Name: "amount", Type: field.TypeFloat64, SchemaType: map[string]string{"postgres": "decimal(20,2)"}}, {Name: "pay_amount", Type: field.TypeFloat64, SchemaType: map[string]string{"postgres": "decimal(20,2)"}}, {Name: "fee_rate", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(10,4)"}}, + {Name: "bonus_amount", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(20,2)"}}, {Name: "recharge_code", Type: field.TypeString, Size: 64}, {Name: "out_trade_no", Type: field.TypeString, Size: 64, Default: ""}, {Name: "payment_type", Type: field.TypeString, Size: 30}, @@ -1170,7 +1171,7 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "payment_orders_users_payment_orders", - Columns: []*schema.Column{PaymentOrdersColumns[39]}, + Columns: []*schema.Column{PaymentOrdersColumns[40]}, RefColumns: []*schema.Column{UsersColumns[0]}, OnDelete: schema.NoAction, }, @@ -1179,7 +1180,7 @@ var ( { Name: "paymentorder_out_trade_no", Unique: true, - Columns: []*schema.Column{PaymentOrdersColumns[8]}, + Columns: []*schema.Column{PaymentOrdersColumns[9]}, Annotation: &entsql.IndexAnnotation{ Where: "out_trade_no <> ''", }, @@ -1187,37 +1188,37 @@ var ( { Name: "paymentorder_user_id", Unique: false, - Columns: []*schema.Column{PaymentOrdersColumns[39]}, + Columns: []*schema.Column{PaymentOrdersColumns[40]}, }, { Name: "paymentorder_status", Unique: false, - Columns: []*schema.Column{PaymentOrdersColumns[21]}, + Columns: []*schema.Column{PaymentOrdersColumns[22]}, }, { Name: "paymentorder_expires_at", Unique: false, - Columns: []*schema.Column{PaymentOrdersColumns[29]}, + Columns: []*schema.Column{PaymentOrdersColumns[30]}, }, { Name: "paymentorder_created_at", Unique: false, - Columns: []*schema.Column{PaymentOrdersColumns[37]}, + Columns: []*schema.Column{PaymentOrdersColumns[38]}, }, { Name: "paymentorder_paid_at", Unique: false, - Columns: []*schema.Column{PaymentOrdersColumns[30]}, + Columns: []*schema.Column{PaymentOrdersColumns[31]}, }, { Name: "paymentorder_payment_type_paid_at", Unique: false, - Columns: []*schema.Column{PaymentOrdersColumns[9], PaymentOrdersColumns[30]}, + Columns: []*schema.Column{PaymentOrdersColumns[10], PaymentOrdersColumns[31]}, }, { Name: "paymentorder_order_type", Unique: false, - Columns: []*schema.Column{PaymentOrdersColumns[14]}, + Columns: []*schema.Column{PaymentOrdersColumns[15]}, }, }, } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 3367fab567..38fd383f76 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -30113,6 +30113,8 @@ type PaymentOrderMutation struct { addpay_amount *float64 fee_rate *float64 addfee_rate *float64 + bonus_amount *float64 + addbonus_amount *float64 recharge_code *string out_trade_no *string payment_type *string @@ -30580,6 +30582,62 @@ func (m *PaymentOrderMutation) ResetFeeRate() { m.addfee_rate = nil } +// SetBonusAmount sets the "bonus_amount" field. +func (m *PaymentOrderMutation) SetBonusAmount(f float64) { + m.bonus_amount = &f + m.addbonus_amount = nil +} + +// BonusAmount returns the value of the "bonus_amount" field in the mutation. +func (m *PaymentOrderMutation) BonusAmount() (r float64, exists bool) { + v := m.bonus_amount + if v == nil { + return + } + return *v, true +} + +// OldBonusAmount returns the old "bonus_amount" field's value of the PaymentOrder entity. +// If the PaymentOrder 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 *PaymentOrderMutation) OldBonusAmount(ctx context.Context) (v float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldBonusAmount is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldBonusAmount requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldBonusAmount: %w", err) + } + return oldValue.BonusAmount, nil +} + +// AddBonusAmount adds f to the "bonus_amount" field. +func (m *PaymentOrderMutation) AddBonusAmount(f float64) { + if m.addbonus_amount != nil { + *m.addbonus_amount += f + } else { + m.addbonus_amount = &f + } +} + +// AddedBonusAmount returns the value that was added to the "bonus_amount" field in this mutation. +func (m *PaymentOrderMutation) AddedBonusAmount() (r float64, exists bool) { + v := m.addbonus_amount + if v == nil { + return + } + return *v, true +} + +// ResetBonusAmount resets all changes to the "bonus_amount" field. +func (m *PaymentOrderMutation) ResetBonusAmount() { + m.bonus_amount = nil + m.addbonus_amount = nil +} + // SetRechargeCode sets the "recharge_code" field. func (m *PaymentOrderMutation) SetRechargeCode(s string) { m.recharge_code = &s @@ -32123,7 +32181,7 @@ func (m *PaymentOrderMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *PaymentOrderMutation) Fields() []string { - fields := make([]string, 0, 39) + fields := make([]string, 0, 40) if m.user != nil { fields = append(fields, paymentorder.FieldUserID) } @@ -32145,6 +32203,9 @@ func (m *PaymentOrderMutation) Fields() []string { if m.fee_rate != nil { fields = append(fields, paymentorder.FieldFeeRate) } + if m.bonus_amount != nil { + fields = append(fields, paymentorder.FieldBonusAmount) + } if m.recharge_code != nil { fields = append(fields, paymentorder.FieldRechargeCode) } @@ -32263,6 +32324,8 @@ func (m *PaymentOrderMutation) Field(name string) (ent.Value, bool) { return m.PayAmount() case paymentorder.FieldFeeRate: return m.FeeRate() + case paymentorder.FieldBonusAmount: + return m.BonusAmount() case paymentorder.FieldRechargeCode: return m.RechargeCode() case paymentorder.FieldOutTradeNo: @@ -32350,6 +32413,8 @@ func (m *PaymentOrderMutation) OldField(ctx context.Context, name string) (ent.V return m.OldPayAmount(ctx) case paymentorder.FieldFeeRate: return m.OldFeeRate(ctx) + case paymentorder.FieldBonusAmount: + return m.OldBonusAmount(ctx) case paymentorder.FieldRechargeCode: return m.OldRechargeCode(ctx) case paymentorder.FieldOutTradeNo: @@ -32472,6 +32537,13 @@ func (m *PaymentOrderMutation) SetField(name string, value ent.Value) error { } m.SetFeeRate(v) return nil + case paymentorder.FieldBonusAmount: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetBonusAmount(v) + return nil case paymentorder.FieldRechargeCode: v, ok := value.(string) if !ok { @@ -32713,6 +32785,9 @@ func (m *PaymentOrderMutation) AddedFields() []string { if m.addfee_rate != nil { fields = append(fields, paymentorder.FieldFeeRate) } + if m.addbonus_amount != nil { + fields = append(fields, paymentorder.FieldBonusAmount) + } if m.addplan_id != nil { fields = append(fields, paymentorder.FieldPlanID) } @@ -32739,6 +32814,8 @@ func (m *PaymentOrderMutation) AddedField(name string) (ent.Value, bool) { return m.AddedPayAmount() case paymentorder.FieldFeeRate: return m.AddedFeeRate() + case paymentorder.FieldBonusAmount: + return m.AddedBonusAmount() case paymentorder.FieldPlanID: return m.AddedPlanID() case paymentorder.FieldSubscriptionGroupID: @@ -32777,6 +32854,13 @@ func (m *PaymentOrderMutation) AddField(name string, value ent.Value) error { } m.AddFeeRate(v) return nil + case paymentorder.FieldBonusAmount: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddBonusAmount(v) + return nil case paymentorder.FieldPlanID: v, ok := value.(int64) if !ok { @@ -32976,6 +33060,9 @@ func (m *PaymentOrderMutation) ResetField(name string) error { case paymentorder.FieldFeeRate: m.ResetFeeRate() return nil + case paymentorder.FieldBonusAmount: + m.ResetBonusAmount() + return nil case paymentorder.FieldRechargeCode: m.ResetRechargeCode() return nil diff --git a/backend/ent/paymentorder.go b/backend/ent/paymentorder.go index b131b8c880..db56994990 100644 --- a/backend/ent/paymentorder.go +++ b/backend/ent/paymentorder.go @@ -33,6 +33,8 @@ type PaymentOrder struct { PayAmount float64 `json:"pay_amount,omitempty"` // FeeRate holds the value of the "fee_rate" field. FeeRate float64 `json:"fee_rate,omitempty"` + // BonusAmount holds the value of the "bonus_amount" field. + BonusAmount float64 `json:"bonus_amount,omitempty"` // RechargeCode holds the value of the "recharge_code" field. RechargeCode string `json:"recharge_code,omitempty"` // OutTradeNo holds the value of the "out_trade_no" field. @@ -132,7 +134,7 @@ func (*PaymentOrder) scanValues(columns []string) ([]any, error) { values[i] = new([]byte) case paymentorder.FieldForceRefund: values[i] = new(sql.NullBool) - case paymentorder.FieldAmount, paymentorder.FieldPayAmount, paymentorder.FieldFeeRate, paymentorder.FieldRefundAmount: + case paymentorder.FieldAmount, paymentorder.FieldPayAmount, paymentorder.FieldFeeRate, paymentorder.FieldBonusAmount, paymentorder.FieldRefundAmount: values[i] = new(sql.NullFloat64) case paymentorder.FieldID, paymentorder.FieldUserID, paymentorder.FieldPlanID, paymentorder.FieldSubscriptionGroupID, paymentorder.FieldSubscriptionDays: values[i] = new(sql.NullInt64) @@ -204,6 +206,12 @@ func (_m *PaymentOrder) assignValues(columns []string, values []any) error { } else if value.Valid { _m.FeeRate = value.Float64 } + case paymentorder.FieldBonusAmount: + if value, ok := values[i].(*sql.NullFloat64); !ok { + return fmt.Errorf("unexpected type %T for field bonus_amount", values[i]) + } else if value.Valid { + _m.BonusAmount = value.Float64 + } case paymentorder.FieldRechargeCode: if value, ok := values[i].(*sql.NullString); !ok { return fmt.Errorf("unexpected type %T for field recharge_code", values[i]) @@ -480,6 +488,9 @@ func (_m *PaymentOrder) String() string { builder.WriteString("fee_rate=") builder.WriteString(fmt.Sprintf("%v", _m.FeeRate)) builder.WriteString(", ") + builder.WriteString("bonus_amount=") + builder.WriteString(fmt.Sprintf("%v", _m.BonusAmount)) + builder.WriteString(", ") builder.WriteString("recharge_code=") builder.WriteString(_m.RechargeCode) builder.WriteString(", ") diff --git a/backend/ent/paymentorder/paymentorder.go b/backend/ent/paymentorder/paymentorder.go index 6288379434..391389b159 100644 --- a/backend/ent/paymentorder/paymentorder.go +++ b/backend/ent/paymentorder/paymentorder.go @@ -28,6 +28,8 @@ const ( FieldPayAmount = "pay_amount" // FieldFeeRate holds the string denoting the fee_rate field in the database. FieldFeeRate = "fee_rate" + // FieldBonusAmount holds the string denoting the bonus_amount field in the database. + FieldBonusAmount = "bonus_amount" // FieldRechargeCode holds the string denoting the recharge_code field in the database. FieldRechargeCode = "recharge_code" // FieldOutTradeNo holds the string denoting the out_trade_no field in the database. @@ -115,6 +117,7 @@ var Columns = []string{ FieldAmount, FieldPayAmount, FieldFeeRate, + FieldBonusAmount, FieldRechargeCode, FieldOutTradeNo, FieldPaymentType, @@ -166,6 +169,8 @@ var ( UserNameValidator func(string) error // DefaultFeeRate holds the default value on creation for the "fee_rate" field. DefaultFeeRate float64 + // DefaultBonusAmount holds the default value on creation for the "bonus_amount" field. + DefaultBonusAmount float64 // RechargeCodeValidator is a validator for the "recharge_code" field. It is called by the builders before save. RechargeCodeValidator func(string) error // DefaultOutTradeNo holds the default value on creation for the "out_trade_no" field. @@ -249,6 +254,11 @@ func ByFeeRate(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldFeeRate, opts...).ToFunc() } +// ByBonusAmount orders the results by the bonus_amount field. +func ByBonusAmount(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldBonusAmount, opts...).ToFunc() +} + // ByRechargeCode orders the results by the recharge_code field. func ByRechargeCode(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldRechargeCode, opts...).ToFunc() diff --git a/backend/ent/paymentorder/where.go b/backend/ent/paymentorder/where.go index e96bf51ebd..33e5267791 100644 --- a/backend/ent/paymentorder/where.go +++ b/backend/ent/paymentorder/where.go @@ -90,6 +90,11 @@ func FeeRate(v float64) predicate.PaymentOrder { return predicate.PaymentOrder(sql.FieldEQ(FieldFeeRate, v)) } +// BonusAmount applies equality check predicate on the "bonus_amount" field. It's identical to BonusAmountEQ. +func BonusAmount(v float64) predicate.PaymentOrder { + return predicate.PaymentOrder(sql.FieldEQ(FieldBonusAmount, v)) +} + // RechargeCode applies equality check predicate on the "recharge_code" field. It's identical to RechargeCodeEQ. func RechargeCode(v string) predicate.PaymentOrder { return predicate.PaymentOrder(sql.FieldEQ(FieldRechargeCode, v)) @@ -590,6 +595,46 @@ func FeeRateLTE(v float64) predicate.PaymentOrder { return predicate.PaymentOrder(sql.FieldLTE(FieldFeeRate, v)) } +// BonusAmountEQ applies the EQ predicate on the "bonus_amount" field. +func BonusAmountEQ(v float64) predicate.PaymentOrder { + return predicate.PaymentOrder(sql.FieldEQ(FieldBonusAmount, v)) +} + +// BonusAmountNEQ applies the NEQ predicate on the "bonus_amount" field. +func BonusAmountNEQ(v float64) predicate.PaymentOrder { + return predicate.PaymentOrder(sql.FieldNEQ(FieldBonusAmount, v)) +} + +// BonusAmountIn applies the In predicate on the "bonus_amount" field. +func BonusAmountIn(vs ...float64) predicate.PaymentOrder { + return predicate.PaymentOrder(sql.FieldIn(FieldBonusAmount, vs...)) +} + +// BonusAmountNotIn applies the NotIn predicate on the "bonus_amount" field. +func BonusAmountNotIn(vs ...float64) predicate.PaymentOrder { + return predicate.PaymentOrder(sql.FieldNotIn(FieldBonusAmount, vs...)) +} + +// BonusAmountGT applies the GT predicate on the "bonus_amount" field. +func BonusAmountGT(v float64) predicate.PaymentOrder { + return predicate.PaymentOrder(sql.FieldGT(FieldBonusAmount, v)) +} + +// BonusAmountGTE applies the GTE predicate on the "bonus_amount" field. +func BonusAmountGTE(v float64) predicate.PaymentOrder { + return predicate.PaymentOrder(sql.FieldGTE(FieldBonusAmount, v)) +} + +// BonusAmountLT applies the LT predicate on the "bonus_amount" field. +func BonusAmountLT(v float64) predicate.PaymentOrder { + return predicate.PaymentOrder(sql.FieldLT(FieldBonusAmount, v)) +} + +// BonusAmountLTE applies the LTE predicate on the "bonus_amount" field. +func BonusAmountLTE(v float64) predicate.PaymentOrder { + return predicate.PaymentOrder(sql.FieldLTE(FieldBonusAmount, v)) +} + // RechargeCodeEQ applies the EQ predicate on the "recharge_code" field. func RechargeCodeEQ(v string) predicate.PaymentOrder { return predicate.PaymentOrder(sql.FieldEQ(FieldRechargeCode, v)) diff --git a/backend/ent/paymentorder_create.go b/backend/ent/paymentorder_create.go index 3ee24f8e91..a8693d0914 100644 --- a/backend/ent/paymentorder_create.go +++ b/backend/ent/paymentorder_create.go @@ -81,6 +81,20 @@ func (_c *PaymentOrderCreate) SetNillableFeeRate(v *float64) *PaymentOrderCreate return _c } +// SetBonusAmount sets the "bonus_amount" field. +func (_c *PaymentOrderCreate) SetBonusAmount(v float64) *PaymentOrderCreate { + _c.mutation.SetBonusAmount(v) + return _c +} + +// SetNillableBonusAmount sets the "bonus_amount" field if the given value is not nil. +func (_c *PaymentOrderCreate) SetNillableBonusAmount(v *float64) *PaymentOrderCreate { + if v != nil { + _c.SetBonusAmount(*v) + } + return _c +} + // SetRechargeCode sets the "recharge_code" field. func (_c *PaymentOrderCreate) SetRechargeCode(v string) *PaymentOrderCreate { _c.mutation.SetRechargeCode(v) @@ -517,6 +531,10 @@ func (_c *PaymentOrderCreate) defaults() { v := paymentorder.DefaultFeeRate _c.mutation.SetFeeRate(v) } + if _, ok := _c.mutation.BonusAmount(); !ok { + v := paymentorder.DefaultBonusAmount + _c.mutation.SetBonusAmount(v) + } if _, ok := _c.mutation.OutTradeNo(); !ok { v := paymentorder.DefaultOutTradeNo _c.mutation.SetOutTradeNo(v) @@ -577,6 +595,9 @@ func (_c *PaymentOrderCreate) check() error { if _, ok := _c.mutation.FeeRate(); !ok { return &ValidationError{Name: "fee_rate", err: errors.New(`ent: missing required field "PaymentOrder.fee_rate"`)} } + if _, ok := _c.mutation.BonusAmount(); !ok { + return &ValidationError{Name: "bonus_amount", err: errors.New(`ent: missing required field "PaymentOrder.bonus_amount"`)} + } if _, ok := _c.mutation.RechargeCode(); !ok { return &ValidationError{Name: "recharge_code", err: errors.New(`ent: missing required field "PaymentOrder.recharge_code"`)} } @@ -725,6 +746,10 @@ func (_c *PaymentOrderCreate) createSpec() (*PaymentOrder, *sqlgraph.CreateSpec) _spec.SetField(paymentorder.FieldFeeRate, field.TypeFloat64, value) _node.FeeRate = value } + if value, ok := _c.mutation.BonusAmount(); ok { + _spec.SetField(paymentorder.FieldBonusAmount, field.TypeFloat64, value) + _node.BonusAmount = value + } if value, ok := _c.mutation.RechargeCode(); ok { _spec.SetField(paymentorder.FieldRechargeCode, field.TypeString, value) _node.RechargeCode = value @@ -1030,6 +1055,24 @@ func (u *PaymentOrderUpsert) AddFeeRate(v float64) *PaymentOrderUpsert { return u } +// SetBonusAmount sets the "bonus_amount" field. +func (u *PaymentOrderUpsert) SetBonusAmount(v float64) *PaymentOrderUpsert { + u.Set(paymentorder.FieldBonusAmount, v) + return u +} + +// UpdateBonusAmount sets the "bonus_amount" field to the value that was provided on create. +func (u *PaymentOrderUpsert) UpdateBonusAmount() *PaymentOrderUpsert { + u.SetExcluded(paymentorder.FieldBonusAmount) + return u +} + +// AddBonusAmount adds v to the "bonus_amount" field. +func (u *PaymentOrderUpsert) AddBonusAmount(v float64) *PaymentOrderUpsert { + u.Add(paymentorder.FieldBonusAmount, v) + return u +} + // SetRechargeCode sets the "recharge_code" field. func (u *PaymentOrderUpsert) SetRechargeCode(v string) *PaymentOrderUpsert { u.Set(paymentorder.FieldRechargeCode, v) @@ -1711,6 +1754,27 @@ func (u *PaymentOrderUpsertOne) UpdateFeeRate() *PaymentOrderUpsertOne { }) } +// SetBonusAmount sets the "bonus_amount" field. +func (u *PaymentOrderUpsertOne) SetBonusAmount(v float64) *PaymentOrderUpsertOne { + return u.Update(func(s *PaymentOrderUpsert) { + s.SetBonusAmount(v) + }) +} + +// AddBonusAmount adds v to the "bonus_amount" field. +func (u *PaymentOrderUpsertOne) AddBonusAmount(v float64) *PaymentOrderUpsertOne { + return u.Update(func(s *PaymentOrderUpsert) { + s.AddBonusAmount(v) + }) +} + +// UpdateBonusAmount sets the "bonus_amount" field to the value that was provided on create. +func (u *PaymentOrderUpsertOne) UpdateBonusAmount() *PaymentOrderUpsertOne { + return u.Update(func(s *PaymentOrderUpsert) { + s.UpdateBonusAmount() + }) +} + // SetRechargeCode sets the "recharge_code" field. func (u *PaymentOrderUpsertOne) SetRechargeCode(v string) *PaymentOrderUpsertOne { return u.Update(func(s *PaymentOrderUpsert) { @@ -2643,6 +2707,27 @@ func (u *PaymentOrderUpsertBulk) UpdateFeeRate() *PaymentOrderUpsertBulk { }) } +// SetBonusAmount sets the "bonus_amount" field. +func (u *PaymentOrderUpsertBulk) SetBonusAmount(v float64) *PaymentOrderUpsertBulk { + return u.Update(func(s *PaymentOrderUpsert) { + s.SetBonusAmount(v) + }) +} + +// AddBonusAmount adds v to the "bonus_amount" field. +func (u *PaymentOrderUpsertBulk) AddBonusAmount(v float64) *PaymentOrderUpsertBulk { + return u.Update(func(s *PaymentOrderUpsert) { + s.AddBonusAmount(v) + }) +} + +// UpdateBonusAmount sets the "bonus_amount" field to the value that was provided on create. +func (u *PaymentOrderUpsertBulk) UpdateBonusAmount() *PaymentOrderUpsertBulk { + return u.Update(func(s *PaymentOrderUpsert) { + s.UpdateBonusAmount() + }) +} + // SetRechargeCode sets the "recharge_code" field. func (u *PaymentOrderUpsertBulk) SetRechargeCode(v string) *PaymentOrderUpsertBulk { return u.Update(func(s *PaymentOrderUpsert) { diff --git a/backend/ent/paymentorder_update.go b/backend/ent/paymentorder_update.go index 378e0dad2f..3ab893cbe6 100644 --- a/backend/ent/paymentorder_update.go +++ b/backend/ent/paymentorder_update.go @@ -154,6 +154,27 @@ func (_u *PaymentOrderUpdate) AddFeeRate(v float64) *PaymentOrderUpdate { return _u } +// SetBonusAmount sets the "bonus_amount" field. +func (_u *PaymentOrderUpdate) SetBonusAmount(v float64) *PaymentOrderUpdate { + _u.mutation.ResetBonusAmount() + _u.mutation.SetBonusAmount(v) + return _u +} + +// SetNillableBonusAmount sets the "bonus_amount" field if the given value is not nil. +func (_u *PaymentOrderUpdate) SetNillableBonusAmount(v *float64) *PaymentOrderUpdate { + if v != nil { + _u.SetBonusAmount(*v) + } + return _u +} + +// AddBonusAmount adds value to the "bonus_amount" field. +func (_u *PaymentOrderUpdate) AddBonusAmount(v float64) *PaymentOrderUpdate { + _u.mutation.AddBonusAmount(v) + return _u +} + // SetRechargeCode sets the "recharge_code" field. func (_u *PaymentOrderUpdate) SetRechargeCode(v string) *PaymentOrderUpdate { _u.mutation.SetRechargeCode(v) @@ -881,6 +902,12 @@ func (_u *PaymentOrderUpdate) sqlSave(ctx context.Context) (_node int, err error if value, ok := _u.mutation.AddedFeeRate(); ok { _spec.AddField(paymentorder.FieldFeeRate, field.TypeFloat64, value) } + if value, ok := _u.mutation.BonusAmount(); ok { + _spec.SetField(paymentorder.FieldBonusAmount, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedBonusAmount(); ok { + _spec.AddField(paymentorder.FieldBonusAmount, field.TypeFloat64, value) + } if value, ok := _u.mutation.RechargeCode(); ok { _spec.SetField(paymentorder.FieldRechargeCode, field.TypeString, value) } @@ -1217,6 +1244,27 @@ func (_u *PaymentOrderUpdateOne) AddFeeRate(v float64) *PaymentOrderUpdateOne { return _u } +// SetBonusAmount sets the "bonus_amount" field. +func (_u *PaymentOrderUpdateOne) SetBonusAmount(v float64) *PaymentOrderUpdateOne { + _u.mutation.ResetBonusAmount() + _u.mutation.SetBonusAmount(v) + return _u +} + +// SetNillableBonusAmount sets the "bonus_amount" field if the given value is not nil. +func (_u *PaymentOrderUpdateOne) SetNillableBonusAmount(v *float64) *PaymentOrderUpdateOne { + if v != nil { + _u.SetBonusAmount(*v) + } + return _u +} + +// AddBonusAmount adds value to the "bonus_amount" field. +func (_u *PaymentOrderUpdateOne) AddBonusAmount(v float64) *PaymentOrderUpdateOne { + _u.mutation.AddBonusAmount(v) + return _u +} + // SetRechargeCode sets the "recharge_code" field. func (_u *PaymentOrderUpdateOne) SetRechargeCode(v string) *PaymentOrderUpdateOne { _u.mutation.SetRechargeCode(v) @@ -1974,6 +2022,12 @@ func (_u *PaymentOrderUpdateOne) sqlSave(ctx context.Context) (_node *PaymentOrd if value, ok := _u.mutation.AddedFeeRate(); ok { _spec.AddField(paymentorder.FieldFeeRate, field.TypeFloat64, value) } + if value, ok := _u.mutation.BonusAmount(); ok { + _spec.SetField(paymentorder.FieldBonusAmount, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedBonusAmount(); ok { + _spec.AddField(paymentorder.FieldBonusAmount, field.TypeFloat64, value) + } if value, ok := _u.mutation.RechargeCode(); ok { _spec.SetField(paymentorder.FieldRechargeCode, field.TypeString, value) } diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 5a718fabca..375e749c73 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1323,70 +1323,74 @@ func init() { paymentorderDescFeeRate := paymentorderFields[6].Descriptor() // paymentorder.DefaultFeeRate holds the default value on creation for the fee_rate field. paymentorder.DefaultFeeRate = paymentorderDescFeeRate.Default.(float64) + // paymentorderDescBonusAmount is the schema descriptor for bonus_amount field. + paymentorderDescBonusAmount := paymentorderFields[7].Descriptor() + // paymentorder.DefaultBonusAmount holds the default value on creation for the bonus_amount field. + paymentorder.DefaultBonusAmount = paymentorderDescBonusAmount.Default.(float64) // paymentorderDescRechargeCode is the schema descriptor for recharge_code field. - paymentorderDescRechargeCode := paymentorderFields[7].Descriptor() + paymentorderDescRechargeCode := paymentorderFields[8].Descriptor() // paymentorder.RechargeCodeValidator is a validator for the "recharge_code" field. It is called by the builders before save. paymentorder.RechargeCodeValidator = paymentorderDescRechargeCode.Validators[0].(func(string) error) // paymentorderDescOutTradeNo is the schema descriptor for out_trade_no field. - paymentorderDescOutTradeNo := paymentorderFields[8].Descriptor() + paymentorderDescOutTradeNo := paymentorderFields[9].Descriptor() // paymentorder.DefaultOutTradeNo holds the default value on creation for the out_trade_no field. paymentorder.DefaultOutTradeNo = paymentorderDescOutTradeNo.Default.(string) // paymentorder.OutTradeNoValidator is a validator for the "out_trade_no" field. It is called by the builders before save. paymentorder.OutTradeNoValidator = paymentorderDescOutTradeNo.Validators[0].(func(string) error) // paymentorderDescPaymentType is the schema descriptor for payment_type field. - paymentorderDescPaymentType := paymentorderFields[9].Descriptor() + paymentorderDescPaymentType := paymentorderFields[10].Descriptor() // paymentorder.PaymentTypeValidator is a validator for the "payment_type" field. It is called by the builders before save. paymentorder.PaymentTypeValidator = paymentorderDescPaymentType.Validators[0].(func(string) error) // paymentorderDescPaymentTradeNo is the schema descriptor for payment_trade_no field. - paymentorderDescPaymentTradeNo := paymentorderFields[10].Descriptor() + paymentorderDescPaymentTradeNo := paymentorderFields[11].Descriptor() // paymentorder.PaymentTradeNoValidator is a validator for the "payment_trade_no" field. It is called by the builders before save. paymentorder.PaymentTradeNoValidator = paymentorderDescPaymentTradeNo.Validators[0].(func(string) error) // paymentorderDescOrderType is the schema descriptor for order_type field. - paymentorderDescOrderType := paymentorderFields[14].Descriptor() + paymentorderDescOrderType := paymentorderFields[15].Descriptor() // paymentorder.DefaultOrderType holds the default value on creation for the order_type field. paymentorder.DefaultOrderType = paymentorderDescOrderType.Default.(string) // paymentorder.OrderTypeValidator is a validator for the "order_type" field. It is called by the builders before save. paymentorder.OrderTypeValidator = paymentorderDescOrderType.Validators[0].(func(string) error) // paymentorderDescProviderInstanceID is the schema descriptor for provider_instance_id field. - paymentorderDescProviderInstanceID := paymentorderFields[18].Descriptor() + paymentorderDescProviderInstanceID := paymentorderFields[19].Descriptor() // paymentorder.ProviderInstanceIDValidator is a validator for the "provider_instance_id" field. It is called by the builders before save. paymentorder.ProviderInstanceIDValidator = paymentorderDescProviderInstanceID.Validators[0].(func(string) error) // paymentorderDescProviderKey is the schema descriptor for provider_key field. - paymentorderDescProviderKey := paymentorderFields[19].Descriptor() + paymentorderDescProviderKey := paymentorderFields[20].Descriptor() // paymentorder.ProviderKeyValidator is a validator for the "provider_key" field. It is called by the builders before save. paymentorder.ProviderKeyValidator = paymentorderDescProviderKey.Validators[0].(func(string) error) // paymentorderDescStatus is the schema descriptor for status field. - paymentorderDescStatus := paymentorderFields[21].Descriptor() + paymentorderDescStatus := paymentorderFields[22].Descriptor() // paymentorder.DefaultStatus holds the default value on creation for the status field. paymentorder.DefaultStatus = paymentorderDescStatus.Default.(string) // paymentorder.StatusValidator is a validator for the "status" field. It is called by the builders before save. paymentorder.StatusValidator = paymentorderDescStatus.Validators[0].(func(string) error) // paymentorderDescRefundAmount is the schema descriptor for refund_amount field. - paymentorderDescRefundAmount := paymentorderFields[22].Descriptor() + paymentorderDescRefundAmount := paymentorderFields[23].Descriptor() // paymentorder.DefaultRefundAmount holds the default value on creation for the refund_amount field. paymentorder.DefaultRefundAmount = paymentorderDescRefundAmount.Default.(float64) // paymentorderDescForceRefund is the schema descriptor for force_refund field. - paymentorderDescForceRefund := paymentorderFields[25].Descriptor() + paymentorderDescForceRefund := paymentorderFields[26].Descriptor() // paymentorder.DefaultForceRefund holds the default value on creation for the force_refund field. paymentorder.DefaultForceRefund = paymentorderDescForceRefund.Default.(bool) // paymentorderDescRefundRequestedBy is the schema descriptor for refund_requested_by field. - paymentorderDescRefundRequestedBy := paymentorderFields[28].Descriptor() + paymentorderDescRefundRequestedBy := paymentorderFields[29].Descriptor() // paymentorder.RefundRequestedByValidator is a validator for the "refund_requested_by" field. It is called by the builders before save. paymentorder.RefundRequestedByValidator = paymentorderDescRefundRequestedBy.Validators[0].(func(string) error) // paymentorderDescClientIP is the schema descriptor for client_ip field. - paymentorderDescClientIP := paymentorderFields[34].Descriptor() + paymentorderDescClientIP := paymentorderFields[35].Descriptor() // paymentorder.ClientIPValidator is a validator for the "client_ip" field. It is called by the builders before save. paymentorder.ClientIPValidator = paymentorderDescClientIP.Validators[0].(func(string) error) // paymentorderDescSrcHost is the schema descriptor for src_host field. - paymentorderDescSrcHost := paymentorderFields[35].Descriptor() + paymentorderDescSrcHost := paymentorderFields[36].Descriptor() // paymentorder.SrcHostValidator is a validator for the "src_host" field. It is called by the builders before save. paymentorder.SrcHostValidator = paymentorderDescSrcHost.Validators[0].(func(string) error) // paymentorderDescCreatedAt is the schema descriptor for created_at field. - paymentorderDescCreatedAt := paymentorderFields[37].Descriptor() + paymentorderDescCreatedAt := paymentorderFields[38].Descriptor() // paymentorder.DefaultCreatedAt holds the default value on creation for the created_at field. paymentorder.DefaultCreatedAt = paymentorderDescCreatedAt.Default.(func() time.Time) // paymentorderDescUpdatedAt is the schema descriptor for updated_at field. - paymentorderDescUpdatedAt := paymentorderFields[38].Descriptor() + paymentorderDescUpdatedAt := paymentorderFields[39].Descriptor() // paymentorder.DefaultUpdatedAt holds the default value on creation for the updated_at field. paymentorder.DefaultUpdatedAt = paymentorderDescUpdatedAt.Default.(func() time.Time) // paymentorder.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field. diff --git a/backend/ent/schema/payment_order.go b/backend/ent/schema/payment_order.go index d25d1e5e17..c8ce5f1ed3 100644 --- a/backend/ent/schema/payment_order.go +++ b/backend/ent/schema/payment_order.go @@ -50,6 +50,10 @@ func (PaymentOrder) Fields() []ent.Field { field.Float("fee_rate"). SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}). Default(0), + // 充值赠送额度(USD)。已计入 amount;单独记录用于订单展示与推广返利基数剔除。 + field.Float("bonus_amount"). + SchemaType(map[string]string{dialect.Postgres: "decimal(20,2)"}). + Default(0), field.String("recharge_code"). MaxLen(64), diff --git a/backend/internal/handler/admin/payment_handler.go b/backend/internal/handler/admin/payment_handler.go index 1749d19670..3598635cb5 100644 --- a/backend/internal/handler/admin/payment_handler.go +++ b/backend/internal/handler/admin/payment_handler.go @@ -125,6 +125,7 @@ type AdminPaymentOrderResult struct { Amount float64 `json:"amount"` PayAmount float64 `json:"pay_amount"` FeeRate float64 `json:"fee_rate"` + BonusAmount float64 `json:"bonus_amount"` Currency string `json:"currency"` RechargeCode string `json:"recharge_code,omitempty"` OutTradeNo string `json:"out_trade_no"` @@ -182,6 +183,7 @@ func sanitizeAdminPaymentOrderForResponse(order *dbent.PaymentOrder) *AdminPayme Amount: order.Amount, PayAmount: order.PayAmount, FeeRate: order.FeeRate, + BonusAmount: order.BonusAmount, Currency: service.PaymentOrderCurrency(order), RechargeCode: order.RechargeCode, OutTradeNo: order.OutTradeNo, diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 7bff15c48a..fb05421a8e 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -362,6 +362,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { PaymentBalanceRechargeMultiplier: paymentCfg.BalanceRechargeMultiplier, PaymentSubscriptionUSDToCNYRate: paymentCfg.SubscriptionUSDToCNYRate, PaymentRechargeFeeRate: paymentCfg.RechargeFeeRate, + PaymentRechargeBonusTiers: rechargeBonusTiersToDTO(paymentCfg.RechargeBonusTiers), + PaymentRechargeBonusNotice: paymentCfg.RechargeBonusNotice, PaymentLoadBalanceStrat: paymentCfg.LoadBalanceStrategy, PaymentProductNamePrefix: paymentCfg.ProductNamePrefix, PaymentProductNameSuffix: paymentCfg.ProductNameSuffix, diff --git a/backend/internal/handler/admin/setting_handler_recharge_bonus.go b/backend/internal/handler/admin/setting_handler_recharge_bonus.go new file mode 100644 index 0000000000..d03b2e7e39 --- /dev/null +++ b/backend/internal/handler/admin/setting_handler_recharge_bonus.go @@ -0,0 +1,27 @@ +package admin + +import ( + "github.com/Wei-Shaw/sub2api/internal/handler/dto" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +// rechargeBonusTiersFromDTO 请求 nil 表示未携带该字段(保持现值);空数组表示清空阶梯。 +func rechargeBonusTiersFromDTO(items *[]dto.RechargeBonusTier) *[]service.RechargeBonusTier { + if items == nil { + return nil + } + out := make([]service.RechargeBonusTier, 0, len(*items)) + for _, item := range *items { + out = append(out, service.RechargeBonusTier{MinAmount: item.MinAmount, BonusPercent: item.BonusPercent}) + } + return &out +} + +// rechargeBonusTiersToDTO 始终返回非 nil 切片,空配置输出 []。 +func rechargeBonusTiersToDTO(items []service.RechargeBonusTier) []dto.RechargeBonusTier { + out := make([]dto.RechargeBonusTier, 0, len(items)) + for _, item := range items { + out = append(out, dto.RechargeBonusTier{MinAmount: item.MinAmount, BonusPercent: item.BonusPercent}) + } + return out +} diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 618f0bde5c..ba27da96c6 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -312,11 +312,14 @@ type UpdateSettingsRequest struct { PaymentBalanceRechargeMultiplier *float64 `json:"payment_balance_recharge_multiplier"` PaymentSubscriptionUSDToCNYRate *float64 `json:"payment_subscription_usd_to_cny_rate"` PaymentRechargeFeeRate *float64 `json:"payment_recharge_fee_rate"` - PaymentLoadBalanceStrat *string `json:"payment_load_balance_strategy"` - PaymentProductNamePrefix *string `json:"payment_product_name_prefix"` - PaymentProductNameSuffix *string `json:"payment_product_name_suffix"` - PaymentHelpImageURL *string `json:"payment_help_image_url"` - PaymentHelpText *string `json:"payment_help_text"` + // nil 表示不更新;空数组表示清空阶梯 + PaymentRechargeBonusTiers *[]dto.RechargeBonusTier `json:"payment_recharge_bonus_tiers"` + PaymentRechargeBonusNotice *string `json:"payment_recharge_bonus_notice"` + PaymentLoadBalanceStrat *string `json:"payment_load_balance_strategy"` + PaymentProductNamePrefix *string `json:"payment_product_name_prefix"` + PaymentProductNameSuffix *string `json:"payment_product_name_suffix"` + PaymentHelpImageURL *string `json:"payment_help_image_url"` + PaymentHelpText *string `json:"payment_help_text"` // Cancel rate limit PaymentCancelRateLimitEnabled *bool `json:"payment_cancel_rate_limit_enabled"` @@ -2128,6 +2131,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { BalanceRechargeMultiplier: req.PaymentBalanceRechargeMultiplier, SubscriptionUSDToCNYRate: req.PaymentSubscriptionUSDToCNYRate, RechargeFeeRate: req.PaymentRechargeFeeRate, + RechargeBonusTiers: rechargeBonusTiersFromDTO(req.PaymentRechargeBonusTiers), + RechargeBonusNotice: req.PaymentRechargeBonusNotice, LoadBalanceStrategy: req.PaymentLoadBalanceStrat, ProductNamePrefix: req.PaymentProductNamePrefix, ProductNameSuffix: req.PaymentProductNameSuffix, @@ -2407,6 +2412,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { PaymentBalanceRechargeMultiplier: updatedPaymentCfg.BalanceRechargeMultiplier, PaymentSubscriptionUSDToCNYRate: updatedPaymentCfg.SubscriptionUSDToCNYRate, PaymentRechargeFeeRate: updatedPaymentCfg.RechargeFeeRate, + PaymentRechargeBonusTiers: rechargeBonusTiersToDTO(updatedPaymentCfg.RechargeBonusTiers), + PaymentRechargeBonusNotice: updatedPaymentCfg.RechargeBonusNotice, PaymentLoadBalanceStrat: updatedPaymentCfg.LoadBalanceStrategy, PaymentProductNamePrefix: updatedPaymentCfg.ProductNamePrefix, PaymentProductNameSuffix: updatedPaymentCfg.ProductNameSuffix, @@ -2483,6 +2490,7 @@ func hasPaymentFields(req UpdateSettingsRequest) bool { req.PaymentEnabledTypes != nil || req.PaymentBalanceDisabled != nil || req.PaymentBalanceRechargeMultiplier != nil || req.PaymentSubscriptionUSDToCNYRate != nil || req.PaymentRechargeFeeRate != nil || + req.PaymentRechargeBonusTiers != nil || req.PaymentRechargeBonusNotice != nil || req.PaymentLoadBalanceStrat != nil || req.PaymentProductNamePrefix != nil || req.PaymentProductNameSuffix != nil || req.PaymentHelpImageURL != nil || req.PaymentHelpText != nil || req.PaymentCancelRateLimitEnabled != nil || diff --git a/backend/internal/handler/dto/recharge_bonus_tiers.go b/backend/internal/handler/dto/recharge_bonus_tiers.go new file mode 100644 index 0000000000..21731566a8 --- /dev/null +++ b/backend/internal/handler/dto/recharge_bonus_tiers.go @@ -0,0 +1,7 @@ +package dto + +// RechargeBonusTier 充值赠送档位:余额充值支付金额 ≥ MinAmount 时,在到账基数上赠送 BonusPercent%。 +type RechargeBonusTier struct { + MinAmount float64 `json:"min_amount"` + BonusPercent float64 `json:"bonus_percent"` +} diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 4f16f11d45..aa1477a1be 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -279,11 +279,14 @@ type SystemSettings struct { PaymentBalanceRechargeMultiplier float64 `json:"payment_balance_recharge_multiplier"` PaymentSubscriptionUSDToCNYRate float64 `json:"payment_subscription_usd_to_cny_rate"` PaymentRechargeFeeRate float64 `json:"payment_recharge_fee_rate"` - PaymentLoadBalanceStrat string `json:"payment_load_balance_strategy"` - PaymentProductNamePrefix string `json:"payment_product_name_prefix"` - PaymentProductNameSuffix string `json:"payment_product_name_suffix"` - PaymentHelpImageURL string `json:"payment_help_image_url"` - PaymentHelpText string `json:"payment_help_text"` + // 充值赠送阶梯与活动文案 + PaymentRechargeBonusTiers []RechargeBonusTier `json:"payment_recharge_bonus_tiers"` + PaymentRechargeBonusNotice string `json:"payment_recharge_bonus_notice"` + PaymentLoadBalanceStrat string `json:"payment_load_balance_strategy"` + PaymentProductNamePrefix string `json:"payment_product_name_prefix"` + PaymentProductNameSuffix string `json:"payment_product_name_suffix"` + PaymentHelpImageURL string `json:"payment_help_image_url"` + PaymentHelpText string `json:"payment_help_text"` // Cancel rate limit PaymentCancelRateLimitEnabled bool `json:"payment_cancel_rate_limit_enabled"` diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index 9aab325504..3371fac39f 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -149,6 +149,8 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) { BalanceRechargeMultiplier: cfg.BalanceRechargeMultiplier, SubscriptionUSDToCNYRate: cfg.SubscriptionUSDToCNYRate, RechargeFeeRate: cfg.RechargeFeeRate, + RechargeBonusTiers: cfg.RechargeBonusTiers, + RechargeBonusNotice: cfg.RechargeBonusNotice, HelpText: cfg.HelpText, HelpImageURL: cfg.HelpImageURL, StripePublishableKey: cfg.StripePublishableKey, @@ -166,6 +168,8 @@ type checkoutInfoResponse struct { BalanceRechargeMultiplier float64 `json:"balance_recharge_multiplier"` SubscriptionUSDToCNYRate float64 `json:"subscription_usd_to_cny_rate"` RechargeFeeRate float64 `json:"recharge_fee_rate"` + RechargeBonusTiers []service.RechargeBonusTier `json:"recharge_bonus_tiers"` + RechargeBonusNotice string `json:"recharge_bonus_notice"` HelpText string `json:"help_text"` HelpImageURL string `json:"help_image_url"` StripePublishableKey string `json:"stripe_publishable_key"` @@ -482,6 +486,7 @@ type PublicOrderResult struct { Amount float64 `json:"amount"` PayAmount float64 `json:"pay_amount"` FeeRate float64 `json:"fee_rate"` + BonusAmount float64 `json:"bonus_amount"` Currency string `json:"currency"` PaymentType string `json:"payment_type"` OrderType string `json:"order_type"` @@ -517,6 +522,7 @@ func buildPublicOrderResult(order *dbent.PaymentOrder) PublicOrderResult { Amount: order.Amount, PayAmount: order.PayAmount, FeeRate: order.FeeRate, + BonusAmount: order.BonusAmount, Currency: service.PaymentOrderCurrency(order), PaymentType: order.PaymentType, OrderType: order.OrderType, @@ -625,6 +631,7 @@ type PaymentOrderResult struct { Amount float64 `json:"amount"` PayAmount float64 `json:"pay_amount"` FeeRate float64 `json:"fee_rate"` + BonusAmount float64 `json:"bonus_amount"` Currency string `json:"currency"` PaymentType string `json:"payment_type"` OutTradeNo string `json:"out_trade_no"` @@ -663,6 +670,7 @@ func sanitizePaymentOrderForResponse(order *dbent.PaymentOrder) *PaymentOrderRes Amount: order.Amount, PayAmount: order.PayAmount, FeeRate: order.FeeRate, + BonusAmount: order.BonusAmount, Currency: service.PaymentOrderCurrency(order), PaymentType: order.PaymentType, OutTradeNo: order.OutTradeNo, diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 3d7b757b4e..f420e4d2b5 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -971,6 +971,8 @@ func TestAPIContracts(t *testing.T) { "payment_balance_recharge_multiplier": 0, "payment_subscription_usd_to_cny_rate": 0, "payment_recharge_fee_rate": 0, + "payment_recharge_bonus_tiers": [], + "payment_recharge_bonus_notice": "", "payment_load_balance_strategy": "", "payment_product_name_prefix": "", "payment_product_name_suffix": "", @@ -1291,6 +1293,8 @@ func TestAPIContracts(t *testing.T) { "payment_balance_recharge_multiplier": 0, "payment_subscription_usd_to_cny_rate": 0, "payment_recharge_fee_rate": 0, + "payment_recharge_bonus_tiers": [], + "payment_recharge_bonus_notice": "", "payment_load_balance_strategy": "", "payment_product_name_prefix": "", "payment_product_name_suffix": "", diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go index aac1cc8e26..ab28c3efa0 100644 --- a/backend/internal/service/payment_config_service.go +++ b/backend/internal/service/payment_config_service.go @@ -62,12 +62,16 @@ type PaymentConfig struct { // SubscriptionUSDToCNYRate 为 0 时订阅换算关闭(兼容存量行为)。 SubscriptionUSDToCNYRate float64 `json:"subscription_usd_to_cny_rate"` RechargeFeeRate float64 `json:"recharge_fee_rate"` - LoadBalanceStrategy string `json:"load_balance_strategy"` - ProductNamePrefix string `json:"product_name_prefix"` - ProductNameSuffix string `json:"product_name_suffix"` - HelpImageURL string `json:"help_image_url"` - HelpText string `json:"help_text"` - StripePublishableKey string `json:"stripe_publishable_key,omitempty"` + // RechargeBonusTiers 余额充值赠送阶梯(按 MinAmount 升序);空表示不赠送。 + RechargeBonusTiers []RechargeBonusTier `json:"recharge_bonus_tiers"` + // RechargeBonusNotice 充值页展示的 Markdown 活动文案;空表示不展示。 + RechargeBonusNotice string `json:"recharge_bonus_notice"` + LoadBalanceStrategy string `json:"load_balance_strategy"` + ProductNamePrefix string `json:"product_name_prefix"` + ProductNameSuffix string `json:"product_name_suffix"` + HelpImageURL string `json:"help_image_url"` + HelpText string `json:"help_text"` + StripePublishableKey string `json:"stripe_publishable_key,omitempty"` // Cancel rate limit settings CancelRateLimitEnabled bool `json:"cancel_rate_limit_enabled"` @@ -95,11 +99,14 @@ type UpdatePaymentConfigRequest struct { BalanceRechargeMultiplier *float64 `json:"balance_recharge_multiplier"` SubscriptionUSDToCNYRate *float64 `json:"subscription_usd_to_cny_rate"` RechargeFeeRate *float64 `json:"recharge_fee_rate"` - LoadBalanceStrategy *string `json:"load_balance_strategy"` - ProductNamePrefix *string `json:"product_name_prefix"` - ProductNameSuffix *string `json:"product_name_suffix"` - HelpImageURL *string `json:"help_image_url"` - HelpText *string `json:"help_text"` + // RechargeBonusTiers nil 表示不更新;空切片表示清空阶梯。 + RechargeBonusTiers *[]RechargeBonusTier `json:"recharge_bonus_tiers"` + RechargeBonusNotice *string `json:"recharge_bonus_notice"` + LoadBalanceStrategy *string `json:"load_balance_strategy"` + ProductNamePrefix *string `json:"product_name_prefix"` + ProductNameSuffix *string `json:"product_name_suffix"` + HelpImageURL *string `json:"help_image_url"` + HelpText *string `json:"help_text"` // Cancel rate limit settings CancelRateLimitEnabled *bool `json:"cancel_rate_limit_enabled"` @@ -220,6 +227,7 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo SettingPaymentEnabled, SettingMinRechargeAmount, SettingMaxRechargeAmount, SettingDailyRechargeLimit, SettingOrderTimeoutMinutes, SettingMaxPendingOrders, SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingBalanceRechargeMult, SettingSubscriptionUSDToCNYRate, SettingRechargeFeeRate, SettingLoadBalanceStrategy, + SettingRechargeBonusTiers, SettingRechargeBonusNotice, SettingProductNamePrefix, SettingProductNameSuffix, SettingHelpImageURL, SettingHelpText, SettingCancelRateLimitOn, SettingCancelRateLimitMax, @@ -250,6 +258,8 @@ func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *Payme BalanceRechargeMultiplier: normalizeBalanceRechargeMultiplier(pcParseFloat(vals[SettingBalanceRechargeMult], defaultBalanceRechargeMultiplier)), SubscriptionUSDToCNYRate: normalizeSubscriptionUSDToCNYRate(pcParseFloat(vals[SettingSubscriptionUSDToCNYRate], 0)), RechargeFeeRate: pcParseFloat(vals[SettingRechargeFeeRate], 0), + RechargeBonusTiers: parseRechargeBonusTiers(vals[SettingRechargeBonusTiers]), + RechargeBonusNotice: vals[SettingRechargeBonusNotice], LoadBalanceStrategy: vals[SettingLoadBalanceStrategy], ProductNamePrefix: vals[SettingProductNamePrefix], ProductNameSuffix: vals[SettingProductNameSuffix], @@ -343,6 +353,23 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda return infraerrors.BadRequest("INVALID_RECHARGE_FEE_RATE", "recharge fee rate allows at most 2 decimal places") } } + rechargeBonusTiersValue := "" + if req.RechargeBonusTiers != nil { + normalized, err := NormalizeRechargeBonusTiers(*req.RechargeBonusTiers) + if err != nil { + return infraerrors.BadRequest("INVALID_RECHARGE_BONUS_TIERS", err.Error()) + } + encoded, err := encodeRechargeBonusTiers(normalized) + if err != nil { + return err + } + rechargeBonusTiersValue = encoded + } + if req.RechargeBonusNotice != nil { + if err := validateRechargeBonusNotice(*req.RechargeBonusNotice); err != nil { + return infraerrors.BadRequest("INVALID_RECHARGE_BONUS_NOTICE", err.Error()) + } + } m := make(map[string]string) if req.Enabled != nil { m[SettingPaymentEnabled] = formatBoolOrEmpty(req.Enabled) @@ -377,6 +404,12 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda if req.RechargeFeeRate != nil { m[SettingRechargeFeeRate] = formatNonNegativeFloat(req.RechargeFeeRate) } + if req.RechargeBonusTiers != nil { + m[SettingRechargeBonusTiers] = rechargeBonusTiersValue + } + if req.RechargeBonusNotice != nil { + m[SettingRechargeBonusNotice] = strings.TrimSpace(*req.RechargeBonusNotice) + } if req.LoadBalanceStrategy != nil { m[SettingLoadBalanceStrategy] = derefStr(req.LoadBalanceStrategy) } diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go index 2b70a90ef6..4c363d3e03 100644 --- a/backend/internal/service/payment_fulfillment.go +++ b/backend/internal/service/payment_fulfillment.go @@ -741,7 +741,10 @@ func affiliateRebateBaseAmount(o *dbent.PaymentOrder) float64 { return 0 } switch o.OrderType { - case payment.OrderTypeBalance, payment.OrderTypeSubscription: + case payment.OrderTypeBalance: + // 返利只按实充部分计算,赠送额度不参与 + return paymentOrderAmountWithoutBonus(o) + case payment.OrderTypeSubscription: return o.Amount default: return 0 diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index da7178dd7f..be28657015 100644 --- a/backend/internal/service/payment_order.go +++ b/backend/internal/service/payment_order.go @@ -55,11 +55,15 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest } orderAmount := req.Amount limitAmount := req.Amount + bonusAmount := 0.0 if plan != nil { orderAmount = plan.Price limitAmount = plan.Price } else if req.OrderType == payment.OrderTypeBalance { - orderAmount = calculateCreditedBalance(req.Amount, cfg.BalanceRechargeMultiplier) + // 到账 = 基数(支付金额 × 倍率)+ 赠送(按支付金额命中阶梯,按基数计百分比) + baseCredited := calculateCreditedBalance(req.Amount, cfg.BalanceRechargeMultiplier) + bonusAmount, _ = resolveRechargeBonus(cfg.RechargeBonusTiers, req.Amount, baseCredited) + orderAmount = addRechargeBonus(baseCredited, bonusAmount) } feeRate := cfg.RechargeFeeRate methodCurrency := payment.DefaultPaymentCurrency @@ -100,7 +104,7 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest if oauthResp != nil { return oauthResp, nil } - order, err := s.createOrderInTx(ctx, req, user, plan, cfg, orderAmount, limitAmount, feeRate, payAmount, sel) + order, err := s.createOrderInTx(ctx, req, user, plan, cfg, orderAmount, limitAmount, feeRate, payAmount, bonusAmount, sel) if err != nil { return nil, err } @@ -149,7 +153,7 @@ func (s *PaymentService) validateSubOrder(ctx context.Context, req CreateOrderRe return plan, nil } -func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderRequest, user *User, plan *dbent.SubscriptionPlan, cfg *PaymentConfig, orderAmount, limitAmount, feeRate, payAmount float64, sel *payment.InstanceSelection) (*dbent.PaymentOrder, error) { +func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderRequest, user *User, plan *dbent.SubscriptionPlan, cfg *PaymentConfig, orderAmount, limitAmount, feeRate, payAmount, bonusAmount float64, sel *payment.InstanceSelection) (*dbent.PaymentOrder, error) { tx, err := s.entClient.Tx(ctx) if err != nil { return nil, fmt.Errorf("begin transaction: %w", err) @@ -185,6 +189,7 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq SetAmount(orderAmount). SetPayAmount(payAmount). SetFeeRate(feeRate). + SetBonusAmount(bonusAmount). SetRechargeCode(""). SetOutTradeNo(outTradeNo). SetPaymentType(req.PaymentType). @@ -471,6 +476,7 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen s.writeAuditLog(ctx, order.ID, "ORDER_CREATED", fmt.Sprintf("user:%d", req.UserID), map[string]any{ "paymentAmount": req.Amount, "creditedAmount": order.Amount, + "bonusAmount": order.BonusAmount, "payAmount": order.PayAmount, "paymentType": req.PaymentType, "orderType": req.OrderType, @@ -735,6 +741,7 @@ func buildCreateOrderResponse(order *dbent.PaymentOrder, req CreateOrderRequest, Amount: order.Amount, PayAmount: payAmount, FeeRate: order.FeeRate, + BonusAmount: order.BonusAmount, Status: OrderStatusPending, ResultType: resultType, PaymentType: req.PaymentType, diff --git a/backend/internal/service/payment_order_provider_snapshot_test.go b/backend/internal/service/payment_order_provider_snapshot_test.go index 127202bc23..19047959ed 100644 --- a/backend/internal/service/payment_order_provider_snapshot_test.go +++ b/backend/internal/service/payment_order_provider_snapshot_test.go @@ -88,6 +88,7 @@ func TestCreateOrderInTx_WritesProviderSnapshot(t *testing.T) { 88, 0, 88, + 0, &payment.InstanceSelection{ InstanceID: strconv.FormatInt(instance.ID, 10), ProviderKey: payment.TypeAlipay, diff --git a/backend/internal/service/payment_recharge_bonus.go b/backend/internal/service/payment_recharge_bonus.go new file mode 100644 index 0000000000..a3598bc6a7 --- /dev/null +++ b/backend/internal/service/payment_recharge_bonus.go @@ -0,0 +1,197 @@ +package service + +import ( + "encoding/json" + "fmt" + "log/slog" + "math" + "sort" + "strings" + "unicode/utf8" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/payment" + "github.com/shopspring/decimal" +) + +// 充值赠送阶梯:余额充值订单按用户输入的支付金额命中阶梯(取不超过该金额的最大 MinAmount), +// 在到账基数(输入金额 × 充值倍率)之上额外赠送 BonusPercent% 的 USD 余额。 +// 订阅订单不参与。赠送在下单时按当时配置计算并落到 payment_orders.bonus_amount,后续改配置不影响已建订单。 +const ( + // SettingRechargeBonusTiers 存 JSON 数组(RechargeBonusTier 列表),空/缺失表示未启用赠送。 + SettingRechargeBonusTiers = "RECHARGE_BONUS_TIERS" + // SettingRechargeBonusNotice 充值页金额卡顶部展示的 Markdown 活动文案,空表示不展示。 + SettingRechargeBonusNotice = "RECHARGE_BONUS_NOTICE" +) + +const ( + maxRechargeBonusTiers = 20 + maxRechargeBonusPercent = 1000 + maxRechargeBonusNoticeRunes = 10000 + rechargeBonusAmountEpsilon = 1e-9 +) + +// RechargeBonusTier 一个赠送档位:支付金额 ≥ MinAmount 时赠送到账基数的 BonusPercent%。 +type RechargeBonusTier struct { + MinAmount float64 `json:"min_amount"` + BonusPercent float64 `json:"bonus_percent"` +} + +func rechargeBonusValueValid(v float64, max float64) bool { + if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 || v > max { + return false + } + d := decimal.NewFromFloat(v) + return d.Equal(d.Round(2)) +} + +// NormalizeRechargeBonusTiers 严格归一化(写路径):任何非法项直接报错; +// 成功时返回按 MinAmount 升序排序的副本。 +func NormalizeRechargeBonusTiers(raw []RechargeBonusTier) ([]RechargeBonusTier, error) { + if len(raw) == 0 { + return []RechargeBonusTier{}, nil + } + if len(raw) > maxRechargeBonusTiers { + return nil, fmt.Errorf("recharge bonus tiers exceed limit of %d", maxRechargeBonusTiers) + } + out := make([]RechargeBonusTier, 0, len(raw)) + seen := make(map[string]struct{}, len(raw)) + for _, tier := range raw { + if !rechargeBonusValueValid(tier.MinAmount, math.MaxFloat64) { + return nil, fmt.Errorf("recharge bonus tier min amount must be a non-negative number with at most 2 decimals") + } + if !rechargeBonusValueValid(tier.BonusPercent, maxRechargeBonusPercent) { + return nil, fmt.Errorf("recharge bonus tier percent must be between 0 and %d with at most 2 decimals", maxRechargeBonusPercent) + } + key := decimal.NewFromFloat(tier.MinAmount).Round(2).String() + if _, dup := seen[key]; dup { + return nil, fmt.Errorf("duplicate recharge bonus tier min amount: %s", key) + } + seen[key] = struct{}{} + out = append(out, RechargeBonusTier{MinAmount: tier.MinAmount, BonusPercent: tier.BonusPercent}) + } + sortRechargeBonusTiers(out) + return out, nil +} + +func sortRechargeBonusTiers(tiers []RechargeBonusTier) { + sort.SliceStable(tiers, func(i, j int) bool { + return tiers[i].MinAmount < tiers[j].MinAmount + }) +} + +// encodeRechargeBonusTiers 序列化为设置值;空列表存空串,与「未配置」保持同一形态。 +func encodeRechargeBonusTiers(tiers []RechargeBonusTier) (string, error) { + if len(tiers) == 0 { + return "", nil + } + raw, err := json.Marshal(tiers) + if err != nil { + return "", fmt.Errorf("marshal recharge bonus tiers: %w", err) + } + return string(raw), nil +} + +// parseRechargeBonusTiers 宽松解析(读路径):非法条目丢弃而非报错,避免历史错配置阻断下单。 +// 同一 MinAmount 重复时保留先出现的档位。始终返回非 nil 切片,便于 JSON 输出为 []。 +func parseRechargeBonusTiers(raw string) []RechargeBonusTier { + out := make([]RechargeBonusTier, 0) + raw = strings.TrimSpace(raw) + if raw == "" { + return out + } + var items []RechargeBonusTier + if err := json.Unmarshal([]byte(raw), &items); err != nil { + slog.Warn("[Payment] parseRechargeBonusTiers: unmarshal failed", "error", err) + return out + } + seen := make(map[string]struct{}, len(items)) + for _, tier := range items { + if !rechargeBonusValueValid(tier.MinAmount, math.MaxFloat64) || !rechargeBonusValueValid(tier.BonusPercent, maxRechargeBonusPercent) { + continue + } + key := decimal.NewFromFloat(tier.MinAmount).Round(2).String() + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + out = append(out, tier) + } + sortRechargeBonusTiers(out) + return out +} + +func validateRechargeBonusNotice(notice string) error { + if utf8.RuneCountInString(notice) > maxRechargeBonusNoticeRunes { + return fmt.Errorf("recharge bonus notice exceeds %d characters", maxRechargeBonusNoticeRunes) + } + return nil +} + +// matchRechargeBonusTier 返回不超过 paymentAmount 的最大档位;tiers 需已按 MinAmount 升序。 +func matchRechargeBonusTier(tiers []RechargeBonusTier, paymentAmount float64) (RechargeBonusTier, bool) { + if math.IsNaN(paymentAmount) || math.IsInf(paymentAmount, 0) || paymentAmount <= 0 { + return RechargeBonusTier{}, false + } + var matched RechargeBonusTier + found := false + for _, tier := range tiers { + if paymentAmount+rechargeBonusAmountEpsilon < tier.MinAmount { + break + } + matched = tier + found = true + } + return matched, found +} + +// calculateRechargeBonus 赠送金额 = 到账基数 × 百分比,保留两位小数(四舍五入)。 +func calculateRechargeBonus(baseCredited, bonusPercent float64) float64 { + if baseCredited <= 0 || bonusPercent <= 0 || math.IsNaN(baseCredited) || math.IsNaN(bonusPercent) { + return 0 + } + return decimal.NewFromFloat(baseCredited). + Mul(decimal.NewFromFloat(bonusPercent)). + Div(decimal.NewFromInt(100)). + Round(2). + InexactFloat64() +} + +// resolveRechargeBonus 按支付金额命中阶梯并计算赠送;未命中或赠送为 0 时返回 (0, 0)。 +func resolveRechargeBonus(tiers []RechargeBonusTier, paymentAmount, baseCredited float64) (bonus float64, percent float64) { + tier, ok := matchRechargeBonusTier(tiers, paymentAmount) + if !ok { + return 0, 0 + } + bonus = calculateRechargeBonus(baseCredited, tier.BonusPercent) + if bonus <= 0 { + return 0, 0 + } + return bonus, tier.BonusPercent +} + +// addRechargeBonus 到账总额 = 基数 + 赠送,两位小数。 +func addRechargeBonus(baseCredited, bonus float64) float64 { + return decimal.NewFromFloat(baseCredited). + Add(decimal.NewFromFloat(bonus)). + Round(2). + InexactFloat64() +} + +// paymentOrderAmountWithoutBonus 订单到账金额剔除赠送后的实充部分(USD),用于推广返利基数。 +func paymentOrderAmountWithoutBonus(o *dbent.PaymentOrder) float64 { + if o == nil { + return 0 + } + if o.OrderType != payment.OrderTypeBalance || o.BonusAmount <= 0 { + return o.Amount + } + base := decimal.NewFromFloat(o.Amount). + Sub(decimal.NewFromFloat(o.BonusAmount)). + Round(2). + InexactFloat64() + if base < 0 { + return 0 + } + return base +} diff --git a/backend/internal/service/payment_recharge_bonus_test.go b/backend/internal/service/payment_recharge_bonus_test.go new file mode 100644 index 0000000000..d0b459e4c2 --- /dev/null +++ b/backend/internal/service/payment_recharge_bonus_test.go @@ -0,0 +1,251 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/payment" + "github.com/stretchr/testify/require" +) + +func TestNormalizeRechargeBonusTiers(t *testing.T) { + t.Run("sorts ascending by min amount", func(t *testing.T) { + out, err := NormalizeRechargeBonusTiers([]RechargeBonusTier{ + {MinAmount: 1000, BonusPercent: 35}, + {MinAmount: 100, BonusPercent: 20}, + {MinAmount: 500, BonusPercent: 30}, + }) + require.NoError(t, err) + require.Equal(t, []RechargeBonusTier{ + {MinAmount: 100, BonusPercent: 20}, + {MinAmount: 500, BonusPercent: 30}, + {MinAmount: 1000, BonusPercent: 35}, + }, out) + }) + + t.Run("empty input yields empty non-nil slice", func(t *testing.T) { + out, err := NormalizeRechargeBonusTiers(nil) + require.NoError(t, err) + require.NotNil(t, out) + require.Len(t, out, 0) + }) + + t.Run("allows zero threshold and zero percent", func(t *testing.T) { + out, err := NormalizeRechargeBonusTiers([]RechargeBonusTier{{MinAmount: 0, BonusPercent: 0}}) + require.NoError(t, err) + require.Len(t, out, 1) + }) + + t.Run("rejects invalid values", func(t *testing.T) { + cases := map[string][]RechargeBonusTier{ + "negative min": {{MinAmount: -1, BonusPercent: 10}}, + "min three decimals": {{MinAmount: 100.123, BonusPercent: 10}}, + "negative percent": {{MinAmount: 100, BonusPercent: -5}}, + "percent over limit": {{MinAmount: 100, BonusPercent: 1000.01}}, + "percent 3 decimals": {{MinAmount: 100, BonusPercent: 12.345}}, + "duplicate min": {{MinAmount: 100, BonusPercent: 10}, {MinAmount: 100, BonusPercent: 20}}, + "duplicate min 2 dec": {{MinAmount: 100, BonusPercent: 10}, {MinAmount: 100.00, BonusPercent: 20}}, + } + for name, tiers := range cases { + _, err := NormalizeRechargeBonusTiers(tiers) + require.Error(t, err, name) + } + }) + + t.Run("rejects too many tiers", func(t *testing.T) { + tiers := make([]RechargeBonusTier, 0, maxRechargeBonusTiers+1) + for i := 0; i <= maxRechargeBonusTiers; i++ { + tiers = append(tiers, RechargeBonusTier{MinAmount: float64(i + 1), BonusPercent: 1}) + } + _, err := NormalizeRechargeBonusTiers(tiers) + require.Error(t, err) + }) +} + +func TestParseRechargeBonusTiers(t *testing.T) { + t.Run("empty or invalid json yields empty slice", func(t *testing.T) { + require.NotNil(t, parseRechargeBonusTiers("")) + require.Len(t, parseRechargeBonusTiers(""), 0) + require.Len(t, parseRechargeBonusTiers("not json"), 0) + }) + + t.Run("drops invalid entries keeps first duplicate and sorts", func(t *testing.T) { + raw := `[{"min_amount":500,"bonus_percent":30},{"min_amount":-1,"bonus_percent":5},` + + `{"min_amount":100,"bonus_percent":20},{"min_amount":100,"bonus_percent":99},` + + `{"min_amount":50,"bonus_percent":5000}]` + out := parseRechargeBonusTiers(raw) + require.Equal(t, []RechargeBonusTier{ + {MinAmount: 100, BonusPercent: 20}, + {MinAmount: 500, BonusPercent: 30}, + }, out) + }) + + t.Run("round trips encode", func(t *testing.T) { + tiers := []RechargeBonusTier{{MinAmount: 100, BonusPercent: 20}, {MinAmount: 500, BonusPercent: 30}} + encoded, err := encodeRechargeBonusTiers(tiers) + require.NoError(t, err) + require.Equal(t, tiers, parseRechargeBonusTiers(encoded)) + + empty, err := encodeRechargeBonusTiers(nil) + require.NoError(t, err) + require.Equal(t, "", empty) + }) +} + +func TestMatchRechargeBonusTier(t *testing.T) { + tiers := []RechargeBonusTier{ + {MinAmount: 100, BonusPercent: 20}, + {MinAmount: 500, BonusPercent: 30}, + {MinAmount: 1000, BonusPercent: 35}, + } + cases := []struct { + amount float64 + percent float64 + ok bool + }{ + {amount: 0, ok: false}, + {amount: 99.99, ok: false}, + {amount: 100, percent: 20, ok: true}, + {amount: 499.99, percent: 20, ok: true}, + {amount: 500, percent: 30, ok: true}, + {amount: 1000, percent: 35, ok: true}, + {amount: 1000000, percent: 35, ok: true}, + } + for _, tc := range cases { + tier, ok := matchRechargeBonusTier(tiers, tc.amount) + require.Equal(t, tc.ok, ok, "amount %v", tc.amount) + if ok { + require.Equal(t, tc.percent, tier.BonusPercent, "amount %v", tc.amount) + } + } + + t.Run("float boundary 0.1+0.2 still matches 0.3 threshold", func(t *testing.T) { + _, ok := matchRechargeBonusTier([]RechargeBonusTier{{MinAmount: 0.3, BonusPercent: 1}}, 0.1+0.2) + require.True(t, ok) + }) + + t.Run("no tiers never matches", func(t *testing.T) { + _, ok := matchRechargeBonusTier(nil, 100) + require.False(t, ok) + }) +} + +func TestResolveRechargeBonus(t *testing.T) { + tiers := []RechargeBonusTier{{MinAmount: 100, BonusPercent: 20}, {MinAmount: 500, BonusPercent: 30}} + + t.Run("bonus is percent of credited base rounded to cents", func(t *testing.T) { + bonus, pct := resolveRechargeBonus(tiers, 100, 100) + require.Equal(t, 20.0, bonus) + require.Equal(t, 20.0, pct) + require.Equal(t, 120.0, addRechargeBonus(100, bonus)) + + // 33.33 * 15% = 4.9995 → 5.00 + bonus, _ = resolveRechargeBonus([]RechargeBonusTier{{MinAmount: 1, BonusPercent: 15}}, 33.33, 33.33) + require.Equal(t, 5.0, bonus) + }) + + t.Run("threshold uses payment amount while bonus uses credited base", func(t *testing.T) { + // 1000 CNY paid, multiplier 0.14 → base 140 USD; tier matched by 1000 (30%) + base := calculateCreditedBalance(1000, 0.14) + bonus, pct := resolveRechargeBonus(tiers, 1000, base) + require.Equal(t, 30.0, pct) + require.Equal(t, 42.0, bonus) + require.Equal(t, 182.0, addRechargeBonus(base, bonus)) + }) + + t.Run("no match or zero percent yields zero", func(t *testing.T) { + bonus, pct := resolveRechargeBonus(tiers, 50, 50) + require.Zero(t, bonus) + require.Zero(t, pct) + + bonus, pct = resolveRechargeBonus([]RechargeBonusTier{{MinAmount: 10, BonusPercent: 0}}, 50, 50) + require.Zero(t, bonus) + require.Zero(t, pct) + }) +} + +func TestParsePaymentConfigRechargeBonus(t *testing.T) { + svc := &PaymentConfigService{} + + t.Run("defaults", func(t *testing.T) { + cfg := svc.parsePaymentConfig(map[string]string{}) + require.NotNil(t, cfg.RechargeBonusTiers) + require.Len(t, cfg.RechargeBonusTiers, 0) + require.Equal(t, "", cfg.RechargeBonusNotice) + }) + + t.Run("reads tiers and notice", func(t *testing.T) { + cfg := svc.parsePaymentConfig(map[string]string{ + SettingRechargeBonusTiers: `[{"min_amount":500,"bonus_percent":30},{"min_amount":100,"bonus_percent":20}]`, + SettingRechargeBonusNotice: "**满 100 送 20%**", + }) + require.Equal(t, []RechargeBonusTier{{MinAmount: 100, BonusPercent: 20}, {MinAmount: 500, BonusPercent: 30}}, cfg.RechargeBonusTiers) + require.Equal(t, "**满 100 送 20%**", cfg.RechargeBonusNotice) + }) +} + +func TestUpdatePaymentConfigRechargeBonus(t *testing.T) { + ctx := context.Background() + + t.Run("persists normalized tiers and trimmed notice", func(t *testing.T) { + repo := &paymentConfigSettingRepoStub{values: map[string]string{}} + svc := &PaymentConfigService{settingRepo: repo} + tiers := []RechargeBonusTier{{MinAmount: 500, BonusPercent: 30}, {MinAmount: 100, BonusPercent: 20}} + notice := " 活动文案 " + require.NoError(t, svc.UpdatePaymentConfig(ctx, UpdatePaymentConfigRequest{ + RechargeBonusTiers: &tiers, + RechargeBonusNotice: ¬ice, + })) + require.Equal(t, `[{"min_amount":100,"bonus_percent":20},{"min_amount":500,"bonus_percent":30}]`, repo.updates[SettingRechargeBonusTiers]) + require.Equal(t, "活动文案", repo.updates[SettingRechargeBonusNotice]) + + cfg, err := svc.GetPaymentConfig(ctx) + require.NoError(t, err) + require.Equal(t, []RechargeBonusTier{{MinAmount: 100, BonusPercent: 20}, {MinAmount: 500, BonusPercent: 30}}, cfg.RechargeBonusTiers) + }) + + t.Run("empty tiers clears setting and omitted fields are untouched", func(t *testing.T) { + repo := &paymentConfigSettingRepoStub{values: map[string]string{ + SettingRechargeBonusTiers: `[{"min_amount":100,"bonus_percent":20}]`, + SettingRechargeBonusNotice: "keep me", + }} + svc := &PaymentConfigService{settingRepo: repo} + empty := []RechargeBonusTier{} + require.NoError(t, svc.UpdatePaymentConfig(ctx, UpdatePaymentConfigRequest{RechargeBonusTiers: &empty})) + value, ok := repo.updates[SettingRechargeBonusTiers] + require.True(t, ok) + require.Equal(t, "", value) + _, touched := repo.updates[SettingRechargeBonusNotice] + require.False(t, touched) + require.Equal(t, "keep me", repo.values[SettingRechargeBonusNotice]) + }) + + t.Run("rejects invalid tiers", func(t *testing.T) { + repo := &paymentConfigSettingRepoStub{values: map[string]string{}} + svc := &PaymentConfigService{settingRepo: repo} + bad := []RechargeBonusTier{{MinAmount: 100, BonusPercent: 20}, {MinAmount: 100, BonusPercent: 30}} + err := svc.UpdatePaymentConfig(ctx, UpdatePaymentConfigRequest{RechargeBonusTiers: &bad}) + require.Error(t, err) + require.Nil(t, repo.updates) + }) +} + +func TestAffiliateRebateBaseAmountExcludesRechargeBonus(t *testing.T) { + require.Equal(t, 100.0, affiliateRebateBaseAmount(&dbent.PaymentOrder{ + OrderType: payment.OrderTypeBalance, Amount: 130, BonusAmount: 30, + })) + require.Equal(t, 130.0, affiliateRebateBaseAmount(&dbent.PaymentOrder{ + OrderType: payment.OrderTypeBalance, Amount: 130, + })) + // 订阅订单不受 bonus 字段影响 + require.Equal(t, 50.0, affiliateRebateBaseAmount(&dbent.PaymentOrder{ + OrderType: payment.OrderTypeSubscription, Amount: 50, BonusAmount: 30, + })) + // 异常数据:赠送大于总额时钳到 0 + require.Equal(t, 0.0, affiliateRebateBaseAmount(&dbent.PaymentOrder{ + OrderType: payment.OrderTypeBalance, Amount: 10, BonusAmount: 30, + })) +} diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index 792a842a23..dae1a7d938 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -92,6 +92,7 @@ type CreateOrderResponse struct { Amount float64 `json:"amount"` PayAmount float64 `json:"pay_amount"` FeeRate float64 `json:"fee_rate"` + BonusAmount float64 `json:"bonus_amount"` Status string `json:"status"` ResultType payment.CreatePaymentResultType `json:"result_type,omitempty"` PaymentType string `json:"payment_type"` diff --git a/backend/migrations/241_add_payment_order_bonus_amount.sql b/backend/migrations/241_add_payment_order_bonus_amount.sql new file mode 100644 index 0000000000..1eeddfadba --- /dev/null +++ b/backend/migrations/241_add_payment_order_bonus_amount.sql @@ -0,0 +1,3 @@ +-- 充值赠送额度:余额充值订单命中赠送阶梯时的赠送 USD 金额。 +-- 已计入 payment_orders.amount(到账总额),单独落列用于订单展示与推广返利基数剔除。 +ALTER TABLE payment_orders ADD COLUMN IF NOT EXISTS bonus_amount DECIMAL(20,2) NOT NULL DEFAULT 0; diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 1fcc0021b1..2c57aec3b2 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -10,6 +10,7 @@ import type { LoginAgreementDocument, NotifyEmailEntry, } from "@/types"; +import type { RechargeBonusTier } from "@/utils/rechargeBonus"; export interface DefaultSubscriptionSetting { group_id: number; @@ -670,6 +671,8 @@ export interface SystemSettings { payment_balance_recharge_multiplier: number; payment_subscription_usd_to_cny_rate: number; payment_recharge_fee_rate: number; + payment_recharge_bonus_tiers?: RechargeBonusTier[]; + payment_recharge_bonus_notice?: string; payment_load_balance_strategy: string; payment_product_name_prefix: string; payment_product_name_suffix: string; @@ -991,6 +994,8 @@ export interface UpdateSettingsRequest { payment_balance_recharge_multiplier?: number; payment_subscription_usd_to_cny_rate?: number; payment_recharge_fee_rate?: number; + payment_recharge_bonus_tiers?: RechargeBonusTier[]; + payment_recharge_bonus_notice?: string; payment_load_balance_strategy?: string; payment_product_name_prefix?: string; payment_product_name_suffix?: string; diff --git a/frontend/src/components/admin/payment/AdminOrderDetail.vue b/frontend/src/components/admin/payment/AdminOrderDetail.vue index 28bd614ac8..277cae5e7a 100644 --- a/frontend/src/components/admin/payment/AdminOrderDetail.vue +++ b/frontend/src/components/admin/payment/AdminOrderDetail.vue @@ -29,7 +29,11 @@

{{ t('payment.orders.payAmount') }}

{{ paymentAmountSymbol }}{{ order.pay_amount.toFixed(2) }}

-
+
+

{{ t('payment.orders.bonusAmount') }}

+

+{{ creditedAmountSymbol }}{{ (order.bonus_amount ?? 0).toFixed(2) }}

+
+

{{ t('payment.orders.creditedAmount') }}

{{ creditedAmountSymbol }}{{ order.amount.toFixed(2) }}

diff --git a/frontend/src/components/admin/payment/AdminOrderTable.vue b/frontend/src/components/admin/payment/AdminOrderTable.vue index 5290866cae..dbbb5e87e0 100644 --- a/frontend/src/components/admin/payment/AdminOrderTable.vue +++ b/frontend/src/components/admin/payment/AdminOrderTable.vue @@ -57,8 +57,11 @@ ({{ row.fee_rate }}%) -
+
{{ t('payment.orders.creditedAmount') }}: {{ creditedAmountSymbol }}{{ row.amount.toFixed(2) }} + + ({{ t('payment.orders.bonusIncluded', { amount: creditedAmountSymbol + (row.bonus_amount ?? 0).toFixed(2) }) }}) +
diff --git a/frontend/src/components/admin/settings/RechargeBonusTierEditor.vue b/frontend/src/components/admin/settings/RechargeBonusTierEditor.vue new file mode 100644 index 0000000000..aaf641b68d --- /dev/null +++ b/frontend/src/components/admin/settings/RechargeBonusTierEditor.vue @@ -0,0 +1,206 @@ + + + diff --git a/frontend/src/components/payment/AmountInput.vue b/frontend/src/components/payment/AmountInput.vue index 0e0c9d339a..601193ad4a 100644 --- a/frontend/src/components/payment/AmountInput.vue +++ b/frontend/src/components/payment/AmountInput.vue @@ -5,20 +5,36 @@ -
+
@@ -48,16 +64,24 @@