diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index f8f3c0872..b112f9132 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -1.0.18 +1.0.21 diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 501d6b9f7..db39d3924 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -79,6 +79,7 @@ func provideCleanup( opsScheduledReport *service.OpsScheduledReportService, opsSystemLogSink *service.OpsSystemLogSink, schedulerSnapshot *service.SchedulerSnapshotService, + groupRateSchedule *service.GroupRateScheduleService, tokenRefresh *service.TokenRefreshService, accountExpiry *service.AccountExpiryService, subscriptionExpiry *service.SubscriptionExpiryService, @@ -153,6 +154,12 @@ func provideCleanup( } return nil }}, + {"GroupRateScheduleService", func() error { + if groupRateSchedule != nil { + groupRateSchedule.Stop() + } + return nil + }}, {"UsageCleanupService", func() error { if usageCleanup != nil { usageCleanup.Stop() diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 55c03b8d5..a9ac9bd84 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -63,10 +63,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { apiKeyRepository := repository.NewAPIKeyRepository(client, db) userRPMCache := repository.NewUserRPMCache(redisClient) userGroupRateRepository := repository.NewUserGroupRateRepository(db) + groupRateScheduleRepository := repository.NewGroupRateScheduleRepository(db) billingCacheService := service.ProvideBillingCacheService(billingCache, userRepository, userSubscriptionRepository, apiKeyRepository, userRPMCache, userGroupRateRepository, configConfig) apiKeyCache := repository.NewAPIKeyCache(redisClient) apiKeyService := service.ProvideAPIKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, userGroupRateRepository, apiKeyCache, configConfig, settingService, billingCacheService) apiKeyAuthCacheInvalidator := service.ProvideAPIKeyAuthCacheInvalidator(apiKeyService) + groupRateScheduleService := service.ProvideGroupRateScheduleService(groupRateScheduleRepository, groupRepository, apiKeyAuthCacheInvalidator) promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator) subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig) affiliateRepository := repository.NewAffiliateRepository(client, db) @@ -158,7 +160,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { rpmCache := repository.NewRPMCache(redisClient) groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache) channelMonitorUserHandler := handler.NewChannelMonitorUserHandler(channelMonitorService, settingService, groupCapacityService) - groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService) + groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService, groupRateScheduleService) crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig) accountHandler := admin.NewAccountHandler(adminService, accountService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, accountBatchTaskService) accountBatchTaskService.Start() @@ -301,7 +303,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig) paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService) channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService) - v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, subsiteMaintenanceService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner) + v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, groupRateScheduleService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, subsiteMaintenanceService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner) application := &Application{ Server: httpServer, Cleanup: v, @@ -337,6 +339,7 @@ func provideCleanup( opsScheduledReport *service.OpsScheduledReportService, opsSystemLogSink *service.OpsSystemLogSink, schedulerSnapshot *service.SchedulerSnapshotService, + groupRateSchedule *service.GroupRateScheduleService, tokenRefresh *service.TokenRefreshService, accountExpiry *service.AccountExpiryService, subscriptionExpiry *service.SubscriptionExpiryService, @@ -410,6 +413,12 @@ func provideCleanup( } return nil }}, + {"GroupRateScheduleService", func() error { + if groupRateSchedule != nil { + groupRateSchedule.Stop() + } + return nil + }}, {"UsageCleanupService", func() error { if usageCleanup != nil { usageCleanup.Stop() diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go index 8bafccc7e..dea3e797f 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -59,6 +59,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { &service.OpsScheduledReportService{}, opsSystemLogSinkSvc, schedulerSnapshotSvc, + nil, // groupRateSchedule tokenRefreshSvc, accountExpirySvc, subscriptionExpirySvc, diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 9133b74a9..effc939b6 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -1483,9 +1483,11 @@ var ( {Name: "product_name", Type: field.TypeString, Size: 150}, {Name: "product_cover_url", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}}, {Name: "product_description", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}}, + {Name: "product_type", Type: field.TypeString, Size: 30, Default: "card_key"}, {Name: "unit_price", Type: field.TypeFloat64, SchemaType: map[string]string{"postgres": "decimal(20,2)"}}, {Name: "quantity", Type: field.TypeInt}, {Name: "total_amount", Type: field.TypeFloat64, SchemaType: map[string]string{"postgres": "decimal(20,2)"}}, + {Name: "points_amount", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(20,2)"}}, {Name: "payment_method", Type: field.TypeString, Size: 30}, {Name: "payment_order_id", Type: field.TypeInt64, Nullable: true}, {Name: "status", Type: field.TypeString, Size: 30, Default: "pending"}, @@ -1508,19 +1510,19 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "shop_orders_shop_draw_cycles_orders", - Columns: []*schema.Column{ShopOrdersColumns[20]}, + Columns: []*schema.Column{ShopOrdersColumns[22]}, RefColumns: []*schema.Column{ShopDrawCyclesColumns[0]}, OnDelete: schema.SetNull, }, { Symbol: "shop_orders_shop_products_orders", - Columns: []*schema.Column{ShopOrdersColumns[21]}, + Columns: []*schema.Column{ShopOrdersColumns[23]}, RefColumns: []*schema.Column{ShopProductsColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "shop_orders_users_shop_orders", - Columns: []*schema.Column{ShopOrdersColumns[22]}, + Columns: []*schema.Column{ShopOrdersColumns[24]}, RefColumns: []*schema.Column{UsersColumns[0]}, OnDelete: schema.NoAction, }, @@ -1529,27 +1531,27 @@ var ( { Name: "shoporder_user_id", Unique: false, - Columns: []*schema.Column{ShopOrdersColumns[22]}, + Columns: []*schema.Column{ShopOrdersColumns[24]}, }, { Name: "shoporder_product_id", Unique: false, - Columns: []*schema.Column{ShopOrdersColumns[21]}, + Columns: []*schema.Column{ShopOrdersColumns[23]}, }, { Name: "shoporder_payment_order_id", Unique: true, - Columns: []*schema.Column{ShopOrdersColumns[11]}, + Columns: []*schema.Column{ShopOrdersColumns[13]}, }, { Name: "shoporder_draw_cycle_id", Unique: false, - Columns: []*schema.Column{ShopOrdersColumns[20]}, + Columns: []*schema.Column{ShopOrdersColumns[22]}, }, { Name: "shoporder_status", Unique: false, - Columns: []*schema.Column{ShopOrdersColumns[12]}, + Columns: []*schema.Column{ShopOrdersColumns[14]}, }, { Name: "shoporder_created_at", @@ -1575,6 +1577,9 @@ var ( {Name: "auto_delivery", Type: field.TypeBool, Default: true}, {Name: "product_type", Type: field.TypeString, Size: 30, Default: "card_key"}, {Name: "balance_only", Type: field.TypeBool, Default: false}, + {Name: "allow_balance_payment", Type: field.TypeBool, Default: true}, + {Name: "allow_points_payment", Type: field.TypeBool, Default: false}, + {Name: "allow_platform_payment", Type: field.TypeBool, Default: true}, {Name: "draw_enabled", Type: field.TypeBool, Default: false}, {Name: "draw_min_amount", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(20,2)"}}, {Name: "draw_max_amount", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(20,2)"}}, @@ -1590,7 +1595,7 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "shop_products_shop_categories_products", - Columns: []*schema.Column{ShopProductsColumns[20]}, + Columns: []*schema.Column{ShopProductsColumns[23]}, RefColumns: []*schema.Column{ShopCategoriesColumns[0]}, OnDelete: schema.SetNull, }, @@ -1599,7 +1604,7 @@ var ( { Name: "shopproduct_category_id", Unique: false, - Columns: []*schema.Column{ShopProductsColumns[20]}, + Columns: []*schema.Column{ShopProductsColumns[23]}, }, { Name: "shopproduct_enabled", @@ -1862,6 +1867,8 @@ var ( {Name: "password_hash", Type: field.TypeString, Size: 255}, {Name: "role", Type: field.TypeString, Size: 20, Default: "user"}, {Name: "balance", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, + {Name: "points_balance", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(20,10)"}}, + {Name: "prefer_points_billing", Type: field.TypeBool, Default: false}, {Name: "concurrency", Type: field.TypeInt, Default: 5}, {Name: "status", Type: field.TypeString, Size: 20, Default: "active"}, {Name: "username", Type: field.TypeString, Size: 100, Default: ""}, @@ -1888,7 +1895,7 @@ var ( { Name: "user_status", Unique: false, - Columns: []*schema.Column{UsersColumns[9]}, + Columns: []*schema.Column{UsersColumns[11]}, }, { Name: "user_deleted_at", diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 9ed952e1a..7cf451679 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -36727,12 +36727,15 @@ type ShopOrderMutation struct { product_name *string product_cover_url *string product_description *string + product_type *string unit_price *float64 addunit_price *float64 quantity *int addquantity *int total_amount *float64 addtotal_amount *float64 + points_amount *float64 + addpoints_amount *float64 payment_method *string payment_order_id *int64 addpayment_order_id *int64 @@ -37177,6 +37180,42 @@ func (m *ShopOrderMutation) ResetProductDescription() { delete(m.clearedFields, shoporder.FieldProductDescription) } +// SetProductType sets the "product_type" field. +func (m *ShopOrderMutation) SetProductType(s string) { + m.product_type = &s +} + +// ProductType returns the value of the "product_type" field in the mutation. +func (m *ShopOrderMutation) ProductType() (r string, exists bool) { + v := m.product_type + if v == nil { + return + } + return *v, true +} + +// OldProductType returns the old "product_type" field's value of the ShopOrder entity. +// If the ShopOrder 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 *ShopOrderMutation) OldProductType(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldProductType is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldProductType requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldProductType: %w", err) + } + return oldValue.ProductType, nil +} + +// ResetProductType resets all changes to the "product_type" field. +func (m *ShopOrderMutation) ResetProductType() { + m.product_type = nil +} + // SetUnitPrice sets the "unit_price" field. func (m *ShopOrderMutation) SetUnitPrice(f float64) { m.unit_price = &f @@ -37345,6 +37384,62 @@ func (m *ShopOrderMutation) ResetTotalAmount() { m.addtotal_amount = nil } +// SetPointsAmount sets the "points_amount" field. +func (m *ShopOrderMutation) SetPointsAmount(f float64) { + m.points_amount = &f + m.addpoints_amount = nil +} + +// PointsAmount returns the value of the "points_amount" field in the mutation. +func (m *ShopOrderMutation) PointsAmount() (r float64, exists bool) { + v := m.points_amount + if v == nil { + return + } + return *v, true +} + +// OldPointsAmount returns the old "points_amount" field's value of the ShopOrder entity. +// If the ShopOrder 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 *ShopOrderMutation) OldPointsAmount(ctx context.Context) (v float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldPointsAmount is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldPointsAmount requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldPointsAmount: %w", err) + } + return oldValue.PointsAmount, nil +} + +// AddPointsAmount adds f to the "points_amount" field. +func (m *ShopOrderMutation) AddPointsAmount(f float64) { + if m.addpoints_amount != nil { + *m.addpoints_amount += f + } else { + m.addpoints_amount = &f + } +} + +// AddedPointsAmount returns the value that was added to the "points_amount" field in this mutation. +func (m *ShopOrderMutation) AddedPointsAmount() (r float64, exists bool) { + v := m.addpoints_amount + if v == nil { + return + } + return *v, true +} + +// ResetPointsAmount resets all changes to the "points_amount" field. +func (m *ShopOrderMutation) ResetPointsAmount() { + m.points_amount = nil + m.addpoints_amount = nil +} + // SetPaymentMethod sets the "payment_method" field. func (m *ShopOrderMutation) SetPaymentMethod(s string) { m.payment_method = &s @@ -38160,7 +38255,7 @@ func (m *ShopOrderMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *ShopOrderMutation) Fields() []string { - fields := make([]string, 0, 22) + fields := make([]string, 0, 24) if m.created_at != nil { fields = append(fields, shoporder.FieldCreatedAt) } @@ -38185,6 +38280,9 @@ func (m *ShopOrderMutation) Fields() []string { if m.product_description != nil { fields = append(fields, shoporder.FieldProductDescription) } + if m.product_type != nil { + fields = append(fields, shoporder.FieldProductType) + } if m.unit_price != nil { fields = append(fields, shoporder.FieldUnitPrice) } @@ -38194,6 +38292,9 @@ func (m *ShopOrderMutation) Fields() []string { if m.total_amount != nil { fields = append(fields, shoporder.FieldTotalAmount) } + if m.points_amount != nil { + fields = append(fields, shoporder.FieldPointsAmount) + } if m.payment_method != nil { fields = append(fields, shoporder.FieldPaymentMethod) } @@ -38251,12 +38352,16 @@ func (m *ShopOrderMutation) Field(name string) (ent.Value, bool) { return m.ProductCoverURL() case shoporder.FieldProductDescription: return m.ProductDescription() + case shoporder.FieldProductType: + return m.ProductType() case shoporder.FieldUnitPrice: return m.UnitPrice() case shoporder.FieldQuantity: return m.Quantity() case shoporder.FieldTotalAmount: return m.TotalAmount() + case shoporder.FieldPointsAmount: + return m.PointsAmount() case shoporder.FieldPaymentMethod: return m.PaymentMethod() case shoporder.FieldPaymentOrderID: @@ -38304,12 +38409,16 @@ func (m *ShopOrderMutation) OldField(ctx context.Context, name string) (ent.Valu return m.OldProductCoverURL(ctx) case shoporder.FieldProductDescription: return m.OldProductDescription(ctx) + case shoporder.FieldProductType: + return m.OldProductType(ctx) case shoporder.FieldUnitPrice: return m.OldUnitPrice(ctx) case shoporder.FieldQuantity: return m.OldQuantity(ctx) case shoporder.FieldTotalAmount: return m.OldTotalAmount(ctx) + case shoporder.FieldPointsAmount: + return m.OldPointsAmount(ctx) case shoporder.FieldPaymentMethod: return m.OldPaymentMethod(ctx) case shoporder.FieldPaymentOrderID: @@ -38397,6 +38506,13 @@ func (m *ShopOrderMutation) SetField(name string, value ent.Value) error { } m.SetProductDescription(v) return nil + case shoporder.FieldProductType: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetProductType(v) + return nil case shoporder.FieldUnitPrice: v, ok := value.(float64) if !ok { @@ -38418,6 +38534,13 @@ func (m *ShopOrderMutation) SetField(name string, value ent.Value) error { } m.SetTotalAmount(v) return nil + case shoporder.FieldPointsAmount: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetPointsAmount(v) + return nil case shoporder.FieldPaymentMethod: v, ok := value.(string) if !ok { @@ -38512,6 +38635,9 @@ func (m *ShopOrderMutation) AddedFields() []string { if m.addtotal_amount != nil { fields = append(fields, shoporder.FieldTotalAmount) } + if m.addpoints_amount != nil { + fields = append(fields, shoporder.FieldPointsAmount) + } if m.addpayment_order_id != nil { fields = append(fields, shoporder.FieldPaymentOrderID) } @@ -38535,6 +38661,8 @@ func (m *ShopOrderMutation) AddedField(name string) (ent.Value, bool) { return m.AddedQuantity() case shoporder.FieldTotalAmount: return m.AddedTotalAmount() + case shoporder.FieldPointsAmount: + return m.AddedPointsAmount() case shoporder.FieldPaymentOrderID: return m.AddedPaymentOrderID() case shoporder.FieldDrawRewardAmount: @@ -38571,6 +38699,13 @@ func (m *ShopOrderMutation) AddField(name string, value ent.Value) error { } m.AddTotalAmount(v) return nil + case shoporder.FieldPointsAmount: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddPointsAmount(v) + return nil case shoporder.FieldPaymentOrderID: v, ok := value.(int64) if !ok { @@ -38712,6 +38847,9 @@ func (m *ShopOrderMutation) ResetField(name string) error { case shoporder.FieldProductDescription: m.ResetProductDescription() return nil + case shoporder.FieldProductType: + m.ResetProductType() + return nil case shoporder.FieldUnitPrice: m.ResetUnitPrice() return nil @@ -38721,6 +38859,9 @@ func (m *ShopOrderMutation) ResetField(name string) error { case shoporder.FieldTotalAmount: m.ResetTotalAmount() return nil + case shoporder.FieldPointsAmount: + m.ResetPointsAmount() + return nil case shoporder.FieldPaymentMethod: m.ResetPaymentMethod() return nil @@ -38947,6 +39088,9 @@ type ShopProductMutation struct { auto_delivery *bool product_type *string balance_only *bool + allow_balance_payment *bool + allow_points_payment *bool + allow_platform_payment *bool draw_enabled *bool draw_min_amount *float64 adddraw_min_amount *float64 @@ -39764,6 +39908,114 @@ func (m *ShopProductMutation) ResetBalanceOnly() { m.balance_only = nil } +// SetAllowBalancePayment sets the "allow_balance_payment" field. +func (m *ShopProductMutation) SetAllowBalancePayment(b bool) { + m.allow_balance_payment = &b +} + +// AllowBalancePayment returns the value of the "allow_balance_payment" field in the mutation. +func (m *ShopProductMutation) AllowBalancePayment() (r bool, exists bool) { + v := m.allow_balance_payment + if v == nil { + return + } + return *v, true +} + +// OldAllowBalancePayment returns the old "allow_balance_payment" field's value of the ShopProduct entity. +// If the ShopProduct 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 *ShopProductMutation) OldAllowBalancePayment(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldAllowBalancePayment is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldAllowBalancePayment requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldAllowBalancePayment: %w", err) + } + return oldValue.AllowBalancePayment, nil +} + +// ResetAllowBalancePayment resets all changes to the "allow_balance_payment" field. +func (m *ShopProductMutation) ResetAllowBalancePayment() { + m.allow_balance_payment = nil +} + +// SetAllowPointsPayment sets the "allow_points_payment" field. +func (m *ShopProductMutation) SetAllowPointsPayment(b bool) { + m.allow_points_payment = &b +} + +// AllowPointsPayment returns the value of the "allow_points_payment" field in the mutation. +func (m *ShopProductMutation) AllowPointsPayment() (r bool, exists bool) { + v := m.allow_points_payment + if v == nil { + return + } + return *v, true +} + +// OldAllowPointsPayment returns the old "allow_points_payment" field's value of the ShopProduct entity. +// If the ShopProduct 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 *ShopProductMutation) OldAllowPointsPayment(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldAllowPointsPayment is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldAllowPointsPayment requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldAllowPointsPayment: %w", err) + } + return oldValue.AllowPointsPayment, nil +} + +// ResetAllowPointsPayment resets all changes to the "allow_points_payment" field. +func (m *ShopProductMutation) ResetAllowPointsPayment() { + m.allow_points_payment = nil +} + +// SetAllowPlatformPayment sets the "allow_platform_payment" field. +func (m *ShopProductMutation) SetAllowPlatformPayment(b bool) { + m.allow_platform_payment = &b +} + +// AllowPlatformPayment returns the value of the "allow_platform_payment" field in the mutation. +func (m *ShopProductMutation) AllowPlatformPayment() (r bool, exists bool) { + v := m.allow_platform_payment + if v == nil { + return + } + return *v, true +} + +// OldAllowPlatformPayment returns the old "allow_platform_payment" field's value of the ShopProduct entity. +// If the ShopProduct 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 *ShopProductMutation) OldAllowPlatformPayment(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldAllowPlatformPayment is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldAllowPlatformPayment requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldAllowPlatformPayment: %w", err) + } + return oldValue.AllowPlatformPayment, nil +} + +// ResetAllowPlatformPayment resets all changes to the "allow_platform_payment" field. +func (m *ShopProductMutation) ResetAllowPlatformPayment() { + m.allow_platform_payment = nil +} + // SetDrawEnabled sets the "draw_enabled" field. func (m *ShopProductMutation) SetDrawEnabled(b bool) { m.draw_enabled = &b @@ -40247,7 +40499,7 @@ func (m *ShopProductMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *ShopProductMutation) Fields() []string { - fields := make([]string, 0, 20) + fields := make([]string, 0, 23) if m.created_at != nil { fields = append(fields, shopproduct.FieldCreatedAt) } @@ -40293,6 +40545,15 @@ func (m *ShopProductMutation) Fields() []string { if m.balance_only != nil { fields = append(fields, shopproduct.FieldBalanceOnly) } + if m.allow_balance_payment != nil { + fields = append(fields, shopproduct.FieldAllowBalancePayment) + } + if m.allow_points_payment != nil { + fields = append(fields, shopproduct.FieldAllowPointsPayment) + } + if m.allow_platform_payment != nil { + fields = append(fields, shopproduct.FieldAllowPlatformPayment) + } if m.draw_enabled != nil { fields = append(fields, shopproduct.FieldDrawEnabled) } @@ -40346,6 +40607,12 @@ func (m *ShopProductMutation) Field(name string) (ent.Value, bool) { return m.ProductType() case shopproduct.FieldBalanceOnly: return m.BalanceOnly() + case shopproduct.FieldAllowBalancePayment: + return m.AllowBalancePayment() + case shopproduct.FieldAllowPointsPayment: + return m.AllowPointsPayment() + case shopproduct.FieldAllowPlatformPayment: + return m.AllowPlatformPayment() case shopproduct.FieldDrawEnabled: return m.DrawEnabled() case shopproduct.FieldDrawMinAmount: @@ -40395,6 +40662,12 @@ func (m *ShopProductMutation) OldField(ctx context.Context, name string) (ent.Va return m.OldProductType(ctx) case shopproduct.FieldBalanceOnly: return m.OldBalanceOnly(ctx) + case shopproduct.FieldAllowBalancePayment: + return m.OldAllowBalancePayment(ctx) + case shopproduct.FieldAllowPointsPayment: + return m.OldAllowPointsPayment(ctx) + case shopproduct.FieldAllowPlatformPayment: + return m.OldAllowPlatformPayment(ctx) case shopproduct.FieldDrawEnabled: return m.OldDrawEnabled(ctx) case shopproduct.FieldDrawMinAmount: @@ -40519,6 +40792,27 @@ func (m *ShopProductMutation) SetField(name string, value ent.Value) error { } m.SetBalanceOnly(v) return nil + case shopproduct.FieldAllowBalancePayment: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetAllowBalancePayment(v) + return nil + case shopproduct.FieldAllowPointsPayment: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetAllowPointsPayment(v) + return nil + case shopproduct.FieldAllowPlatformPayment: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetAllowPlatformPayment(v) + return nil case shopproduct.FieldDrawEnabled: v, ok := value.(bool) if !ok { @@ -40786,6 +41080,15 @@ func (m *ShopProductMutation) ResetField(name string) error { case shopproduct.FieldBalanceOnly: m.ResetBalanceOnly() return nil + case shopproduct.FieldAllowBalancePayment: + m.ResetAllowBalancePayment() + return nil + case shopproduct.FieldAllowPointsPayment: + m.ResetAllowPointsPayment() + return nil + case shopproduct.FieldAllowPlatformPayment: + m.ResetAllowPlatformPayment() + return nil case shopproduct.FieldDrawEnabled: m.ResetDrawEnabled() return nil @@ -48080,6 +48383,9 @@ type UserMutation struct { role *string balance *float64 addbalance *float64 + points_balance *float64 + addpoints_balance *float64 + prefer_points_billing *bool concurrency *int addconcurrency *int status *string @@ -48537,6 +48843,98 @@ func (m *UserMutation) ResetBalance() { m.addbalance = nil } +// SetPointsBalance sets the "points_balance" field. +func (m *UserMutation) SetPointsBalance(f float64) { + m.points_balance = &f + m.addpoints_balance = nil +} + +// PointsBalance returns the value of the "points_balance" field in the mutation. +func (m *UserMutation) PointsBalance() (r float64, exists bool) { + v := m.points_balance + if v == nil { + return + } + return *v, true +} + +// OldPointsBalance returns the old "points_balance" field's value of the User entity. +// If the User 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 *UserMutation) OldPointsBalance(ctx context.Context) (v float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldPointsBalance is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldPointsBalance requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldPointsBalance: %w", err) + } + return oldValue.PointsBalance, nil +} + +// AddPointsBalance adds f to the "points_balance" field. +func (m *UserMutation) AddPointsBalance(f float64) { + if m.addpoints_balance != nil { + *m.addpoints_balance += f + } else { + m.addpoints_balance = &f + } +} + +// AddedPointsBalance returns the value that was added to the "points_balance" field in this mutation. +func (m *UserMutation) AddedPointsBalance() (r float64, exists bool) { + v := m.addpoints_balance + if v == nil { + return + } + return *v, true +} + +// ResetPointsBalance resets all changes to the "points_balance" field. +func (m *UserMutation) ResetPointsBalance() { + m.points_balance = nil + m.addpoints_balance = nil +} + +// SetPreferPointsBilling sets the "prefer_points_billing" field. +func (m *UserMutation) SetPreferPointsBilling(b bool) { + m.prefer_points_billing = &b +} + +// PreferPointsBilling returns the value of the "prefer_points_billing" field in the mutation. +func (m *UserMutation) PreferPointsBilling() (r bool, exists bool) { + v := m.prefer_points_billing + if v == nil { + return + } + return *v, true +} + +// OldPreferPointsBilling returns the old "prefer_points_billing" field's value of the User entity. +// If the User 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 *UserMutation) OldPreferPointsBilling(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldPreferPointsBilling is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldPreferPointsBilling requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldPreferPointsBilling: %w", err) + } + return oldValue.PreferPointsBilling, nil +} + +// ResetPreferPointsBilling resets all changes to the "prefer_points_billing" field. +func (m *UserMutation) ResetPreferPointsBilling() { + m.prefer_points_billing = nil +} + // SetConcurrency sets the "concurrency" field. func (m *UserMutation) SetConcurrency(i int) { m.concurrency = &i @@ -50157,7 +50555,7 @@ func (m *UserMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *UserMutation) Fields() []string { - fields := make([]string, 0, 23) + fields := make([]string, 0, 25) if m.created_at != nil { fields = append(fields, user.FieldCreatedAt) } @@ -50179,6 +50577,12 @@ func (m *UserMutation) Fields() []string { if m.balance != nil { fields = append(fields, user.FieldBalance) } + if m.points_balance != nil { + fields = append(fields, user.FieldPointsBalance) + } + if m.prefer_points_billing != nil { + fields = append(fields, user.FieldPreferPointsBilling) + } if m.concurrency != nil { fields = append(fields, user.FieldConcurrency) } @@ -50249,6 +50653,10 @@ func (m *UserMutation) Field(name string) (ent.Value, bool) { return m.Role() case user.FieldBalance: return m.Balance() + case user.FieldPointsBalance: + return m.PointsBalance() + case user.FieldPreferPointsBilling: + return m.PreferPointsBilling() case user.FieldConcurrency: return m.Concurrency() case user.FieldStatus: @@ -50304,6 +50712,10 @@ func (m *UserMutation) OldField(ctx context.Context, name string) (ent.Value, er return m.OldRole(ctx) case user.FieldBalance: return m.OldBalance(ctx) + case user.FieldPointsBalance: + return m.OldPointsBalance(ctx) + case user.FieldPreferPointsBilling: + return m.OldPreferPointsBilling(ctx) case user.FieldConcurrency: return m.OldConcurrency(ctx) case user.FieldStatus: @@ -50394,6 +50806,20 @@ func (m *UserMutation) SetField(name string, value ent.Value) error { } m.SetBalance(v) return nil + case user.FieldPointsBalance: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetPointsBalance(v) + return nil + case user.FieldPreferPointsBilling: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetPreferPointsBilling(v) + return nil case user.FieldConcurrency: v, ok := value.(int) if !ok { @@ -50517,6 +50943,9 @@ func (m *UserMutation) AddedFields() []string { if m.addbalance != nil { fields = append(fields, user.FieldBalance) } + if m.addpoints_balance != nil { + fields = append(fields, user.FieldPointsBalance) + } if m.addconcurrency != nil { fields = append(fields, user.FieldConcurrency) } @@ -50539,6 +50968,8 @@ func (m *UserMutation) AddedField(name string) (ent.Value, bool) { switch name { case user.FieldBalance: return m.AddedBalance() + case user.FieldPointsBalance: + return m.AddedPointsBalance() case user.FieldConcurrency: return m.AddedConcurrency() case user.FieldBalanceNotifyThreshold: @@ -50563,6 +50994,13 @@ func (m *UserMutation) AddField(name string, value ent.Value) error { } m.AddBalance(v) return nil + case user.FieldPointsBalance: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddPointsBalance(v) + return nil case user.FieldConcurrency: v, ok := value.(int) if !ok { @@ -50678,6 +51116,12 @@ func (m *UserMutation) ResetField(name string) error { case user.FieldBalance: m.ResetBalance() return nil + case user.FieldPointsBalance: + m.ResetPointsBalance() + return nil + case user.FieldPreferPointsBilling: + m.ResetPreferPointsBilling() + return nil case user.FieldConcurrency: m.ResetConcurrency() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 7f3b91ce4..b80d2afc6 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1695,12 +1695,22 @@ func init() { return nil } }() + // shoporderDescProductType is the schema descriptor for product_type field. + shoporderDescProductType := shoporderFields[6].Descriptor() + // shoporder.DefaultProductType holds the default value on creation for the product_type field. + shoporder.DefaultProductType = shoporderDescProductType.Default.(string) + // shoporder.ProductTypeValidator is a validator for the "product_type" field. It is called by the builders before save. + shoporder.ProductTypeValidator = shoporderDescProductType.Validators[0].(func(string) error) + // shoporderDescPointsAmount is the schema descriptor for points_amount field. + shoporderDescPointsAmount := shoporderFields[10].Descriptor() + // shoporder.DefaultPointsAmount holds the default value on creation for the points_amount field. + shoporder.DefaultPointsAmount = shoporderDescPointsAmount.Default.(float64) // shoporderDescPaymentMethod is the schema descriptor for payment_method field. - shoporderDescPaymentMethod := shoporderFields[9].Descriptor() + shoporderDescPaymentMethod := shoporderFields[11].Descriptor() // shoporder.PaymentMethodValidator is a validator for the "payment_method" field. It is called by the builders before save. shoporder.PaymentMethodValidator = shoporderDescPaymentMethod.Validators[0].(func(string) error) // shoporderDescStatus is the schema descriptor for status field. - shoporderDescStatus := shoporderFields[11].Descriptor() + shoporderDescStatus := shoporderFields[13].Descriptor() // shoporder.DefaultStatus holds the default value on creation for the status field. shoporder.DefaultStatus = shoporderDescStatus.Default.(string) // shoporder.StatusValidator is a validator for the "status" field. It is called by the builders before save. @@ -1772,24 +1782,36 @@ func init() { shopproductDescBalanceOnly := shopproductFields[12].Descriptor() // shopproduct.DefaultBalanceOnly holds the default value on creation for the balance_only field. shopproduct.DefaultBalanceOnly = shopproductDescBalanceOnly.Default.(bool) + // shopproductDescAllowBalancePayment is the schema descriptor for allow_balance_payment field. + shopproductDescAllowBalancePayment := shopproductFields[13].Descriptor() + // shopproduct.DefaultAllowBalancePayment holds the default value on creation for the allow_balance_payment field. + shopproduct.DefaultAllowBalancePayment = shopproductDescAllowBalancePayment.Default.(bool) + // shopproductDescAllowPointsPayment is the schema descriptor for allow_points_payment field. + shopproductDescAllowPointsPayment := shopproductFields[14].Descriptor() + // shopproduct.DefaultAllowPointsPayment holds the default value on creation for the allow_points_payment field. + shopproduct.DefaultAllowPointsPayment = shopproductDescAllowPointsPayment.Default.(bool) + // shopproductDescAllowPlatformPayment is the schema descriptor for allow_platform_payment field. + shopproductDescAllowPlatformPayment := shopproductFields[15].Descriptor() + // shopproduct.DefaultAllowPlatformPayment holds the default value on creation for the allow_platform_payment field. + shopproduct.DefaultAllowPlatformPayment = shopproductDescAllowPlatformPayment.Default.(bool) // shopproductDescDrawEnabled is the schema descriptor for draw_enabled field. - shopproductDescDrawEnabled := shopproductFields[13].Descriptor() + shopproductDescDrawEnabled := shopproductFields[16].Descriptor() // shopproduct.DefaultDrawEnabled holds the default value on creation for the draw_enabled field. shopproduct.DefaultDrawEnabled = shopproductDescDrawEnabled.Default.(bool) // shopproductDescDrawMinAmount is the schema descriptor for draw_min_amount field. - shopproductDescDrawMinAmount := shopproductFields[14].Descriptor() + shopproductDescDrawMinAmount := shopproductFields[17].Descriptor() // shopproduct.DefaultDrawMinAmount holds the default value on creation for the draw_min_amount field. shopproduct.DefaultDrawMinAmount = shopproductDescDrawMinAmount.Default.(float64) // shopproductDescDrawMaxAmount is the schema descriptor for draw_max_amount field. - shopproductDescDrawMaxAmount := shopproductFields[15].Descriptor() + shopproductDescDrawMaxAmount := shopproductFields[18].Descriptor() // shopproduct.DefaultDrawMaxAmount holds the default value on creation for the draw_max_amount field. shopproduct.DefaultDrawMaxAmount = shopproductDescDrawMaxAmount.Default.(float64) // shopproductDescDrawGuaranteeCount is the schema descriptor for draw_guarantee_count field. - shopproductDescDrawGuaranteeCount := shopproductFields[16].Descriptor() + shopproductDescDrawGuaranteeCount := shopproductFields[19].Descriptor() // shopproduct.DefaultDrawGuaranteeCount holds the default value on creation for the draw_guarantee_count field. shopproduct.DefaultDrawGuaranteeCount = shopproductDescDrawGuaranteeCount.Default.(int) // shopproductDescDrawReturnRate is the schema descriptor for draw_return_rate field. - shopproductDescDrawReturnRate := shopproductFields[17].Descriptor() + shopproductDescDrawReturnRate := shopproductFields[20].Descriptor() // shopproduct.DefaultDrawReturnRate holds the default value on creation for the draw_return_rate field. shopproduct.DefaultDrawReturnRate = shopproductDescDrawReturnRate.Default.(float64) subscriptionplanFields := schema.SubscriptionPlan{}.Fields() @@ -2141,54 +2163,62 @@ func init() { userDescBalance := userFields[3].Descriptor() // user.DefaultBalance holds the default value on creation for the balance field. user.DefaultBalance = userDescBalance.Default.(float64) + // userDescPointsBalance is the schema descriptor for points_balance field. + userDescPointsBalance := userFields[4].Descriptor() + // user.DefaultPointsBalance holds the default value on creation for the points_balance field. + user.DefaultPointsBalance = userDescPointsBalance.Default.(float64) + // userDescPreferPointsBilling is the schema descriptor for prefer_points_billing field. + userDescPreferPointsBilling := userFields[5].Descriptor() + // user.DefaultPreferPointsBilling holds the default value on creation for the prefer_points_billing field. + user.DefaultPreferPointsBilling = userDescPreferPointsBilling.Default.(bool) // userDescConcurrency is the schema descriptor for concurrency field. - userDescConcurrency := userFields[4].Descriptor() + userDescConcurrency := userFields[6].Descriptor() // user.DefaultConcurrency holds the default value on creation for the concurrency field. user.DefaultConcurrency = userDescConcurrency.Default.(int) // userDescStatus is the schema descriptor for status field. - userDescStatus := userFields[5].Descriptor() + userDescStatus := userFields[7].Descriptor() // user.DefaultStatus holds the default value on creation for the status field. user.DefaultStatus = userDescStatus.Default.(string) // user.StatusValidator is a validator for the "status" field. It is called by the builders before save. user.StatusValidator = userDescStatus.Validators[0].(func(string) error) // userDescUsername is the schema descriptor for username field. - userDescUsername := userFields[6].Descriptor() + userDescUsername := userFields[8].Descriptor() // user.DefaultUsername holds the default value on creation for the username field. user.DefaultUsername = userDescUsername.Default.(string) // user.UsernameValidator is a validator for the "username" field. It is called by the builders before save. user.UsernameValidator = userDescUsername.Validators[0].(func(string) error) // userDescNotes is the schema descriptor for notes field. - userDescNotes := userFields[7].Descriptor() + userDescNotes := userFields[9].Descriptor() // user.DefaultNotes holds the default value on creation for the notes field. user.DefaultNotes = userDescNotes.Default.(string) // userDescTotpEnabled is the schema descriptor for totp_enabled field. - userDescTotpEnabled := userFields[9].Descriptor() + userDescTotpEnabled := userFields[11].Descriptor() // user.DefaultTotpEnabled holds the default value on creation for the totp_enabled field. user.DefaultTotpEnabled = userDescTotpEnabled.Default.(bool) // userDescSignupSource is the schema descriptor for signup_source field. - userDescSignupSource := userFields[11].Descriptor() + userDescSignupSource := userFields[13].Descriptor() // user.DefaultSignupSource holds the default value on creation for the signup_source field. user.DefaultSignupSource = userDescSignupSource.Default.(string) // user.SignupSourceValidator is a validator for the "signup_source" field. It is called by the builders before save. user.SignupSourceValidator = userDescSignupSource.Validators[0].(func(string) error) // userDescBalanceNotifyEnabled is the schema descriptor for balance_notify_enabled field. - userDescBalanceNotifyEnabled := userFields[14].Descriptor() + userDescBalanceNotifyEnabled := userFields[16].Descriptor() // user.DefaultBalanceNotifyEnabled holds the default value on creation for the balance_notify_enabled field. user.DefaultBalanceNotifyEnabled = userDescBalanceNotifyEnabled.Default.(bool) // userDescBalanceNotifyThresholdType is the schema descriptor for balance_notify_threshold_type field. - userDescBalanceNotifyThresholdType := userFields[15].Descriptor() + userDescBalanceNotifyThresholdType := userFields[17].Descriptor() // user.DefaultBalanceNotifyThresholdType holds the default value on creation for the balance_notify_threshold_type field. user.DefaultBalanceNotifyThresholdType = userDescBalanceNotifyThresholdType.Default.(string) // userDescBalanceNotifyExtraEmails is the schema descriptor for balance_notify_extra_emails field. - userDescBalanceNotifyExtraEmails := userFields[17].Descriptor() + userDescBalanceNotifyExtraEmails := userFields[19].Descriptor() // user.DefaultBalanceNotifyExtraEmails holds the default value on creation for the balance_notify_extra_emails field. user.DefaultBalanceNotifyExtraEmails = userDescBalanceNotifyExtraEmails.Default.(string) // userDescTotalRecharged is the schema descriptor for total_recharged field. - userDescTotalRecharged := userFields[18].Descriptor() + userDescTotalRecharged := userFields[20].Descriptor() // user.DefaultTotalRecharged holds the default value on creation for the total_recharged field. user.DefaultTotalRecharged = userDescTotalRecharged.Default.(float64) // userDescRpmLimit is the schema descriptor for rpm_limit field. - userDescRpmLimit := userFields[19].Descriptor() + userDescRpmLimit := userFields[21].Descriptor() // user.DefaultRpmLimit holds the default value on creation for the rpm_limit field. user.DefaultRpmLimit = userDescRpmLimit.Default.(int) userallowedgroupFields := schema.UserAllowedGroup{}.Fields() diff --git a/backend/ent/schema/shop_order.go b/backend/ent/schema/shop_order.go index 7db6a0507..ba5908c7d 100644 --- a/backend/ent/schema/shop_order.go +++ b/backend/ent/schema/shop_order.go @@ -48,11 +48,17 @@ func (ShopOrder) Fields() []ent.Field { Optional(). Nillable(). SchemaType(map[string]string{dialect.Postgres: "text"}), + field.String("product_type"). + MaxLen(30). + Default("card_key"), field.Float("unit_price"). SchemaType(map[string]string{dialect.Postgres: "decimal(20,2)"}), field.Int("quantity"), field.Float("total_amount"). SchemaType(map[string]string{dialect.Postgres: "decimal(20,2)"}), + field.Float("points_amount"). + SchemaType(map[string]string{dialect.Postgres: "decimal(20,2)"}). + Default(0), field.String("payment_method"). MaxLen(30), field.Int64("payment_order_id"). diff --git a/backend/ent/schema/shop_product.go b/backend/ent/schema/shop_product.go index df942a85e..7dbf10dd9 100644 --- a/backend/ent/schema/shop_product.go +++ b/backend/ent/schema/shop_product.go @@ -67,6 +67,12 @@ func (ShopProduct) Fields() []ent.Field { Default("card_key"), field.Bool("balance_only"). Default(false), + field.Bool("allow_balance_payment"). + Default(true), + field.Bool("allow_points_payment"). + Default(false), + field.Bool("allow_platform_payment"). + Default(true), field.Bool("draw_enabled"). Default(false), field.Float("draw_min_amount"). diff --git a/backend/ent/schema/user.go b/backend/ent/schema/user.go index 7780db674..bd106028c 100644 --- a/backend/ent/schema/user.go +++ b/backend/ent/schema/user.go @@ -49,6 +49,11 @@ func (User) Fields() []ent.Field { field.Float("balance"). SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}). Default(0), + field.Float("points_balance"). + SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}). + Default(0), + field.Bool("prefer_points_billing"). + Default(false), field.Int("concurrency"). Default(5), field.String("status"). diff --git a/backend/ent/shoporder.go b/backend/ent/shoporder.go index 346a2e393..88396bdc4 100644 --- a/backend/ent/shoporder.go +++ b/backend/ent/shoporder.go @@ -37,12 +37,16 @@ type ShopOrder struct { ProductCoverURL *string `json:"product_cover_url,omitempty"` // ProductDescription holds the value of the "product_description" field. ProductDescription *string `json:"product_description,omitempty"` + // ProductType holds the value of the "product_type" field. + ProductType string `json:"product_type,omitempty"` // UnitPrice holds the value of the "unit_price" field. UnitPrice float64 `json:"unit_price,omitempty"` // Quantity holds the value of the "quantity" field. Quantity int `json:"quantity,omitempty"` // TotalAmount holds the value of the "total_amount" field. TotalAmount float64 `json:"total_amount,omitempty"` + // PointsAmount holds the value of the "points_amount" field. + PointsAmount float64 `json:"points_amount,omitempty"` // PaymentMethod holds the value of the "payment_method" field. PaymentMethod string `json:"payment_method,omitempty"` // PaymentOrderID holds the value of the "payment_order_id" field. @@ -146,11 +150,11 @@ func (*ShopOrder) scanValues(columns []string) ([]any, error) { switch columns[i] { case shoporder.FieldDeliveredCards: values[i] = new([]byte) - case shoporder.FieldUnitPrice, shoporder.FieldTotalAmount, shoporder.FieldDrawRewardAmount: + case shoporder.FieldUnitPrice, shoporder.FieldTotalAmount, shoporder.FieldPointsAmount, shoporder.FieldDrawRewardAmount: values[i] = new(sql.NullFloat64) case shoporder.FieldID, shoporder.FieldUserID, shoporder.FieldProductID, shoporder.FieldQuantity, shoporder.FieldPaymentOrderID, shoporder.FieldDrawCycleID, shoporder.FieldDrawCycleIndex: values[i] = new(sql.NullInt64) - case shoporder.FieldOrderNo, shoporder.FieldProductName, shoporder.FieldProductCoverURL, shoporder.FieldProductDescription, shoporder.FieldPaymentMethod, shoporder.FieldStatus, shoporder.FieldFailedReason: + case shoporder.FieldOrderNo, shoporder.FieldProductName, shoporder.FieldProductCoverURL, shoporder.FieldProductDescription, shoporder.FieldProductType, shoporder.FieldPaymentMethod, shoporder.FieldStatus, shoporder.FieldFailedReason: values[i] = new(sql.NullString) case shoporder.FieldCreatedAt, shoporder.FieldUpdatedAt, shoporder.FieldPaidAt, shoporder.FieldCompletedAt, shoporder.FieldCancelledAt: values[i] = new(sql.NullTime) @@ -225,6 +229,12 @@ func (_m *ShopOrder) assignValues(columns []string, values []any) error { _m.ProductDescription = new(string) *_m.ProductDescription = value.String } + case shoporder.FieldProductType: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field product_type", values[i]) + } else if value.Valid { + _m.ProductType = value.String + } case shoporder.FieldUnitPrice: if value, ok := values[i].(*sql.NullFloat64); !ok { return fmt.Errorf("unexpected type %T for field unit_price", values[i]) @@ -243,6 +253,12 @@ func (_m *ShopOrder) assignValues(columns []string, values []any) error { } else if value.Valid { _m.TotalAmount = value.Float64 } + case shoporder.FieldPointsAmount: + if value, ok := values[i].(*sql.NullFloat64); !ok { + return fmt.Errorf("unexpected type %T for field points_amount", values[i]) + } else if value.Valid { + _m.PointsAmount = value.Float64 + } case shoporder.FieldPaymentMethod: if value, ok := values[i].(*sql.NullString); !ok { return fmt.Errorf("unexpected type %T for field payment_method", values[i]) @@ -408,6 +424,9 @@ func (_m *ShopOrder) String() string { builder.WriteString(*v) } builder.WriteString(", ") + builder.WriteString("product_type=") + builder.WriteString(_m.ProductType) + builder.WriteString(", ") builder.WriteString("unit_price=") builder.WriteString(fmt.Sprintf("%v", _m.UnitPrice)) builder.WriteString(", ") @@ -417,6 +436,9 @@ func (_m *ShopOrder) String() string { builder.WriteString("total_amount=") builder.WriteString(fmt.Sprintf("%v", _m.TotalAmount)) builder.WriteString(", ") + builder.WriteString("points_amount=") + builder.WriteString(fmt.Sprintf("%v", _m.PointsAmount)) + builder.WriteString(", ") builder.WriteString("payment_method=") builder.WriteString(_m.PaymentMethod) builder.WriteString(", ") diff --git a/backend/ent/shoporder/shoporder.go b/backend/ent/shoporder/shoporder.go index 32f1ee3c1..8964a16dd 100644 --- a/backend/ent/shoporder/shoporder.go +++ b/backend/ent/shoporder/shoporder.go @@ -30,12 +30,16 @@ const ( FieldProductCoverURL = "product_cover_url" // FieldProductDescription holds the string denoting the product_description field in the database. FieldProductDescription = "product_description" + // FieldProductType holds the string denoting the product_type field in the database. + FieldProductType = "product_type" // FieldUnitPrice holds the string denoting the unit_price field in the database. FieldUnitPrice = "unit_price" // FieldQuantity holds the string denoting the quantity field in the database. FieldQuantity = "quantity" // FieldTotalAmount holds the string denoting the total_amount field in the database. FieldTotalAmount = "total_amount" + // FieldPointsAmount holds the string denoting the points_amount field in the database. + FieldPointsAmount = "points_amount" // FieldPaymentMethod holds the string denoting the payment_method field in the database. FieldPaymentMethod = "payment_method" // FieldPaymentOrderID holds the string denoting the payment_order_id field in the database. @@ -118,9 +122,11 @@ var Columns = []string{ FieldProductName, FieldProductCoverURL, FieldProductDescription, + FieldProductType, FieldUnitPrice, FieldQuantity, FieldTotalAmount, + FieldPointsAmount, FieldPaymentMethod, FieldPaymentOrderID, FieldStatus, @@ -155,6 +161,12 @@ var ( OrderNoValidator func(string) error // ProductNameValidator is a validator for the "product_name" field. It is called by the builders before save. ProductNameValidator func(string) error + // DefaultProductType holds the default value on creation for the "product_type" field. + DefaultProductType string + // ProductTypeValidator is a validator for the "product_type" field. It is called by the builders before save. + ProductTypeValidator func(string) error + // DefaultPointsAmount holds the default value on creation for the "points_amount" field. + DefaultPointsAmount float64 // PaymentMethodValidator is a validator for the "payment_method" field. It is called by the builders before save. PaymentMethodValidator func(string) error // DefaultStatus holds the default value on creation for the "status" field. @@ -211,6 +223,11 @@ func ByProductDescription(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldProductDescription, opts...).ToFunc() } +// ByProductType orders the results by the product_type field. +func ByProductType(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldProductType, opts...).ToFunc() +} + // ByUnitPrice orders the results by the unit_price field. func ByUnitPrice(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldUnitPrice, opts...).ToFunc() @@ -226,6 +243,11 @@ func ByTotalAmount(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldTotalAmount, opts...).ToFunc() } +// ByPointsAmount orders the results by the points_amount field. +func ByPointsAmount(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldPointsAmount, opts...).ToFunc() +} + // ByPaymentMethod orders the results by the payment_method field. func ByPaymentMethod(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldPaymentMethod, opts...).ToFunc() diff --git a/backend/ent/shoporder/where.go b/backend/ent/shoporder/where.go index fda860b38..f47ec1dfa 100644 --- a/backend/ent/shoporder/where.go +++ b/backend/ent/shoporder/where.go @@ -95,6 +95,11 @@ func ProductDescription(v string) predicate.ShopOrder { return predicate.ShopOrder(sql.FieldEQ(FieldProductDescription, v)) } +// ProductType applies equality check predicate on the "product_type" field. It's identical to ProductTypeEQ. +func ProductType(v string) predicate.ShopOrder { + return predicate.ShopOrder(sql.FieldEQ(FieldProductType, v)) +} + // UnitPrice applies equality check predicate on the "unit_price" field. It's identical to UnitPriceEQ. func UnitPrice(v float64) predicate.ShopOrder { return predicate.ShopOrder(sql.FieldEQ(FieldUnitPrice, v)) @@ -110,6 +115,11 @@ func TotalAmount(v float64) predicate.ShopOrder { return predicate.ShopOrder(sql.FieldEQ(FieldTotalAmount, v)) } +// PointsAmount applies equality check predicate on the "points_amount" field. It's identical to PointsAmountEQ. +func PointsAmount(v float64) predicate.ShopOrder { + return predicate.ShopOrder(sql.FieldEQ(FieldPointsAmount, v)) +} + // PaymentMethod applies equality check predicate on the "payment_method" field. It's identical to PaymentMethodEQ. func PaymentMethod(v string) predicate.ShopOrder { return predicate.ShopOrder(sql.FieldEQ(FieldPaymentMethod, v)) @@ -560,6 +570,71 @@ func ProductDescriptionContainsFold(v string) predicate.ShopOrder { return predicate.ShopOrder(sql.FieldContainsFold(FieldProductDescription, v)) } +// ProductTypeEQ applies the EQ predicate on the "product_type" field. +func ProductTypeEQ(v string) predicate.ShopOrder { + return predicate.ShopOrder(sql.FieldEQ(FieldProductType, v)) +} + +// ProductTypeNEQ applies the NEQ predicate on the "product_type" field. +func ProductTypeNEQ(v string) predicate.ShopOrder { + return predicate.ShopOrder(sql.FieldNEQ(FieldProductType, v)) +} + +// ProductTypeIn applies the In predicate on the "product_type" field. +func ProductTypeIn(vs ...string) predicate.ShopOrder { + return predicate.ShopOrder(sql.FieldIn(FieldProductType, vs...)) +} + +// ProductTypeNotIn applies the NotIn predicate on the "product_type" field. +func ProductTypeNotIn(vs ...string) predicate.ShopOrder { + return predicate.ShopOrder(sql.FieldNotIn(FieldProductType, vs...)) +} + +// ProductTypeGT applies the GT predicate on the "product_type" field. +func ProductTypeGT(v string) predicate.ShopOrder { + return predicate.ShopOrder(sql.FieldGT(FieldProductType, v)) +} + +// ProductTypeGTE applies the GTE predicate on the "product_type" field. +func ProductTypeGTE(v string) predicate.ShopOrder { + return predicate.ShopOrder(sql.FieldGTE(FieldProductType, v)) +} + +// ProductTypeLT applies the LT predicate on the "product_type" field. +func ProductTypeLT(v string) predicate.ShopOrder { + return predicate.ShopOrder(sql.FieldLT(FieldProductType, v)) +} + +// ProductTypeLTE applies the LTE predicate on the "product_type" field. +func ProductTypeLTE(v string) predicate.ShopOrder { + return predicate.ShopOrder(sql.FieldLTE(FieldProductType, v)) +} + +// ProductTypeContains applies the Contains predicate on the "product_type" field. +func ProductTypeContains(v string) predicate.ShopOrder { + return predicate.ShopOrder(sql.FieldContains(FieldProductType, v)) +} + +// ProductTypeHasPrefix applies the HasPrefix predicate on the "product_type" field. +func ProductTypeHasPrefix(v string) predicate.ShopOrder { + return predicate.ShopOrder(sql.FieldHasPrefix(FieldProductType, v)) +} + +// ProductTypeHasSuffix applies the HasSuffix predicate on the "product_type" field. +func ProductTypeHasSuffix(v string) predicate.ShopOrder { + return predicate.ShopOrder(sql.FieldHasSuffix(FieldProductType, v)) +} + +// ProductTypeEqualFold applies the EqualFold predicate on the "product_type" field. +func ProductTypeEqualFold(v string) predicate.ShopOrder { + return predicate.ShopOrder(sql.FieldEqualFold(FieldProductType, v)) +} + +// ProductTypeContainsFold applies the ContainsFold predicate on the "product_type" field. +func ProductTypeContainsFold(v string) predicate.ShopOrder { + return predicate.ShopOrder(sql.FieldContainsFold(FieldProductType, v)) +} + // UnitPriceEQ applies the EQ predicate on the "unit_price" field. func UnitPriceEQ(v float64) predicate.ShopOrder { return predicate.ShopOrder(sql.FieldEQ(FieldUnitPrice, v)) @@ -680,6 +755,46 @@ func TotalAmountLTE(v float64) predicate.ShopOrder { return predicate.ShopOrder(sql.FieldLTE(FieldTotalAmount, v)) } +// PointsAmountEQ applies the EQ predicate on the "points_amount" field. +func PointsAmountEQ(v float64) predicate.ShopOrder { + return predicate.ShopOrder(sql.FieldEQ(FieldPointsAmount, v)) +} + +// PointsAmountNEQ applies the NEQ predicate on the "points_amount" field. +func PointsAmountNEQ(v float64) predicate.ShopOrder { + return predicate.ShopOrder(sql.FieldNEQ(FieldPointsAmount, v)) +} + +// PointsAmountIn applies the In predicate on the "points_amount" field. +func PointsAmountIn(vs ...float64) predicate.ShopOrder { + return predicate.ShopOrder(sql.FieldIn(FieldPointsAmount, vs...)) +} + +// PointsAmountNotIn applies the NotIn predicate on the "points_amount" field. +func PointsAmountNotIn(vs ...float64) predicate.ShopOrder { + return predicate.ShopOrder(sql.FieldNotIn(FieldPointsAmount, vs...)) +} + +// PointsAmountGT applies the GT predicate on the "points_amount" field. +func PointsAmountGT(v float64) predicate.ShopOrder { + return predicate.ShopOrder(sql.FieldGT(FieldPointsAmount, v)) +} + +// PointsAmountGTE applies the GTE predicate on the "points_amount" field. +func PointsAmountGTE(v float64) predicate.ShopOrder { + return predicate.ShopOrder(sql.FieldGTE(FieldPointsAmount, v)) +} + +// PointsAmountLT applies the LT predicate on the "points_amount" field. +func PointsAmountLT(v float64) predicate.ShopOrder { + return predicate.ShopOrder(sql.FieldLT(FieldPointsAmount, v)) +} + +// PointsAmountLTE applies the LTE predicate on the "points_amount" field. +func PointsAmountLTE(v float64) predicate.ShopOrder { + return predicate.ShopOrder(sql.FieldLTE(FieldPointsAmount, v)) +} + // PaymentMethodEQ applies the EQ predicate on the "payment_method" field. func PaymentMethodEQ(v string) predicate.ShopOrder { return predicate.ShopOrder(sql.FieldEQ(FieldPaymentMethod, v)) diff --git a/backend/ent/shoporder_create.go b/backend/ent/shoporder_create.go index cc51edb19..87d8c9d98 100644 --- a/backend/ent/shoporder_create.go +++ b/backend/ent/shoporder_create.go @@ -107,6 +107,20 @@ func (_c *ShopOrderCreate) SetNillableProductDescription(v *string) *ShopOrderCr return _c } +// SetProductType sets the "product_type" field. +func (_c *ShopOrderCreate) SetProductType(v string) *ShopOrderCreate { + _c.mutation.SetProductType(v) + return _c +} + +// SetNillableProductType sets the "product_type" field if the given value is not nil. +func (_c *ShopOrderCreate) SetNillableProductType(v *string) *ShopOrderCreate { + if v != nil { + _c.SetProductType(*v) + } + return _c +} + // SetUnitPrice sets the "unit_price" field. func (_c *ShopOrderCreate) SetUnitPrice(v float64) *ShopOrderCreate { _c.mutation.SetUnitPrice(v) @@ -125,6 +139,20 @@ func (_c *ShopOrderCreate) SetTotalAmount(v float64) *ShopOrderCreate { return _c } +// SetPointsAmount sets the "points_amount" field. +func (_c *ShopOrderCreate) SetPointsAmount(v float64) *ShopOrderCreate { + _c.mutation.SetPointsAmount(v) + return _c +} + +// SetNillablePointsAmount sets the "points_amount" field if the given value is not nil. +func (_c *ShopOrderCreate) SetNillablePointsAmount(v *float64) *ShopOrderCreate { + if v != nil { + _c.SetPointsAmount(*v) + } + return _c +} + // SetPaymentMethod sets the "payment_method" field. func (_c *ShopOrderCreate) SetPaymentMethod(v string) *ShopOrderCreate { _c.mutation.SetPaymentMethod(v) @@ -351,6 +379,14 @@ func (_c *ShopOrderCreate) defaults() { v := shoporder.DefaultUpdatedAt() _c.mutation.SetUpdatedAt(v) } + if _, ok := _c.mutation.ProductType(); !ok { + v := shoporder.DefaultProductType + _c.mutation.SetProductType(v) + } + if _, ok := _c.mutation.PointsAmount(); !ok { + v := shoporder.DefaultPointsAmount + _c.mutation.SetPointsAmount(v) + } if _, ok := _c.mutation.Status(); !ok { v := shoporder.DefaultStatus _c.mutation.SetStatus(v) @@ -387,6 +423,14 @@ func (_c *ShopOrderCreate) check() error { return &ValidationError{Name: "product_name", err: fmt.Errorf(`ent: validator failed for field "ShopOrder.product_name": %w`, err)} } } + if _, ok := _c.mutation.ProductType(); !ok { + return &ValidationError{Name: "product_type", err: errors.New(`ent: missing required field "ShopOrder.product_type"`)} + } + if v, ok := _c.mutation.ProductType(); ok { + if err := shoporder.ProductTypeValidator(v); err != nil { + return &ValidationError{Name: "product_type", err: fmt.Errorf(`ent: validator failed for field "ShopOrder.product_type": %w`, err)} + } + } if _, ok := _c.mutation.UnitPrice(); !ok { return &ValidationError{Name: "unit_price", err: errors.New(`ent: missing required field "ShopOrder.unit_price"`)} } @@ -396,6 +440,9 @@ func (_c *ShopOrderCreate) check() error { if _, ok := _c.mutation.TotalAmount(); !ok { return &ValidationError{Name: "total_amount", err: errors.New(`ent: missing required field "ShopOrder.total_amount"`)} } + if _, ok := _c.mutation.PointsAmount(); !ok { + return &ValidationError{Name: "points_amount", err: errors.New(`ent: missing required field "ShopOrder.points_amount"`)} + } if _, ok := _c.mutation.PaymentMethod(); !ok { return &ValidationError{Name: "payment_method", err: errors.New(`ent: missing required field "ShopOrder.payment_method"`)} } @@ -469,6 +516,10 @@ func (_c *ShopOrderCreate) createSpec() (*ShopOrder, *sqlgraph.CreateSpec) { _spec.SetField(shoporder.FieldProductDescription, field.TypeString, value) _node.ProductDescription = &value } + if value, ok := _c.mutation.ProductType(); ok { + _spec.SetField(shoporder.FieldProductType, field.TypeString, value) + _node.ProductType = value + } if value, ok := _c.mutation.UnitPrice(); ok { _spec.SetField(shoporder.FieldUnitPrice, field.TypeFloat64, value) _node.UnitPrice = value @@ -481,6 +532,10 @@ func (_c *ShopOrderCreate) createSpec() (*ShopOrder, *sqlgraph.CreateSpec) { _spec.SetField(shoporder.FieldTotalAmount, field.TypeFloat64, value) _node.TotalAmount = value } + if value, ok := _c.mutation.PointsAmount(); ok { + _spec.SetField(shoporder.FieldPointsAmount, field.TypeFloat64, value) + _node.PointsAmount = value + } if value, ok := _c.mutation.PaymentMethod(); ok { _spec.SetField(shoporder.FieldPaymentMethod, field.TypeString, value) _node.PaymentMethod = value @@ -752,6 +807,18 @@ func (u *ShopOrderUpsert) ClearProductDescription() *ShopOrderUpsert { return u } +// SetProductType sets the "product_type" field. +func (u *ShopOrderUpsert) SetProductType(v string) *ShopOrderUpsert { + u.Set(shoporder.FieldProductType, v) + return u +} + +// UpdateProductType sets the "product_type" field to the value that was provided on create. +func (u *ShopOrderUpsert) UpdateProductType() *ShopOrderUpsert { + u.SetExcluded(shoporder.FieldProductType) + return u +} + // SetUnitPrice sets the "unit_price" field. func (u *ShopOrderUpsert) SetUnitPrice(v float64) *ShopOrderUpsert { u.Set(shoporder.FieldUnitPrice, v) @@ -806,6 +873,24 @@ func (u *ShopOrderUpsert) AddTotalAmount(v float64) *ShopOrderUpsert { return u } +// SetPointsAmount sets the "points_amount" field. +func (u *ShopOrderUpsert) SetPointsAmount(v float64) *ShopOrderUpsert { + u.Set(shoporder.FieldPointsAmount, v) + return u +} + +// UpdatePointsAmount sets the "points_amount" field to the value that was provided on create. +func (u *ShopOrderUpsert) UpdatePointsAmount() *ShopOrderUpsert { + u.SetExcluded(shoporder.FieldPointsAmount) + return u +} + +// AddPointsAmount adds v to the "points_amount" field. +func (u *ShopOrderUpsert) AddPointsAmount(v float64) *ShopOrderUpsert { + u.Add(shoporder.FieldPointsAmount, v) + return u +} + // SetPaymentMethod sets the "payment_method" field. func (u *ShopOrderUpsert) SetPaymentMethod(v string) *ShopOrderUpsert { u.Set(shoporder.FieldPaymentMethod, v) @@ -1167,6 +1252,20 @@ func (u *ShopOrderUpsertOne) ClearProductDescription() *ShopOrderUpsertOne { }) } +// SetProductType sets the "product_type" field. +func (u *ShopOrderUpsertOne) SetProductType(v string) *ShopOrderUpsertOne { + return u.Update(func(s *ShopOrderUpsert) { + s.SetProductType(v) + }) +} + +// UpdateProductType sets the "product_type" field to the value that was provided on create. +func (u *ShopOrderUpsertOne) UpdateProductType() *ShopOrderUpsertOne { + return u.Update(func(s *ShopOrderUpsert) { + s.UpdateProductType() + }) +} + // SetUnitPrice sets the "unit_price" field. func (u *ShopOrderUpsertOne) SetUnitPrice(v float64) *ShopOrderUpsertOne { return u.Update(func(s *ShopOrderUpsert) { @@ -1230,6 +1329,27 @@ func (u *ShopOrderUpsertOne) UpdateTotalAmount() *ShopOrderUpsertOne { }) } +// SetPointsAmount sets the "points_amount" field. +func (u *ShopOrderUpsertOne) SetPointsAmount(v float64) *ShopOrderUpsertOne { + return u.Update(func(s *ShopOrderUpsert) { + s.SetPointsAmount(v) + }) +} + +// AddPointsAmount adds v to the "points_amount" field. +func (u *ShopOrderUpsertOne) AddPointsAmount(v float64) *ShopOrderUpsertOne { + return u.Update(func(s *ShopOrderUpsert) { + s.AddPointsAmount(v) + }) +} + +// UpdatePointsAmount sets the "points_amount" field to the value that was provided on create. +func (u *ShopOrderUpsertOne) UpdatePointsAmount() *ShopOrderUpsertOne { + return u.Update(func(s *ShopOrderUpsert) { + s.UpdatePointsAmount() + }) +} + // SetPaymentMethod sets the "payment_method" field. func (u *ShopOrderUpsertOne) SetPaymentMethod(v string) *ShopOrderUpsertOne { return u.Update(func(s *ShopOrderUpsert) { @@ -1791,6 +1911,20 @@ func (u *ShopOrderUpsertBulk) ClearProductDescription() *ShopOrderUpsertBulk { }) } +// SetProductType sets the "product_type" field. +func (u *ShopOrderUpsertBulk) SetProductType(v string) *ShopOrderUpsertBulk { + return u.Update(func(s *ShopOrderUpsert) { + s.SetProductType(v) + }) +} + +// UpdateProductType sets the "product_type" field to the value that was provided on create. +func (u *ShopOrderUpsertBulk) UpdateProductType() *ShopOrderUpsertBulk { + return u.Update(func(s *ShopOrderUpsert) { + s.UpdateProductType() + }) +} + // SetUnitPrice sets the "unit_price" field. func (u *ShopOrderUpsertBulk) SetUnitPrice(v float64) *ShopOrderUpsertBulk { return u.Update(func(s *ShopOrderUpsert) { @@ -1854,6 +1988,27 @@ func (u *ShopOrderUpsertBulk) UpdateTotalAmount() *ShopOrderUpsertBulk { }) } +// SetPointsAmount sets the "points_amount" field. +func (u *ShopOrderUpsertBulk) SetPointsAmount(v float64) *ShopOrderUpsertBulk { + return u.Update(func(s *ShopOrderUpsert) { + s.SetPointsAmount(v) + }) +} + +// AddPointsAmount adds v to the "points_amount" field. +func (u *ShopOrderUpsertBulk) AddPointsAmount(v float64) *ShopOrderUpsertBulk { + return u.Update(func(s *ShopOrderUpsert) { + s.AddPointsAmount(v) + }) +} + +// UpdatePointsAmount sets the "points_amount" field to the value that was provided on create. +func (u *ShopOrderUpsertBulk) UpdatePointsAmount() *ShopOrderUpsertBulk { + return u.Update(func(s *ShopOrderUpsert) { + s.UpdatePointsAmount() + }) +} + // SetPaymentMethod sets the "payment_method" field. func (u *ShopOrderUpsertBulk) SetPaymentMethod(v string) *ShopOrderUpsertBulk { return u.Update(func(s *ShopOrderUpsert) { diff --git a/backend/ent/shoporder_update.go b/backend/ent/shoporder_update.go index 4fd29da8b..05d773f8a 100644 --- a/backend/ent/shoporder_update.go +++ b/backend/ent/shoporder_update.go @@ -136,6 +136,20 @@ func (_u *ShopOrderUpdate) ClearProductDescription() *ShopOrderUpdate { return _u } +// SetProductType sets the "product_type" field. +func (_u *ShopOrderUpdate) SetProductType(v string) *ShopOrderUpdate { + _u.mutation.SetProductType(v) + return _u +} + +// SetNillableProductType sets the "product_type" field if the given value is not nil. +func (_u *ShopOrderUpdate) SetNillableProductType(v *string) *ShopOrderUpdate { + if v != nil { + _u.SetProductType(*v) + } + return _u +} + // SetUnitPrice sets the "unit_price" field. func (_u *ShopOrderUpdate) SetUnitPrice(v float64) *ShopOrderUpdate { _u.mutation.ResetUnitPrice() @@ -199,6 +213,27 @@ func (_u *ShopOrderUpdate) AddTotalAmount(v float64) *ShopOrderUpdate { return _u } +// SetPointsAmount sets the "points_amount" field. +func (_u *ShopOrderUpdate) SetPointsAmount(v float64) *ShopOrderUpdate { + _u.mutation.ResetPointsAmount() + _u.mutation.SetPointsAmount(v) + return _u +} + +// SetNillablePointsAmount sets the "points_amount" field if the given value is not nil. +func (_u *ShopOrderUpdate) SetNillablePointsAmount(v *float64) *ShopOrderUpdate { + if v != nil { + _u.SetPointsAmount(*v) + } + return _u +} + +// AddPointsAmount adds value to the "points_amount" field. +func (_u *ShopOrderUpdate) AddPointsAmount(v float64) *ShopOrderUpdate { + _u.mutation.AddPointsAmount(v) + return _u +} + // SetPaymentMethod sets the "payment_method" field. func (_u *ShopOrderUpdate) SetPaymentMethod(v string) *ShopOrderUpdate { _u.mutation.SetPaymentMethod(v) @@ -584,6 +619,11 @@ func (_u *ShopOrderUpdate) check() error { return &ValidationError{Name: "product_name", err: fmt.Errorf(`ent: validator failed for field "ShopOrder.product_name": %w`, err)} } } + if v, ok := _u.mutation.ProductType(); ok { + if err := shoporder.ProductTypeValidator(v); err != nil { + return &ValidationError{Name: "product_type", err: fmt.Errorf(`ent: validator failed for field "ShopOrder.product_type": %w`, err)} + } + } if v, ok := _u.mutation.PaymentMethod(); ok { if err := shoporder.PaymentMethodValidator(v); err != nil { return &ValidationError{Name: "payment_method", err: fmt.Errorf(`ent: validator failed for field "ShopOrder.payment_method": %w`, err)} @@ -636,6 +676,9 @@ func (_u *ShopOrderUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.ProductDescriptionCleared() { _spec.ClearField(shoporder.FieldProductDescription, field.TypeString) } + if value, ok := _u.mutation.ProductType(); ok { + _spec.SetField(shoporder.FieldProductType, field.TypeString, value) + } if value, ok := _u.mutation.UnitPrice(); ok { _spec.SetField(shoporder.FieldUnitPrice, field.TypeFloat64, value) } @@ -654,6 +697,12 @@ func (_u *ShopOrderUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.AddedTotalAmount(); ok { _spec.AddField(shoporder.FieldTotalAmount, field.TypeFloat64, value) } + if value, ok := _u.mutation.PointsAmount(); ok { + _spec.SetField(shoporder.FieldPointsAmount, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedPointsAmount(); ok { + _spec.AddField(shoporder.FieldPointsAmount, field.TypeFloat64, value) + } if value, ok := _u.mutation.PaymentMethod(); ok { _spec.SetField(shoporder.FieldPaymentMethod, field.TypeString, value) } @@ -1021,6 +1070,20 @@ func (_u *ShopOrderUpdateOne) ClearProductDescription() *ShopOrderUpdateOne { return _u } +// SetProductType sets the "product_type" field. +func (_u *ShopOrderUpdateOne) SetProductType(v string) *ShopOrderUpdateOne { + _u.mutation.SetProductType(v) + return _u +} + +// SetNillableProductType sets the "product_type" field if the given value is not nil. +func (_u *ShopOrderUpdateOne) SetNillableProductType(v *string) *ShopOrderUpdateOne { + if v != nil { + _u.SetProductType(*v) + } + return _u +} + // SetUnitPrice sets the "unit_price" field. func (_u *ShopOrderUpdateOne) SetUnitPrice(v float64) *ShopOrderUpdateOne { _u.mutation.ResetUnitPrice() @@ -1084,6 +1147,27 @@ func (_u *ShopOrderUpdateOne) AddTotalAmount(v float64) *ShopOrderUpdateOne { return _u } +// SetPointsAmount sets the "points_amount" field. +func (_u *ShopOrderUpdateOne) SetPointsAmount(v float64) *ShopOrderUpdateOne { + _u.mutation.ResetPointsAmount() + _u.mutation.SetPointsAmount(v) + return _u +} + +// SetNillablePointsAmount sets the "points_amount" field if the given value is not nil. +func (_u *ShopOrderUpdateOne) SetNillablePointsAmount(v *float64) *ShopOrderUpdateOne { + if v != nil { + _u.SetPointsAmount(*v) + } + return _u +} + +// AddPointsAmount adds value to the "points_amount" field. +func (_u *ShopOrderUpdateOne) AddPointsAmount(v float64) *ShopOrderUpdateOne { + _u.mutation.AddPointsAmount(v) + return _u +} + // SetPaymentMethod sets the "payment_method" field. func (_u *ShopOrderUpdateOne) SetPaymentMethod(v string) *ShopOrderUpdateOne { _u.mutation.SetPaymentMethod(v) @@ -1482,6 +1566,11 @@ func (_u *ShopOrderUpdateOne) check() error { return &ValidationError{Name: "product_name", err: fmt.Errorf(`ent: validator failed for field "ShopOrder.product_name": %w`, err)} } } + if v, ok := _u.mutation.ProductType(); ok { + if err := shoporder.ProductTypeValidator(v); err != nil { + return &ValidationError{Name: "product_type", err: fmt.Errorf(`ent: validator failed for field "ShopOrder.product_type": %w`, err)} + } + } if v, ok := _u.mutation.PaymentMethod(); ok { if err := shoporder.PaymentMethodValidator(v); err != nil { return &ValidationError{Name: "payment_method", err: fmt.Errorf(`ent: validator failed for field "ShopOrder.payment_method": %w`, err)} @@ -1551,6 +1640,9 @@ func (_u *ShopOrderUpdateOne) sqlSave(ctx context.Context) (_node *ShopOrder, er if _u.mutation.ProductDescriptionCleared() { _spec.ClearField(shoporder.FieldProductDescription, field.TypeString) } + if value, ok := _u.mutation.ProductType(); ok { + _spec.SetField(shoporder.FieldProductType, field.TypeString, value) + } if value, ok := _u.mutation.UnitPrice(); ok { _spec.SetField(shoporder.FieldUnitPrice, field.TypeFloat64, value) } @@ -1569,6 +1661,12 @@ func (_u *ShopOrderUpdateOne) sqlSave(ctx context.Context) (_node *ShopOrder, er if value, ok := _u.mutation.AddedTotalAmount(); ok { _spec.AddField(shoporder.FieldTotalAmount, field.TypeFloat64, value) } + if value, ok := _u.mutation.PointsAmount(); ok { + _spec.SetField(shoporder.FieldPointsAmount, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedPointsAmount(); ok { + _spec.AddField(shoporder.FieldPointsAmount, field.TypeFloat64, value) + } if value, ok := _u.mutation.PaymentMethod(); ok { _spec.SetField(shoporder.FieldPaymentMethod, field.TypeString, value) } diff --git a/backend/ent/shopproduct.go b/backend/ent/shopproduct.go index 854ca2ea6..962068cfb 100644 --- a/backend/ent/shopproduct.go +++ b/backend/ent/shopproduct.go @@ -48,6 +48,12 @@ type ShopProduct struct { ProductType string `json:"product_type,omitempty"` // BalanceOnly holds the value of the "balance_only" field. BalanceOnly bool `json:"balance_only,omitempty"` + // AllowBalancePayment holds the value of the "allow_balance_payment" field. + AllowBalancePayment bool `json:"allow_balance_payment,omitempty"` + // AllowPointsPayment holds the value of the "allow_points_payment" field. + AllowPointsPayment bool `json:"allow_points_payment,omitempty"` + // AllowPlatformPayment holds the value of the "allow_platform_payment" field. + AllowPlatformPayment bool `json:"allow_platform_payment,omitempty"` // DrawEnabled holds the value of the "draw_enabled" field. DrawEnabled bool `json:"draw_enabled,omitempty"` // DrawMinAmount holds the value of the "draw_min_amount" field. @@ -122,7 +128,7 @@ func (*ShopProduct) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case shopproduct.FieldEnabled, shopproduct.FieldAutoDelivery, shopproduct.FieldBalanceOnly, shopproduct.FieldDrawEnabled: + case shopproduct.FieldEnabled, shopproduct.FieldAutoDelivery, shopproduct.FieldBalanceOnly, shopproduct.FieldAllowBalancePayment, shopproduct.FieldAllowPointsPayment, shopproduct.FieldAllowPlatformPayment, shopproduct.FieldDrawEnabled: values[i] = new(sql.NullBool) case shopproduct.FieldPrice, shopproduct.FieldOriginalPrice, shopproduct.FieldDrawMinAmount, shopproduct.FieldDrawMaxAmount, shopproduct.FieldDrawReturnRate: values[i] = new(sql.NullFloat64) @@ -247,6 +253,24 @@ func (_m *ShopProduct) assignValues(columns []string, values []any) error { } else if value.Valid { _m.BalanceOnly = value.Bool } + case shopproduct.FieldAllowBalancePayment: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field allow_balance_payment", values[i]) + } else if value.Valid { + _m.AllowBalancePayment = value.Bool + } + case shopproduct.FieldAllowPointsPayment: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field allow_points_payment", values[i]) + } else if value.Valid { + _m.AllowPointsPayment = value.Bool + } + case shopproduct.FieldAllowPlatformPayment: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field allow_platform_payment", values[i]) + } else if value.Valid { + _m.AllowPlatformPayment = value.Bool + } case shopproduct.FieldDrawEnabled: if value, ok := values[i].(*sql.NullBool); !ok { return fmt.Errorf("unexpected type %T for field draw_enabled", values[i]) @@ -386,6 +410,15 @@ func (_m *ShopProduct) String() string { builder.WriteString("balance_only=") builder.WriteString(fmt.Sprintf("%v", _m.BalanceOnly)) builder.WriteString(", ") + builder.WriteString("allow_balance_payment=") + builder.WriteString(fmt.Sprintf("%v", _m.AllowBalancePayment)) + builder.WriteString(", ") + builder.WriteString("allow_points_payment=") + builder.WriteString(fmt.Sprintf("%v", _m.AllowPointsPayment)) + builder.WriteString(", ") + builder.WriteString("allow_platform_payment=") + builder.WriteString(fmt.Sprintf("%v", _m.AllowPlatformPayment)) + builder.WriteString(", ") builder.WriteString("draw_enabled=") builder.WriteString(fmt.Sprintf("%v", _m.DrawEnabled)) builder.WriteString(", ") diff --git a/backend/ent/shopproduct/shopproduct.go b/backend/ent/shopproduct/shopproduct.go index 5f5a3b69d..e9f8ae783 100644 --- a/backend/ent/shopproduct/shopproduct.go +++ b/backend/ent/shopproduct/shopproduct.go @@ -44,6 +44,12 @@ const ( FieldProductType = "product_type" // FieldBalanceOnly holds the string denoting the balance_only field in the database. FieldBalanceOnly = "balance_only" + // FieldAllowBalancePayment holds the string denoting the allow_balance_payment field in the database. + FieldAllowBalancePayment = "allow_balance_payment" + // FieldAllowPointsPayment holds the string denoting the allow_points_payment field in the database. + FieldAllowPointsPayment = "allow_points_payment" + // FieldAllowPlatformPayment holds the string denoting the allow_platform_payment field in the database. + FieldAllowPlatformPayment = "allow_platform_payment" // FieldDrawEnabled holds the string denoting the draw_enabled field in the database. FieldDrawEnabled = "draw_enabled" // FieldDrawMinAmount holds the string denoting the draw_min_amount field in the database. @@ -112,6 +118,9 @@ var Columns = []string{ FieldAutoDelivery, FieldProductType, FieldBalanceOnly, + FieldAllowBalancePayment, + FieldAllowPointsPayment, + FieldAllowPlatformPayment, FieldDrawEnabled, FieldDrawMinAmount, FieldDrawMaxAmount, @@ -156,6 +165,12 @@ var ( ProductTypeValidator func(string) error // DefaultBalanceOnly holds the default value on creation for the "balance_only" field. DefaultBalanceOnly bool + // DefaultAllowBalancePayment holds the default value on creation for the "allow_balance_payment" field. + DefaultAllowBalancePayment bool + // DefaultAllowPointsPayment holds the default value on creation for the "allow_points_payment" field. + DefaultAllowPointsPayment bool + // DefaultAllowPlatformPayment holds the default value on creation for the "allow_platform_payment" field. + DefaultAllowPlatformPayment bool // DefaultDrawEnabled holds the default value on creation for the "draw_enabled" field. DefaultDrawEnabled bool // DefaultDrawMinAmount holds the default value on creation for the "draw_min_amount" field. @@ -251,6 +266,21 @@ func ByBalanceOnly(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldBalanceOnly, opts...).ToFunc() } +// ByAllowBalancePayment orders the results by the allow_balance_payment field. +func ByAllowBalancePayment(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldAllowBalancePayment, opts...).ToFunc() +} + +// ByAllowPointsPayment orders the results by the allow_points_payment field. +func ByAllowPointsPayment(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldAllowPointsPayment, opts...).ToFunc() +} + +// ByAllowPlatformPayment orders the results by the allow_platform_payment field. +func ByAllowPlatformPayment(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldAllowPlatformPayment, opts...).ToFunc() +} + // ByDrawEnabled orders the results by the draw_enabled field. func ByDrawEnabled(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldDrawEnabled, opts...).ToFunc() diff --git a/backend/ent/shopproduct/where.go b/backend/ent/shopproduct/where.go index 9c7617e0d..1624a9852 100644 --- a/backend/ent/shopproduct/where.go +++ b/backend/ent/shopproduct/where.go @@ -130,6 +130,21 @@ func BalanceOnly(v bool) predicate.ShopProduct { return predicate.ShopProduct(sql.FieldEQ(FieldBalanceOnly, v)) } +// AllowBalancePayment applies equality check predicate on the "allow_balance_payment" field. It's identical to AllowBalancePaymentEQ. +func AllowBalancePayment(v bool) predicate.ShopProduct { + return predicate.ShopProduct(sql.FieldEQ(FieldAllowBalancePayment, v)) +} + +// AllowPointsPayment applies equality check predicate on the "allow_points_payment" field. It's identical to AllowPointsPaymentEQ. +func AllowPointsPayment(v bool) predicate.ShopProduct { + return predicate.ShopProduct(sql.FieldEQ(FieldAllowPointsPayment, v)) +} + +// AllowPlatformPayment applies equality check predicate on the "allow_platform_payment" field. It's identical to AllowPlatformPaymentEQ. +func AllowPlatformPayment(v bool) predicate.ShopProduct { + return predicate.ShopProduct(sql.FieldEQ(FieldAllowPlatformPayment, v)) +} + // DrawEnabled applies equality check predicate on the "draw_enabled" field. It's identical to DrawEnabledEQ. func DrawEnabled(v bool) predicate.ShopProduct { return predicate.ShopProduct(sql.FieldEQ(FieldDrawEnabled, v)) @@ -785,6 +800,36 @@ func BalanceOnlyNEQ(v bool) predicate.ShopProduct { return predicate.ShopProduct(sql.FieldNEQ(FieldBalanceOnly, v)) } +// AllowBalancePaymentEQ applies the EQ predicate on the "allow_balance_payment" field. +func AllowBalancePaymentEQ(v bool) predicate.ShopProduct { + return predicate.ShopProduct(sql.FieldEQ(FieldAllowBalancePayment, v)) +} + +// AllowBalancePaymentNEQ applies the NEQ predicate on the "allow_balance_payment" field. +func AllowBalancePaymentNEQ(v bool) predicate.ShopProduct { + return predicate.ShopProduct(sql.FieldNEQ(FieldAllowBalancePayment, v)) +} + +// AllowPointsPaymentEQ applies the EQ predicate on the "allow_points_payment" field. +func AllowPointsPaymentEQ(v bool) predicate.ShopProduct { + return predicate.ShopProduct(sql.FieldEQ(FieldAllowPointsPayment, v)) +} + +// AllowPointsPaymentNEQ applies the NEQ predicate on the "allow_points_payment" field. +func AllowPointsPaymentNEQ(v bool) predicate.ShopProduct { + return predicate.ShopProduct(sql.FieldNEQ(FieldAllowPointsPayment, v)) +} + +// AllowPlatformPaymentEQ applies the EQ predicate on the "allow_platform_payment" field. +func AllowPlatformPaymentEQ(v bool) predicate.ShopProduct { + return predicate.ShopProduct(sql.FieldEQ(FieldAllowPlatformPayment, v)) +} + +// AllowPlatformPaymentNEQ applies the NEQ predicate on the "allow_platform_payment" field. +func AllowPlatformPaymentNEQ(v bool) predicate.ShopProduct { + return predicate.ShopProduct(sql.FieldNEQ(FieldAllowPlatformPayment, v)) +} + // DrawEnabledEQ applies the EQ predicate on the "draw_enabled" field. func DrawEnabledEQ(v bool) predicate.ShopProduct { return predicate.ShopProduct(sql.FieldEQ(FieldDrawEnabled, v)) diff --git a/backend/ent/shopproduct_create.go b/backend/ent/shopproduct_create.go index d6cef285c..86baae24d 100644 --- a/backend/ent/shopproduct_create.go +++ b/backend/ent/shopproduct_create.go @@ -228,6 +228,48 @@ func (_c *ShopProductCreate) SetNillableBalanceOnly(v *bool) *ShopProductCreate return _c } +// SetAllowBalancePayment sets the "allow_balance_payment" field. +func (_c *ShopProductCreate) SetAllowBalancePayment(v bool) *ShopProductCreate { + _c.mutation.SetAllowBalancePayment(v) + return _c +} + +// SetNillableAllowBalancePayment sets the "allow_balance_payment" field if the given value is not nil. +func (_c *ShopProductCreate) SetNillableAllowBalancePayment(v *bool) *ShopProductCreate { + if v != nil { + _c.SetAllowBalancePayment(*v) + } + return _c +} + +// SetAllowPointsPayment sets the "allow_points_payment" field. +func (_c *ShopProductCreate) SetAllowPointsPayment(v bool) *ShopProductCreate { + _c.mutation.SetAllowPointsPayment(v) + return _c +} + +// SetNillableAllowPointsPayment sets the "allow_points_payment" field if the given value is not nil. +func (_c *ShopProductCreate) SetNillableAllowPointsPayment(v *bool) *ShopProductCreate { + if v != nil { + _c.SetAllowPointsPayment(*v) + } + return _c +} + +// SetAllowPlatformPayment sets the "allow_platform_payment" field. +func (_c *ShopProductCreate) SetAllowPlatformPayment(v bool) *ShopProductCreate { + _c.mutation.SetAllowPlatformPayment(v) + return _c +} + +// SetNillableAllowPlatformPayment sets the "allow_platform_payment" field if the given value is not nil. +func (_c *ShopProductCreate) SetNillableAllowPlatformPayment(v *bool) *ShopProductCreate { + if v != nil { + _c.SetAllowPlatformPayment(*v) + } + return _c +} + // SetDrawEnabled sets the "draw_enabled" field. func (_c *ShopProductCreate) SetDrawEnabled(v bool) *ShopProductCreate { _c.mutation.SetDrawEnabled(v) @@ -423,6 +465,18 @@ func (_c *ShopProductCreate) defaults() { v := shopproduct.DefaultBalanceOnly _c.mutation.SetBalanceOnly(v) } + if _, ok := _c.mutation.AllowBalancePayment(); !ok { + v := shopproduct.DefaultAllowBalancePayment + _c.mutation.SetAllowBalancePayment(v) + } + if _, ok := _c.mutation.AllowPointsPayment(); !ok { + v := shopproduct.DefaultAllowPointsPayment + _c.mutation.SetAllowPointsPayment(v) + } + if _, ok := _c.mutation.AllowPlatformPayment(); !ok { + v := shopproduct.DefaultAllowPlatformPayment + _c.mutation.SetAllowPlatformPayment(v) + } if _, ok := _c.mutation.DrawEnabled(); !ok { v := shopproduct.DefaultDrawEnabled _c.mutation.SetDrawEnabled(v) @@ -490,6 +544,15 @@ func (_c *ShopProductCreate) check() error { if _, ok := _c.mutation.BalanceOnly(); !ok { return &ValidationError{Name: "balance_only", err: errors.New(`ent: missing required field "ShopProduct.balance_only"`)} } + if _, ok := _c.mutation.AllowBalancePayment(); !ok { + return &ValidationError{Name: "allow_balance_payment", err: errors.New(`ent: missing required field "ShopProduct.allow_balance_payment"`)} + } + if _, ok := _c.mutation.AllowPointsPayment(); !ok { + return &ValidationError{Name: "allow_points_payment", err: errors.New(`ent: missing required field "ShopProduct.allow_points_payment"`)} + } + if _, ok := _c.mutation.AllowPlatformPayment(); !ok { + return &ValidationError{Name: "allow_platform_payment", err: errors.New(`ent: missing required field "ShopProduct.allow_platform_payment"`)} + } if _, ok := _c.mutation.DrawEnabled(); !ok { return &ValidationError{Name: "draw_enabled", err: errors.New(`ent: missing required field "ShopProduct.draw_enabled"`)} } @@ -588,6 +651,18 @@ func (_c *ShopProductCreate) createSpec() (*ShopProduct, *sqlgraph.CreateSpec) { _spec.SetField(shopproduct.FieldBalanceOnly, field.TypeBool, value) _node.BalanceOnly = value } + if value, ok := _c.mutation.AllowBalancePayment(); ok { + _spec.SetField(shopproduct.FieldAllowBalancePayment, field.TypeBool, value) + _node.AllowBalancePayment = value + } + if value, ok := _c.mutation.AllowPointsPayment(); ok { + _spec.SetField(shopproduct.FieldAllowPointsPayment, field.TypeBool, value) + _node.AllowPointsPayment = value + } + if value, ok := _c.mutation.AllowPlatformPayment(); ok { + _spec.SetField(shopproduct.FieldAllowPlatformPayment, field.TypeBool, value) + _node.AllowPlatformPayment = value + } if value, ok := _c.mutation.DrawEnabled(); ok { _spec.SetField(shopproduct.FieldDrawEnabled, field.TypeBool, value) _node.DrawEnabled = value @@ -947,6 +1022,42 @@ func (u *ShopProductUpsert) UpdateBalanceOnly() *ShopProductUpsert { return u } +// SetAllowBalancePayment sets the "allow_balance_payment" field. +func (u *ShopProductUpsert) SetAllowBalancePayment(v bool) *ShopProductUpsert { + u.Set(shopproduct.FieldAllowBalancePayment, v) + return u +} + +// UpdateAllowBalancePayment sets the "allow_balance_payment" field to the value that was provided on create. +func (u *ShopProductUpsert) UpdateAllowBalancePayment() *ShopProductUpsert { + u.SetExcluded(shopproduct.FieldAllowBalancePayment) + return u +} + +// SetAllowPointsPayment sets the "allow_points_payment" field. +func (u *ShopProductUpsert) SetAllowPointsPayment(v bool) *ShopProductUpsert { + u.Set(shopproduct.FieldAllowPointsPayment, v) + return u +} + +// UpdateAllowPointsPayment sets the "allow_points_payment" field to the value that was provided on create. +func (u *ShopProductUpsert) UpdateAllowPointsPayment() *ShopProductUpsert { + u.SetExcluded(shopproduct.FieldAllowPointsPayment) + return u +} + +// SetAllowPlatformPayment sets the "allow_platform_payment" field. +func (u *ShopProductUpsert) SetAllowPlatformPayment(v bool) *ShopProductUpsert { + u.Set(shopproduct.FieldAllowPlatformPayment, v) + return u +} + +// UpdateAllowPlatformPayment sets the "allow_platform_payment" field to the value that was provided on create. +func (u *ShopProductUpsert) UpdateAllowPlatformPayment() *ShopProductUpsert { + u.SetExcluded(shopproduct.FieldAllowPlatformPayment) + return u +} + // SetDrawEnabled sets the "draw_enabled" field. func (u *ShopProductUpsert) SetDrawEnabled(v bool) *ShopProductUpsert { u.Set(shopproduct.FieldDrawEnabled, v) @@ -1335,6 +1446,48 @@ func (u *ShopProductUpsertOne) UpdateBalanceOnly() *ShopProductUpsertOne { }) } +// SetAllowBalancePayment sets the "allow_balance_payment" field. +func (u *ShopProductUpsertOne) SetAllowBalancePayment(v bool) *ShopProductUpsertOne { + return u.Update(func(s *ShopProductUpsert) { + s.SetAllowBalancePayment(v) + }) +} + +// UpdateAllowBalancePayment sets the "allow_balance_payment" field to the value that was provided on create. +func (u *ShopProductUpsertOne) UpdateAllowBalancePayment() *ShopProductUpsertOne { + return u.Update(func(s *ShopProductUpsert) { + s.UpdateAllowBalancePayment() + }) +} + +// SetAllowPointsPayment sets the "allow_points_payment" field. +func (u *ShopProductUpsertOne) SetAllowPointsPayment(v bool) *ShopProductUpsertOne { + return u.Update(func(s *ShopProductUpsert) { + s.SetAllowPointsPayment(v) + }) +} + +// UpdateAllowPointsPayment sets the "allow_points_payment" field to the value that was provided on create. +func (u *ShopProductUpsertOne) UpdateAllowPointsPayment() *ShopProductUpsertOne { + return u.Update(func(s *ShopProductUpsert) { + s.UpdateAllowPointsPayment() + }) +} + +// SetAllowPlatformPayment sets the "allow_platform_payment" field. +func (u *ShopProductUpsertOne) SetAllowPlatformPayment(v bool) *ShopProductUpsertOne { + return u.Update(func(s *ShopProductUpsert) { + s.SetAllowPlatformPayment(v) + }) +} + +// UpdateAllowPlatformPayment sets the "allow_platform_payment" field to the value that was provided on create. +func (u *ShopProductUpsertOne) UpdateAllowPlatformPayment() *ShopProductUpsertOne { + return u.Update(func(s *ShopProductUpsert) { + s.UpdateAllowPlatformPayment() + }) +} + // SetDrawEnabled sets the "draw_enabled" field. func (u *ShopProductUpsertOne) SetDrawEnabled(v bool) *ShopProductUpsertOne { return u.Update(func(s *ShopProductUpsert) { @@ -1903,6 +2056,48 @@ func (u *ShopProductUpsertBulk) UpdateBalanceOnly() *ShopProductUpsertBulk { }) } +// SetAllowBalancePayment sets the "allow_balance_payment" field. +func (u *ShopProductUpsertBulk) SetAllowBalancePayment(v bool) *ShopProductUpsertBulk { + return u.Update(func(s *ShopProductUpsert) { + s.SetAllowBalancePayment(v) + }) +} + +// UpdateAllowBalancePayment sets the "allow_balance_payment" field to the value that was provided on create. +func (u *ShopProductUpsertBulk) UpdateAllowBalancePayment() *ShopProductUpsertBulk { + return u.Update(func(s *ShopProductUpsert) { + s.UpdateAllowBalancePayment() + }) +} + +// SetAllowPointsPayment sets the "allow_points_payment" field. +func (u *ShopProductUpsertBulk) SetAllowPointsPayment(v bool) *ShopProductUpsertBulk { + return u.Update(func(s *ShopProductUpsert) { + s.SetAllowPointsPayment(v) + }) +} + +// UpdateAllowPointsPayment sets the "allow_points_payment" field to the value that was provided on create. +func (u *ShopProductUpsertBulk) UpdateAllowPointsPayment() *ShopProductUpsertBulk { + return u.Update(func(s *ShopProductUpsert) { + s.UpdateAllowPointsPayment() + }) +} + +// SetAllowPlatformPayment sets the "allow_platform_payment" field. +func (u *ShopProductUpsertBulk) SetAllowPlatformPayment(v bool) *ShopProductUpsertBulk { + return u.Update(func(s *ShopProductUpsert) { + s.SetAllowPlatformPayment(v) + }) +} + +// UpdateAllowPlatformPayment sets the "allow_platform_payment" field to the value that was provided on create. +func (u *ShopProductUpsertBulk) UpdateAllowPlatformPayment() *ShopProductUpsertBulk { + return u.Update(func(s *ShopProductUpsert) { + s.UpdateAllowPlatformPayment() + }) +} + // SetDrawEnabled sets the "draw_enabled" field. func (u *ShopProductUpsertBulk) SetDrawEnabled(v bool) *ShopProductUpsertBulk { return u.Update(func(s *ShopProductUpsert) { diff --git a/backend/ent/shopproduct_update.go b/backend/ent/shopproduct_update.go index 78bd035a9..15e913cb5 100644 --- a/backend/ent/shopproduct_update.go +++ b/backend/ent/shopproduct_update.go @@ -279,6 +279,48 @@ func (_u *ShopProductUpdate) SetNillableBalanceOnly(v *bool) *ShopProductUpdate return _u } +// SetAllowBalancePayment sets the "allow_balance_payment" field. +func (_u *ShopProductUpdate) SetAllowBalancePayment(v bool) *ShopProductUpdate { + _u.mutation.SetAllowBalancePayment(v) + return _u +} + +// SetNillableAllowBalancePayment sets the "allow_balance_payment" field if the given value is not nil. +func (_u *ShopProductUpdate) SetNillableAllowBalancePayment(v *bool) *ShopProductUpdate { + if v != nil { + _u.SetAllowBalancePayment(*v) + } + return _u +} + +// SetAllowPointsPayment sets the "allow_points_payment" field. +func (_u *ShopProductUpdate) SetAllowPointsPayment(v bool) *ShopProductUpdate { + _u.mutation.SetAllowPointsPayment(v) + return _u +} + +// SetNillableAllowPointsPayment sets the "allow_points_payment" field if the given value is not nil. +func (_u *ShopProductUpdate) SetNillableAllowPointsPayment(v *bool) *ShopProductUpdate { + if v != nil { + _u.SetAllowPointsPayment(*v) + } + return _u +} + +// SetAllowPlatformPayment sets the "allow_platform_payment" field. +func (_u *ShopProductUpdate) SetAllowPlatformPayment(v bool) *ShopProductUpdate { + _u.mutation.SetAllowPlatformPayment(v) + return _u +} + +// SetNillableAllowPlatformPayment sets the "allow_platform_payment" field if the given value is not nil. +func (_u *ShopProductUpdate) SetNillableAllowPlatformPayment(v *bool) *ShopProductUpdate { + if v != nil { + _u.SetAllowPlatformPayment(*v) + } + return _u +} + // SetDrawEnabled sets the "draw_enabled" field. func (_u *ShopProductUpdate) SetDrawEnabled(v bool) *ShopProductUpdate { _u.mutation.SetDrawEnabled(v) @@ -627,6 +669,15 @@ func (_u *ShopProductUpdate) sqlSave(ctx context.Context) (_node int, err error) if value, ok := _u.mutation.BalanceOnly(); ok { _spec.SetField(shopproduct.FieldBalanceOnly, field.TypeBool, value) } + if value, ok := _u.mutation.AllowBalancePayment(); ok { + _spec.SetField(shopproduct.FieldAllowBalancePayment, field.TypeBool, value) + } + if value, ok := _u.mutation.AllowPointsPayment(); ok { + _spec.SetField(shopproduct.FieldAllowPointsPayment, field.TypeBool, value) + } + if value, ok := _u.mutation.AllowPlatformPayment(); ok { + _spec.SetField(shopproduct.FieldAllowPlatformPayment, field.TypeBool, value) + } if value, ok := _u.mutation.DrawEnabled(); ok { _spec.SetField(shopproduct.FieldDrawEnabled, field.TypeBool, value) } @@ -1085,6 +1136,48 @@ func (_u *ShopProductUpdateOne) SetNillableBalanceOnly(v *bool) *ShopProductUpda return _u } +// SetAllowBalancePayment sets the "allow_balance_payment" field. +func (_u *ShopProductUpdateOne) SetAllowBalancePayment(v bool) *ShopProductUpdateOne { + _u.mutation.SetAllowBalancePayment(v) + return _u +} + +// SetNillableAllowBalancePayment sets the "allow_balance_payment" field if the given value is not nil. +func (_u *ShopProductUpdateOne) SetNillableAllowBalancePayment(v *bool) *ShopProductUpdateOne { + if v != nil { + _u.SetAllowBalancePayment(*v) + } + return _u +} + +// SetAllowPointsPayment sets the "allow_points_payment" field. +func (_u *ShopProductUpdateOne) SetAllowPointsPayment(v bool) *ShopProductUpdateOne { + _u.mutation.SetAllowPointsPayment(v) + return _u +} + +// SetNillableAllowPointsPayment sets the "allow_points_payment" field if the given value is not nil. +func (_u *ShopProductUpdateOne) SetNillableAllowPointsPayment(v *bool) *ShopProductUpdateOne { + if v != nil { + _u.SetAllowPointsPayment(*v) + } + return _u +} + +// SetAllowPlatformPayment sets the "allow_platform_payment" field. +func (_u *ShopProductUpdateOne) SetAllowPlatformPayment(v bool) *ShopProductUpdateOne { + _u.mutation.SetAllowPlatformPayment(v) + return _u +} + +// SetNillableAllowPlatformPayment sets the "allow_platform_payment" field if the given value is not nil. +func (_u *ShopProductUpdateOne) SetNillableAllowPlatformPayment(v *bool) *ShopProductUpdateOne { + if v != nil { + _u.SetAllowPlatformPayment(*v) + } + return _u +} + // SetDrawEnabled sets the "draw_enabled" field. func (_u *ShopProductUpdateOne) SetDrawEnabled(v bool) *ShopProductUpdateOne { _u.mutation.SetDrawEnabled(v) @@ -1463,6 +1556,15 @@ func (_u *ShopProductUpdateOne) sqlSave(ctx context.Context) (_node *ShopProduct if value, ok := _u.mutation.BalanceOnly(); ok { _spec.SetField(shopproduct.FieldBalanceOnly, field.TypeBool, value) } + if value, ok := _u.mutation.AllowBalancePayment(); ok { + _spec.SetField(shopproduct.FieldAllowBalancePayment, field.TypeBool, value) + } + if value, ok := _u.mutation.AllowPointsPayment(); ok { + _spec.SetField(shopproduct.FieldAllowPointsPayment, field.TypeBool, value) + } + if value, ok := _u.mutation.AllowPlatformPayment(); ok { + _spec.SetField(shopproduct.FieldAllowPlatformPayment, field.TypeBool, value) + } if value, ok := _u.mutation.DrawEnabled(); ok { _spec.SetField(shopproduct.FieldDrawEnabled, field.TypeBool, value) } diff --git a/backend/ent/user.go b/backend/ent/user.go index f745d302f..856c2172a 100644 --- a/backend/ent/user.go +++ b/backend/ent/user.go @@ -31,6 +31,10 @@ type User struct { Role string `json:"role,omitempty"` // Balance holds the value of the "balance" field. Balance float64 `json:"balance,omitempty"` + // PointsBalance holds the value of the "points_balance" field. + PointsBalance float64 `json:"points_balance,omitempty"` + // PreferPointsBilling holds the value of the "prefer_points_billing" field. + PreferPointsBilling bool `json:"prefer_points_billing,omitempty"` // Concurrency holds the value of the "concurrency" field. Concurrency int `json:"concurrency,omitempty"` // Status holds the value of the "status" field. @@ -268,9 +272,9 @@ func (*User) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case user.FieldTotpEnabled, user.FieldBalanceNotifyEnabled: + case user.FieldPreferPointsBilling, user.FieldTotpEnabled, user.FieldBalanceNotifyEnabled: values[i] = new(sql.NullBool) - case user.FieldBalance, user.FieldBalanceNotifyThreshold, user.FieldTotalRecharged: + case user.FieldBalance, user.FieldPointsBalance, user.FieldBalanceNotifyThreshold, user.FieldTotalRecharged: values[i] = new(sql.NullFloat64) case user.FieldID, user.FieldConcurrency, user.FieldRpmLimit: values[i] = new(sql.NullInt64) @@ -342,6 +346,18 @@ func (_m *User) assignValues(columns []string, values []any) error { } else if value.Valid { _m.Balance = value.Float64 } + case user.FieldPointsBalance: + if value, ok := values[i].(*sql.NullFloat64); !ok { + return fmt.Errorf("unexpected type %T for field points_balance", values[i]) + } else if value.Valid { + _m.PointsBalance = value.Float64 + } + case user.FieldPreferPointsBilling: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field prefer_points_billing", values[i]) + } else if value.Valid { + _m.PreferPointsBilling = value.Bool + } case user.FieldConcurrency: if value, ok := values[i].(*sql.NullInt64); !ok { return fmt.Errorf("unexpected type %T for field concurrency", values[i]) @@ -587,6 +603,12 @@ func (_m *User) String() string { builder.WriteString("balance=") builder.WriteString(fmt.Sprintf("%v", _m.Balance)) builder.WriteString(", ") + builder.WriteString("points_balance=") + builder.WriteString(fmt.Sprintf("%v", _m.PointsBalance)) + builder.WriteString(", ") + builder.WriteString("prefer_points_billing=") + builder.WriteString(fmt.Sprintf("%v", _m.PreferPointsBilling)) + builder.WriteString(", ") builder.WriteString("concurrency=") builder.WriteString(fmt.Sprintf("%v", _m.Concurrency)) builder.WriteString(", ") diff --git a/backend/ent/user/user.go b/backend/ent/user/user.go index 027bd784a..74478614a 100644 --- a/backend/ent/user/user.go +++ b/backend/ent/user/user.go @@ -29,6 +29,10 @@ const ( FieldRole = "role" // FieldBalance holds the string denoting the balance field in the database. FieldBalance = "balance" + // FieldPointsBalance holds the string denoting the points_balance field in the database. + FieldPointsBalance = "points_balance" + // FieldPreferPointsBilling holds the string denoting the prefer_points_billing field in the database. + FieldPreferPointsBilling = "prefer_points_billing" // FieldConcurrency holds the string denoting the concurrency field in the database. FieldConcurrency = "concurrency" // FieldStatus holds the string denoting the status field in the database. @@ -226,6 +230,8 @@ var Columns = []string{ FieldPasswordHash, FieldRole, FieldBalance, + FieldPointsBalance, + FieldPreferPointsBilling, FieldConcurrency, FieldStatus, FieldUsername, @@ -284,6 +290,10 @@ var ( RoleValidator func(string) error // DefaultBalance holds the default value on creation for the "balance" field. DefaultBalance float64 + // DefaultPointsBalance holds the default value on creation for the "points_balance" field. + DefaultPointsBalance float64 + // DefaultPreferPointsBilling holds the default value on creation for the "prefer_points_billing" field. + DefaultPreferPointsBilling bool // DefaultConcurrency holds the default value on creation for the "concurrency" field. DefaultConcurrency int // DefaultStatus holds the default value on creation for the "status" field. @@ -357,6 +367,16 @@ func ByBalance(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldBalance, opts...).ToFunc() } +// ByPointsBalance orders the results by the points_balance field. +func ByPointsBalance(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldPointsBalance, opts...).ToFunc() +} + +// ByPreferPointsBilling orders the results by the prefer_points_billing field. +func ByPreferPointsBilling(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldPreferPointsBilling, opts...).ToFunc() +} + // ByConcurrency orders the results by the concurrency field. func ByConcurrency(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldConcurrency, opts...).ToFunc() diff --git a/backend/ent/user/where.go b/backend/ent/user/where.go index 40aac8f67..90919c855 100644 --- a/backend/ent/user/where.go +++ b/backend/ent/user/where.go @@ -90,6 +90,16 @@ func Balance(v float64) predicate.User { return predicate.User(sql.FieldEQ(FieldBalance, v)) } +// PointsBalance applies equality check predicate on the "points_balance" field. It's identical to PointsBalanceEQ. +func PointsBalance(v float64) predicate.User { + return predicate.User(sql.FieldEQ(FieldPointsBalance, v)) +} + +// PreferPointsBilling applies equality check predicate on the "prefer_points_billing" field. It's identical to PreferPointsBillingEQ. +func PreferPointsBilling(v bool) predicate.User { + return predicate.User(sql.FieldEQ(FieldPreferPointsBilling, v)) +} + // Concurrency applies equality check predicate on the "concurrency" field. It's identical to ConcurrencyEQ. func Concurrency(v int) predicate.User { return predicate.User(sql.FieldEQ(FieldConcurrency, v)) @@ -535,6 +545,56 @@ func BalanceLTE(v float64) predicate.User { return predicate.User(sql.FieldLTE(FieldBalance, v)) } +// PointsBalanceEQ applies the EQ predicate on the "points_balance" field. +func PointsBalanceEQ(v float64) predicate.User { + return predicate.User(sql.FieldEQ(FieldPointsBalance, v)) +} + +// PointsBalanceNEQ applies the NEQ predicate on the "points_balance" field. +func PointsBalanceNEQ(v float64) predicate.User { + return predicate.User(sql.FieldNEQ(FieldPointsBalance, v)) +} + +// PointsBalanceIn applies the In predicate on the "points_balance" field. +func PointsBalanceIn(vs ...float64) predicate.User { + return predicate.User(sql.FieldIn(FieldPointsBalance, vs...)) +} + +// PointsBalanceNotIn applies the NotIn predicate on the "points_balance" field. +func PointsBalanceNotIn(vs ...float64) predicate.User { + return predicate.User(sql.FieldNotIn(FieldPointsBalance, vs...)) +} + +// PointsBalanceGT applies the GT predicate on the "points_balance" field. +func PointsBalanceGT(v float64) predicate.User { + return predicate.User(sql.FieldGT(FieldPointsBalance, v)) +} + +// PointsBalanceGTE applies the GTE predicate on the "points_balance" field. +func PointsBalanceGTE(v float64) predicate.User { + return predicate.User(sql.FieldGTE(FieldPointsBalance, v)) +} + +// PointsBalanceLT applies the LT predicate on the "points_balance" field. +func PointsBalanceLT(v float64) predicate.User { + return predicate.User(sql.FieldLT(FieldPointsBalance, v)) +} + +// PointsBalanceLTE applies the LTE predicate on the "points_balance" field. +func PointsBalanceLTE(v float64) predicate.User { + return predicate.User(sql.FieldLTE(FieldPointsBalance, v)) +} + +// PreferPointsBillingEQ applies the EQ predicate on the "prefer_points_billing" field. +func PreferPointsBillingEQ(v bool) predicate.User { + return predicate.User(sql.FieldEQ(FieldPreferPointsBilling, v)) +} + +// PreferPointsBillingNEQ applies the NEQ predicate on the "prefer_points_billing" field. +func PreferPointsBillingNEQ(v bool) predicate.User { + return predicate.User(sql.FieldNEQ(FieldPreferPointsBilling, v)) +} + // ConcurrencyEQ applies the EQ predicate on the "concurrency" field. func ConcurrencyEQ(v int) predicate.User { return predicate.User(sql.FieldEQ(FieldConcurrency, v)) diff --git a/backend/ent/user_create.go b/backend/ent/user_create.go index 3639e13d5..00ceddb8f 100644 --- a/backend/ent/user_create.go +++ b/backend/ent/user_create.go @@ -119,6 +119,34 @@ func (_c *UserCreate) SetNillableBalance(v *float64) *UserCreate { return _c } +// SetPointsBalance sets the "points_balance" field. +func (_c *UserCreate) SetPointsBalance(v float64) *UserCreate { + _c.mutation.SetPointsBalance(v) + return _c +} + +// SetNillablePointsBalance sets the "points_balance" field if the given value is not nil. +func (_c *UserCreate) SetNillablePointsBalance(v *float64) *UserCreate { + if v != nil { + _c.SetPointsBalance(*v) + } + return _c +} + +// SetPreferPointsBilling sets the "prefer_points_billing" field. +func (_c *UserCreate) SetPreferPointsBilling(v bool) *UserCreate { + _c.mutation.SetPreferPointsBilling(v) + return _c +} + +// SetNillablePreferPointsBilling sets the "prefer_points_billing" field if the given value is not nil. +func (_c *UserCreate) SetNillablePreferPointsBilling(v *bool) *UserCreate { + if v != nil { + _c.SetPreferPointsBilling(*v) + } + return _c +} + // SetConcurrency sets the "concurrency" field. func (_c *UserCreate) SetConcurrency(v int) *UserCreate { _c.mutation.SetConcurrency(v) @@ -642,6 +670,14 @@ func (_c *UserCreate) defaults() error { v := user.DefaultBalance _c.mutation.SetBalance(v) } + if _, ok := _c.mutation.PointsBalance(); !ok { + v := user.DefaultPointsBalance + _c.mutation.SetPointsBalance(v) + } + if _, ok := _c.mutation.PreferPointsBilling(); !ok { + v := user.DefaultPreferPointsBilling + _c.mutation.SetPreferPointsBilling(v) + } if _, ok := _c.mutation.Concurrency(); !ok { v := user.DefaultConcurrency _c.mutation.SetConcurrency(v) @@ -724,6 +760,12 @@ func (_c *UserCreate) check() error { if _, ok := _c.mutation.Balance(); !ok { return &ValidationError{Name: "balance", err: errors.New(`ent: missing required field "User.balance"`)} } + if _, ok := _c.mutation.PointsBalance(); !ok { + return &ValidationError{Name: "points_balance", err: errors.New(`ent: missing required field "User.points_balance"`)} + } + if _, ok := _c.mutation.PreferPointsBilling(); !ok { + return &ValidationError{Name: "prefer_points_billing", err: errors.New(`ent: missing required field "User.prefer_points_billing"`)} + } if _, ok := _c.mutation.Concurrency(); !ok { return &ValidationError{Name: "concurrency", err: errors.New(`ent: missing required field "User.concurrency"`)} } @@ -827,6 +869,14 @@ func (_c *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) { _spec.SetField(user.FieldBalance, field.TypeFloat64, value) _node.Balance = value } + if value, ok := _c.mutation.PointsBalance(); ok { + _spec.SetField(user.FieldPointsBalance, field.TypeFloat64, value) + _node.PointsBalance = value + } + if value, ok := _c.mutation.PreferPointsBilling(); ok { + _spec.SetField(user.FieldPreferPointsBilling, field.TypeBool, value) + _node.PreferPointsBilling = value + } if value, ok := _c.mutation.Concurrency(); ok { _spec.SetField(user.FieldConcurrency, field.TypeInt, value) _node.Concurrency = value @@ -1287,6 +1337,36 @@ func (u *UserUpsert) AddBalance(v float64) *UserUpsert { return u } +// SetPointsBalance sets the "points_balance" field. +func (u *UserUpsert) SetPointsBalance(v float64) *UserUpsert { + u.Set(user.FieldPointsBalance, v) + return u +} + +// UpdatePointsBalance sets the "points_balance" field to the value that was provided on create. +func (u *UserUpsert) UpdatePointsBalance() *UserUpsert { + u.SetExcluded(user.FieldPointsBalance) + return u +} + +// AddPointsBalance adds v to the "points_balance" field. +func (u *UserUpsert) AddPointsBalance(v float64) *UserUpsert { + u.Add(user.FieldPointsBalance, v) + return u +} + +// SetPreferPointsBilling sets the "prefer_points_billing" field. +func (u *UserUpsert) SetPreferPointsBilling(v bool) *UserUpsert { + u.Set(user.FieldPreferPointsBilling, v) + return u +} + +// UpdatePreferPointsBilling sets the "prefer_points_billing" field to the value that was provided on create. +func (u *UserUpsert) UpdatePreferPointsBilling() *UserUpsert { + u.SetExcluded(user.FieldPreferPointsBilling) + return u +} + // SetConcurrency sets the "concurrency" field. func (u *UserUpsert) SetConcurrency(v int) *UserUpsert { u.Set(user.FieldConcurrency, v) @@ -1676,6 +1756,41 @@ func (u *UserUpsertOne) UpdateBalance() *UserUpsertOne { }) } +// SetPointsBalance sets the "points_balance" field. +func (u *UserUpsertOne) SetPointsBalance(v float64) *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.SetPointsBalance(v) + }) +} + +// AddPointsBalance adds v to the "points_balance" field. +func (u *UserUpsertOne) AddPointsBalance(v float64) *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.AddPointsBalance(v) + }) +} + +// UpdatePointsBalance sets the "points_balance" field to the value that was provided on create. +func (u *UserUpsertOne) UpdatePointsBalance() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.UpdatePointsBalance() + }) +} + +// SetPreferPointsBilling sets the "prefer_points_billing" field. +func (u *UserUpsertOne) SetPreferPointsBilling(v bool) *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.SetPreferPointsBilling(v) + }) +} + +// UpdatePreferPointsBilling sets the "prefer_points_billing" field to the value that was provided on create. +func (u *UserUpsertOne) UpdatePreferPointsBilling() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.UpdatePreferPointsBilling() + }) +} + // SetConcurrency sets the "concurrency" field. func (u *UserUpsertOne) SetConcurrency(v int) *UserUpsertOne { return u.Update(func(s *UserUpsert) { @@ -2272,6 +2387,41 @@ func (u *UserUpsertBulk) UpdateBalance() *UserUpsertBulk { }) } +// SetPointsBalance sets the "points_balance" field. +func (u *UserUpsertBulk) SetPointsBalance(v float64) *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.SetPointsBalance(v) + }) +} + +// AddPointsBalance adds v to the "points_balance" field. +func (u *UserUpsertBulk) AddPointsBalance(v float64) *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.AddPointsBalance(v) + }) +} + +// UpdatePointsBalance sets the "points_balance" field to the value that was provided on create. +func (u *UserUpsertBulk) UpdatePointsBalance() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.UpdatePointsBalance() + }) +} + +// SetPreferPointsBilling sets the "prefer_points_billing" field. +func (u *UserUpsertBulk) SetPreferPointsBilling(v bool) *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.SetPreferPointsBilling(v) + }) +} + +// UpdatePreferPointsBilling sets the "prefer_points_billing" field to the value that was provided on create. +func (u *UserUpsertBulk) UpdatePreferPointsBilling() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.UpdatePreferPointsBilling() + }) +} + // SetConcurrency sets the "concurrency" field. func (u *UserUpsertBulk) SetConcurrency(v int) *UserUpsertBulk { return u.Update(func(s *UserUpsert) { diff --git a/backend/ent/user_update.go b/backend/ent/user_update.go index 9ee3a327b..8dabf14bd 100644 --- a/backend/ent/user_update.go +++ b/backend/ent/user_update.go @@ -132,6 +132,41 @@ func (_u *UserUpdate) AddBalance(v float64) *UserUpdate { return _u } +// SetPointsBalance sets the "points_balance" field. +func (_u *UserUpdate) SetPointsBalance(v float64) *UserUpdate { + _u.mutation.ResetPointsBalance() + _u.mutation.SetPointsBalance(v) + return _u +} + +// SetNillablePointsBalance sets the "points_balance" field if the given value is not nil. +func (_u *UserUpdate) SetNillablePointsBalance(v *float64) *UserUpdate { + if v != nil { + _u.SetPointsBalance(*v) + } + return _u +} + +// AddPointsBalance adds value to the "points_balance" field. +func (_u *UserUpdate) AddPointsBalance(v float64) *UserUpdate { + _u.mutation.AddPointsBalance(v) + return _u +} + +// SetPreferPointsBilling sets the "prefer_points_billing" field. +func (_u *UserUpdate) SetPreferPointsBilling(v bool) *UserUpdate { + _u.mutation.SetPreferPointsBilling(v) + return _u +} + +// SetNillablePreferPointsBilling sets the "prefer_points_billing" field if the given value is not nil. +func (_u *UserUpdate) SetNillablePreferPointsBilling(v *bool) *UserUpdate { + if v != nil { + _u.SetPreferPointsBilling(*v) + } + return _u +} + // SetConcurrency sets the "concurrency" field. func (_u *UserUpdate) SetConcurrency(v int) *UserUpdate { _u.mutation.ResetConcurrency() @@ -1108,6 +1143,15 @@ func (_u *UserUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.AddedBalance(); ok { _spec.AddField(user.FieldBalance, field.TypeFloat64, value) } + if value, ok := _u.mutation.PointsBalance(); ok { + _spec.SetField(user.FieldPointsBalance, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedPointsBalance(); ok { + _spec.AddField(user.FieldPointsBalance, field.TypeFloat64, value) + } + if value, ok := _u.mutation.PreferPointsBilling(); ok { + _spec.SetField(user.FieldPreferPointsBilling, field.TypeBool, value) + } if value, ok := _u.mutation.Concurrency(); ok { _spec.SetField(user.FieldConcurrency, field.TypeInt, value) } @@ -2024,6 +2068,41 @@ func (_u *UserUpdateOne) AddBalance(v float64) *UserUpdateOne { return _u } +// SetPointsBalance sets the "points_balance" field. +func (_u *UserUpdateOne) SetPointsBalance(v float64) *UserUpdateOne { + _u.mutation.ResetPointsBalance() + _u.mutation.SetPointsBalance(v) + return _u +} + +// SetNillablePointsBalance sets the "points_balance" field if the given value is not nil. +func (_u *UserUpdateOne) SetNillablePointsBalance(v *float64) *UserUpdateOne { + if v != nil { + _u.SetPointsBalance(*v) + } + return _u +} + +// AddPointsBalance adds value to the "points_balance" field. +func (_u *UserUpdateOne) AddPointsBalance(v float64) *UserUpdateOne { + _u.mutation.AddPointsBalance(v) + return _u +} + +// SetPreferPointsBilling sets the "prefer_points_billing" field. +func (_u *UserUpdateOne) SetPreferPointsBilling(v bool) *UserUpdateOne { + _u.mutation.SetPreferPointsBilling(v) + return _u +} + +// SetNillablePreferPointsBilling sets the "prefer_points_billing" field if the given value is not nil. +func (_u *UserUpdateOne) SetNillablePreferPointsBilling(v *bool) *UserUpdateOne { + if v != nil { + _u.SetPreferPointsBilling(*v) + } + return _u +} + // SetConcurrency sets the "concurrency" field. func (_u *UserUpdateOne) SetConcurrency(v int) *UserUpdateOne { _u.mutation.ResetConcurrency() @@ -3030,6 +3109,15 @@ func (_u *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) { if value, ok := _u.mutation.AddedBalance(); ok { _spec.AddField(user.FieldBalance, field.TypeFloat64, value) } + if value, ok := _u.mutation.PointsBalance(); ok { + _spec.SetField(user.FieldPointsBalance, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedPointsBalance(); ok { + _spec.AddField(user.FieldPointsBalance, field.TypeFloat64, value) + } + if value, ok := _u.mutation.PreferPointsBilling(); ok { + _spec.SetField(user.FieldPreferPointsBilling, field.TypeBool, value) + } if value, ok := _u.mutation.Concurrency(); ok { _spec.SetField(user.FieldConcurrency, field.TypeInt, value) } diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index 8906f30d2..7ee3353dc 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -46,6 +46,7 @@ const ( // Redeem type constants const ( RedeemTypeBalance = "balance" + RedeemTypePoints = "points" RedeemTypeConcurrency = "concurrency" RedeemTypeSubscription = "subscription" RedeemTypeInvitation = "invitation" @@ -60,6 +61,7 @@ const ( // Admin adjustment type constants const ( AdjustmentTypeAdminBalance = "admin_balance" // 管理员调整余额 + AdjustmentTypeAdminPoints = "admin_points" // 管理员调整积分 AdjustmentTypeAdminConcurrency = "admin_concurrency" // 管理员调整并发数 ) diff --git a/backend/internal/handler/admin/admin_basic_handlers_test.go b/backend/internal/handler/admin/admin_basic_handlers_test.go index ddeaab021..dabde604b 100644 --- a/backend/internal/handler/admin/admin_basic_handlers_test.go +++ b/backend/internal/handler/admin/admin_basic_handlers_test.go @@ -17,7 +17,7 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) { adminSvc := newStubAdminService() userHandler := NewUserHandler(adminSvc, nil) - groupHandler := NewGroupHandler(adminSvc, nil, nil) + groupHandler := NewGroupHandler(adminSvc, nil, nil, nil) proxyHandler := NewProxyHandler(adminSvc) redeemHandler := NewRedeemHandler(adminSvc, nil) diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index 98f4dc540..5df49e602 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -176,6 +176,11 @@ func (s *stubAdminService) UpdateUserBalance(ctx context.Context, userID int64, return &user, nil } +func (s *stubAdminService) UpdateUserPoints(ctx context.Context, userID int64, points float64, operation string, notes string, operatorUserID int64) (*service.User, error) { + user := service.User{ID: userID, PointsBalance: points, Status: service.StatusActive} + return &user, nil +} + func (s *stubAdminService) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]service.APIKey, int64, error) { return s.apiKeys, int64(len(s.apiKeys)), nil } diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 3662e3cd0..1cd798453 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -17,9 +17,10 @@ import ( // GroupHandler handles admin group management type GroupHandler struct { - adminService service.AdminService - dashboardService *service.DashboardService - groupCapacityService *service.GroupCapacityService + adminService service.AdminService + dashboardService *service.DashboardService + groupCapacityService *service.GroupCapacityService + groupRateScheduleService *service.GroupRateScheduleService } func parseAdminGroupScope(scope string, includePrivate bool) (string, error) { @@ -88,11 +89,12 @@ func (f optionalLimitField) ToServiceInput() *float64 { } // NewGroupHandler creates a new admin group handler -func NewGroupHandler(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService) *GroupHandler { +func NewGroupHandler(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService, groupRateScheduleService *service.GroupRateScheduleService) *GroupHandler { return &GroupHandler{ - adminService: adminService, - dashboardService: dashboardService, - groupCapacityService: groupCapacityService, + adminService: adminService, + dashboardService: dashboardService, + groupCapacityService: groupCapacityService, + groupRateScheduleService: groupRateScheduleService, } } @@ -501,6 +503,86 @@ type BatchSetGroupRateMultipliersRequest struct { Entries []service.GroupRateMultiplierInput `json:"entries" binding:"required"` } +// GroupRateScheduleRequest represents one group time-range multiplier rule. +type GroupRateScheduleRequest struct { + StartMinute int `json:"start_minute"` + EndMinute int `json:"end_minute"` + RateMultiplier float64 `json:"rate_multiplier"` + Enabled *bool `json:"enabled"` +} + +// ReplaceGroupRateSchedulesRequest represents replacing all rate schedules for a group. +type ReplaceGroupRateSchedulesRequest struct { + Entries []GroupRateScheduleRequest `json:"entries"` +} + +// GetGroupRateSchedules handles listing time-range rate schedules for a group. +// GET /api/v1/admin/groups/:id/rate-schedules +func (h *GroupHandler) GetGroupRateSchedules(c *gin.Context) { + groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid group ID") + return + } + if h.groupRateScheduleService == nil { + response.Success(c, []service.GroupRateSchedule{}) + return + } + + schedules, err := h.groupRateScheduleService.List(c.Request.Context(), groupID) + if err != nil { + response.ErrorFrom(c, err) + return + } + if schedules == nil { + schedules = []service.GroupRateSchedule{} + } + response.Success(c, schedules) +} + +// ReplaceGroupRateSchedules handles replacing time-range rate schedules for a group. +// PUT /api/v1/admin/groups/:id/rate-schedules +func (h *GroupHandler) ReplaceGroupRateSchedules(c *gin.Context) { + groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid group ID") + return + } + if h.groupRateScheduleService == nil { + response.Error(c, 500, "Group rate schedule service is not configured") + return + } + + var req ReplaceGroupRateSchedulesRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + inputs := make([]service.GroupRateScheduleInput, 0, len(req.Entries)) + for _, entry := range req.Entries { + enabled := true + if entry.Enabled != nil { + enabled = *entry.Enabled + } + inputs = append(inputs, service.GroupRateScheduleInput{ + StartMinute: entry.StartMinute, + EndMinute: entry.EndMinute, + RateMultiplier: entry.RateMultiplier, + Enabled: enabled, + }) + } + schedules, err := h.groupRateScheduleService.Replace(c.Request.Context(), groupID, inputs) + if err != nil { + response.ErrorFrom(c, err) + return + } + if schedules == nil { + schedules = []service.GroupRateSchedule{} + } + response.Success(c, schedules) +} + // BatchSetGroupRateMultipliers handles batch setting rate multipliers for a group // PUT /api/v1/admin/groups/:id/rate-multipliers func (h *GroupHandler) BatchSetGroupRateMultipliers(c *gin.Context) { diff --git a/backend/internal/handler/admin/redeem_handler.go b/backend/internal/handler/admin/redeem_handler.go index 24365f3da..a76e3566b 100644 --- a/backend/internal/handler/admin/redeem_handler.go +++ b/backend/internal/handler/admin/redeem_handler.go @@ -34,7 +34,7 @@ func NewRedeemHandler(adminService service.AdminService, redeemService *service. // GenerateRedeemCodesRequest represents generate redeem codes request type GenerateRedeemCodesRequest struct { Count int `json:"count" binding:"required,min=1,max=100"` - Type string `json:"type" binding:"required,oneof=balance concurrency subscription invitation"` + Type string `json:"type" binding:"required,oneof=balance points concurrency subscription invitation"` Value float64 `json:"value"` GroupID *int64 `json:"group_id"` // 订阅类型必填 ValidityDays int `json:"validity_days"` // 订阅类型使用,正数增加/负数退款扣减 @@ -44,7 +44,7 @@ type GenerateRedeemCodesRequest struct { // Type 为 omitempty 而非 required 是为了向后兼容旧版调用方(不传 type 时默认 balance)。 type CreateAndRedeemCodeRequest struct { Code string `json:"code" binding:"required,min=3,max=128"` - Type string `json:"type" binding:"omitempty,oneof=balance concurrency subscription invitation"` // 不传时默认 balance(向后兼容) + Type string `json:"type" binding:"omitempty,oneof=balance points concurrency subscription invitation"` // 不传时默认 balance(向后兼容) Value float64 `json:"value" binding:"required"` UserID int64 `json:"user_id" binding:"required,gt=0"` GroupID *int64 `json:"group_id"` // subscription 类型必填 @@ -157,6 +157,10 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) { return } } + if req.Type == "points" && req.Value <= 0 { + response.BadRequest(c, "points redeem code value must be greater than 0") + return + } executeAdminIdempotentJSON(c, "admin.redeem_codes.create_and_redeem", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) { existing, err := h.redeemService.GetByCode(ctx, req.Code) @@ -291,6 +295,7 @@ func (h *RedeemHandler) GetStats(c *gin.Context) { "total_value_distributed": 0.0, "by_type": gin.H{ "balance": 0, + "points": 0, "concurrency": 0, "trial": 0, }, diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index 3d80107fe..3fecff506 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -68,6 +68,13 @@ type UpdateBalanceRequest struct { Notes string `json:"notes"` } +// UpdatePointsRequest represents points update request. +type UpdatePointsRequest struct { + Points float64 `json:"points" binding:"required,gt=0"` + Operation string `json:"operation" binding:"required,oneof=set add subtract"` + Notes string `json:"notes"` +} + type BindUserAuthIdentityRequest struct { ProviderType string `json:"provider_type"` ProviderKey string `json:"provider_key"` @@ -341,6 +348,40 @@ func (h *UserHandler) UpdateBalance(c *gin.Context) { }) } +// UpdatePoints handles updating user points. +// POST /api/v1/admin/users/:id/points +func (h *UserHandler) UpdatePoints(c *gin.Context) { + userID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid user ID") + return + } + + var req UpdatePointsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + operatorUserID, _ := currentAdminUserID(c) + idempotencyPayload := struct { + UserID int64 `json:"user_id"` + OperatorUserID int64 `json:"operator_user_id"` + Body UpdatePointsRequest `json:"body"` + }{ + UserID: userID, + OperatorUserID: operatorUserID, + Body: req, + } + executeAdminIdempotentJSON(c, "admin.users.points.update", idempotencyPayload, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) { + user, execErr := h.adminService.UpdateUserPoints(ctx, userID, req.Points, req.Operation, req.Notes, operatorUserID) + if execErr != nil { + return nil, execErr + } + return dto.UserFromServiceAdmin(user), nil + }) +} + // GetUserAPIKeys handles getting user's API keys // GET /api/v1/admin/users/:id/api-keys func (h *UserHandler) GetUserAPIKeys(c *gin.Context) { diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 36591d557..6c1ea121d 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -18,6 +18,8 @@ func UserFromServiceShallow(u *service.User) *User { Username: u.Username, Role: u.Role, Balance: u.Balance, + PointsBalance: u.PointsBalance, + PreferPointsBilling: u.PreferPointsBilling, Concurrency: u.Concurrency, Status: u.Status, AllowedGroups: u.AllowedGroups, @@ -574,9 +576,9 @@ func redeemCodeFromServiceBase(rc *service.RedeemCode) RedeemCode { Group: GroupFromServiceShallow(rc.Group), } - // For admin_balance/admin_concurrency types, include notes so users can see + // For admin adjustment types, include notes so users can see // why they were charged or credited by admin - if (rc.Type == "admin_balance" || rc.Type == "admin_concurrency") && rc.Notes != "" { + if (rc.Type == "admin_balance" || rc.Type == "admin_points" || rc.Type == "admin_concurrency") && rc.Notes != "" { out.Notes = &rc.Notes } @@ -629,6 +631,9 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog { TotalCost: l.TotalCost, ActualCost: l.ActualCost, RateMultiplier: l.RateMultiplier, + PointsDeducted: l.PointsDeducted, + BalanceDeducted: l.BalanceDeducted, + BillingWalletType: l.BillingWalletType, BillingType: l.BillingType, RequestType: requestType.String(), Stream: stream, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 1e0b604c8..5a9f0b404 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -7,17 +7,19 @@ import ( ) type User struct { - ID int64 `json:"id"` - Email string `json:"email"` - Username string `json:"username"` - Role string `json:"role"` - Balance float64 `json:"balance"` - Concurrency int `json:"concurrency"` - Status string `json:"status"` - AllowedGroups []int64 `json:"allowed_groups"` - LastActiveAt *time.Time `json:"last_active_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + Email string `json:"email"` + Username string `json:"username"` + Role string `json:"role"` + Balance float64 `json:"balance"` + PointsBalance float64 `json:"points_balance"` + PreferPointsBilling bool `json:"prefer_points_billing"` + Concurrency int `json:"concurrency"` + Status string `json:"status"` + AllowedGroups []int64 `json:"allowed_groups"` + LastActiveAt *time.Time `json:"last_active_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // 余额不足通知 BalanceNotifyEnabled bool `json:"balance_notify_enabled"` @@ -413,6 +415,9 @@ type UsageLog struct { TotalCost float64 `json:"total_cost"` ActualCost float64 `json:"actual_cost"` RateMultiplier float64 `json:"rate_multiplier"` + PointsDeducted float64 `json:"points_deducted"` + BalanceDeducted float64 `json:"balance_deducted"` + BillingWalletType string `json:"billing_wallet_type"` BillingType int8 `json:"billing_type"` RequestType string `json:"request_type"` diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 731a8afd8..fb8a3032e 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -50,6 +50,7 @@ type ChangePasswordRequest struct { type UpdateProfileRequest struct { Username *string `json:"username"` AvatarURL *string `json:"avatar_url"` + PreferPointsBilling *bool `json:"prefer_points_billing"` BalanceNotifyEnabled *bool `json:"balance_notify_enabled"` BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"` } @@ -146,6 +147,7 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) { svcReq := service.UpdateProfileRequest{ Username: req.Username, AvatarURL: req.AvatarURL, + PreferPointsBilling: req.PreferPointsBilling, BalanceNotifyEnabled: req.BalanceNotifyEnabled, BalanceNotifyThreshold: req.BalanceNotifyThreshold, } diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index a5ae338ac..590e95ef9 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -850,7 +850,7 @@ func (r *accountRepository) listWithFilters(ctx context.Context, params paginati q = q.Where(dbaccount.NameContainsFold(search)) } if groupID == service.AccountListGroupUngrouped { - q = q.Where(dbaccount.Not(dbaccount.HasAccountGroups())) + q = q.Where(accountHasNoNonPrivateGroups()) } else if groupID > 0 { q = q.Where(dbaccount.HasAccountGroupsWith(dbaccountgroup.GroupIDEQ(groupID))) } @@ -942,6 +942,15 @@ func accountListOrder(params pagination.PaginationParams) []func(*entsql.Selecto return []func(*entsql.Selector){dbent.Asc(field), dbent.Asc(dbaccount.FieldID)} } +func accountHasNoNonPrivateGroups() dbpredicate.Account { + return dbaccount.Not(dbaccount.HasAccountGroupsWith( + dbaccountgroup.HasGroupWith( + dbgroup.DeletedAtIsNil(), + dbgroup.ScopeNEQ(service.GroupScopeUserPrivate), + ), + )) +} + func (r *accountRepository) ListByGroup(ctx context.Context, groupID int64) ([]service.Account, error) { accounts, err := r.queryAccountsByGroup(ctx, groupID, accountGroupQueryOptions{ status: service.StatusActive, diff --git a/backend/internal/repository/account_repo_integration_test.go b/backend/internal/repository/account_repo_integration_test.go index f522636cf..fd4d5e049 100644 --- a/backend/internal/repository/account_repo_integration_test.go +++ b/backend/internal/repository/account_repo_integration_test.go @@ -369,16 +369,22 @@ func (s *AccountRepoSuite) TestListWithFilters() { { name: "filter_by_ungrouped", setup: func(client *dbent.Client) { - group := mustCreateGroup(s.T(), client, &service.Group{Name: "g-ungrouped"}) + publicGroup := mustCreateGroup(s.T(), client, &service.Group{Name: "g-public", Scope: service.GroupScopePublic}) + privateGroup := mustCreateGroup(s.T(), client, &service.Group{Name: "g-private", Scope: service.GroupScopeUserPrivate}) grouped := mustCreateAccount(s.T(), client, &service.Account{Name: "grouped-account"}) + privateOnly := mustCreateAccount(s.T(), client, &service.Account{Name: "private-only-account"}) + mixed := mustCreateAccount(s.T(), client, &service.Account{Name: "mixed-account"}) mustCreateAccount(s.T(), client, &service.Account{Name: "ungrouped-account"}) - mustBindAccountToGroup(s.T(), client, grouped.ID, group.ID, 1) + mustBindAccountToGroup(s.T(), client, grouped.ID, publicGroup.ID, 1) + mustBindAccountToGroup(s.T(), client, privateOnly.ID, privateGroup.ID, 1) + mustBindAccountToGroup(s.T(), client, mixed.ID, privateGroup.ID, 1) + mustBindAccountToGroup(s.T(), client, mixed.ID, publicGroup.ID, 2) }, groupID: service.AccountListGroupUngrouped, - wantCount: 1, + wantCount: 2, validate: func(accounts []service.Account) { - s.Require().Equal("ungrouped-account", accounts[0].Name) - s.Require().Empty(accounts[0].GroupIDs) + names := []string{accounts[0].Name, accounts[1].Name} + s.Require().ElementsMatch([]string{"private-only-account", "ungrouped-account"}, names) }, }, { diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 744fc1f68..ef758892d 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -152,6 +152,8 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se user.FieldStatus, user.FieldRole, user.FieldBalance, + user.FieldPointsBalance, + user.FieldPreferPointsBilling, user.FieldConcurrency, user.FieldBalanceNotifyEnabled, user.FieldBalanceNotifyThresholdType, @@ -784,6 +786,8 @@ func userEntityToService(u *dbent.User) *service.User { PasswordHash: u.PasswordHash, Role: u.Role, Balance: u.Balance, + PointsBalance: u.PointsBalance, + PreferPointsBilling: u.PreferPointsBilling, Concurrency: u.Concurrency, Status: u.Status, SignupSource: u.SignupSource, diff --git a/backend/internal/repository/group_rate_schedule_repo.go b/backend/internal/repository/group_rate_schedule_repo.go new file mode 100644 index 000000000..c938b560f --- /dev/null +++ b/backend/internal/repository/group_rate_schedule_repo.go @@ -0,0 +1,301 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/lib/pq" +) + +const groupRateScheduleMultiplierEpsilon = 0.0000001 + +type groupRateScheduleRepository struct { + db *sql.DB + sql sqlExecutor +} + +func NewGroupRateScheduleRepository(sqlDB *sql.DB) service.GroupRateScheduleRepository { + return &groupRateScheduleRepository{db: sqlDB, sql: sqlDB} +} + +func (r *groupRateScheduleRepository) ListByGroupID(ctx context.Context, groupID int64) ([]service.GroupRateSchedule, error) { + rows, err := r.sql.QueryContext(ctx, ` + SELECT id, group_id, start_minute, end_minute, rate_multiplier, enabled, created_at, updated_at + FROM group_rate_schedules + WHERE group_id = $1 + ORDER BY start_minute, end_minute, id + `, groupID) + if err != nil { + return nil, err + } + return scanGroupRateSchedules(rows) +} + +func (r *groupRateScheduleRepository) ReplaceForGroup(ctx context.Context, groupID int64, schedules []service.GroupRateScheduleInput) ([]service.GroupRateSchedule, error) { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + + var existingGroupID int64 + if err := scanSingleRow(ctx, tx, ` + SELECT id + FROM groups + WHERE id = $1 AND deleted_at IS NULL + FOR UPDATE + `, []any{groupID}, &existingGroupID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, service.ErrGroupNotFound + } + return nil, err + } + + if _, err := tx.ExecContext(ctx, `DELETE FROM group_rate_schedules WHERE group_id = $1`, groupID); err != nil { + return nil, err + } + + if len(schedules) > 0 { + startMinutes := make([]int, len(schedules)) + endMinutes := make([]int, len(schedules)) + multipliers := make([]float64, len(schedules)) + enabled := make([]bool, len(schedules)) + for i, schedule := range schedules { + startMinutes[i] = schedule.StartMinute + endMinutes[i] = schedule.EndMinute + multipliers[i] = schedule.RateMultiplier + enabled[i] = schedule.Enabled + } + now := time.Now() + if _, err := tx.ExecContext(ctx, ` + INSERT INTO group_rate_schedules ( + group_id, start_minute, end_minute, rate_multiplier, enabled, created_at, updated_at + ) + SELECT + $1::bigint, + data.start_minute, + data.end_minute, + data.rate_multiplier, + data.enabled, + $2::timestamptz, + $2::timestamptz + FROM unnest($3::integer[], $4::integer[], $5::double precision[], $6::boolean[]) + AS data(start_minute, end_minute, rate_multiplier, enabled) + `, groupID, now, pq.Array(startMinutes), pq.Array(endMinutes), pq.Array(multipliers), pq.Array(enabled)); err != nil { + return nil, err + } + } + + rows, err := tx.QueryContext(ctx, ` + SELECT id, group_id, start_minute, end_minute, rate_multiplier, enabled, created_at, updated_at + FROM group_rate_schedules + WHERE group_id = $1 + ORDER BY start_minute, end_minute, id + `, groupID) + if err != nil { + return nil, err + } + out, err := scanGroupRateSchedules(rows) + if err != nil { + return nil, err + } + + if err := tx.Commit(); err != nil { + return nil, err + } + return out, nil +} + +func (r *groupRateScheduleRepository) ListEnabled(ctx context.Context) ([]service.GroupRateSchedule, error) { + rows, err := r.sql.QueryContext(ctx, ` + SELECT s.id, s.group_id, s.start_minute, s.end_minute, s.rate_multiplier, s.enabled, s.created_at, s.updated_at + FROM group_rate_schedules s + JOIN groups g ON g.id = s.group_id AND g.deleted_at IS NULL + WHERE s.enabled = TRUE + ORDER BY s.group_id, s.start_minute, s.end_minute, s.id + `) + if err != nil { + return nil, err + } + return scanGroupRateSchedules(rows) +} + +func (r *groupRateScheduleRepository) ListManagedGroupIDs(ctx context.Context) ([]int64, error) { + rows, err := r.sql.QueryContext(ctx, ` + SELECT group_id + FROM ( + SELECT DISTINCT s.group_id + FROM group_rate_schedules s + JOIN groups g ON g.id = s.group_id AND g.deleted_at IS NULL + WHERE s.enabled = TRUE + UNION + SELECT st.group_id + FROM group_rate_schedule_states st + JOIN groups g ON g.id = st.group_id AND g.deleted_at IS NULL + ) AS managed + ORDER BY group_id + `) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var groupIDs []int64 + for rows.Next() { + var groupID int64 + if err := rows.Scan(&groupID); err != nil { + return nil, err + } + groupIDs = append(groupIDs, groupID) + } + if err := rows.Err(); err != nil { + return nil, err + } + return groupIDs, nil +} + +func (r *groupRateScheduleRepository) ApplyScheduledMultiplier(ctx context.Context, groupID int64, scheduleID int64, rateMultiplier float64) (bool, error) { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return false, err + } + defer func() { _ = tx.Rollback() }() + + var currentMultiplier float64 + if err := scanSingleRow(ctx, tx, ` + SELECT rate_multiplier + FROM groups + WHERE id = $1 AND deleted_at IS NULL + FOR UPDATE + `, []any{groupID}, ¤tMultiplier); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, service.ErrGroupNotFound + } + return false, err + } + + now := time.Now() + if _, err := tx.ExecContext(ctx, ` + INSERT INTO group_rate_schedule_states ( + group_id, base_rate_multiplier, applied_schedule_id, created_at, updated_at + ) + VALUES ($1, $2, $3, $4, $4) + ON CONFLICT (group_id) + DO UPDATE SET + applied_schedule_id = EXCLUDED.applied_schedule_id, + updated_at = EXCLUDED.updated_at + `, groupID, currentMultiplier, scheduleID, now); err != nil { + return false, err + } + + changed := mathAbs(currentMultiplier-rateMultiplier) > groupRateScheduleMultiplierEpsilon + if changed { + if _, err := tx.ExecContext(ctx, ` + UPDATE groups + SET rate_multiplier = $2, updated_at = $3 + WHERE id = $1 AND deleted_at IS NULL + `, groupID, rateMultiplier, now); err != nil { + return false, err + } + if err := enqueueSchedulerOutbox(ctx, tx, service.SchedulerOutboxEventGroupChanged, nil, &groupID, nil); err != nil { + return false, err + } + } + + if err := tx.Commit(); err != nil { + return false, err + } + return changed, nil +} + +func (r *groupRateScheduleRepository) RestoreBaseMultiplier(ctx context.Context, groupID int64) (bool, error) { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return false, err + } + defer func() { _ = tx.Rollback() }() + + var baseMultiplier float64 + if err := scanSingleRow(ctx, tx, ` + SELECT base_rate_multiplier + FROM group_rate_schedule_states + WHERE group_id = $1 + FOR UPDATE + `, []any{groupID}, &baseMultiplier); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return false, err + } + + var currentMultiplier float64 + if err := scanSingleRow(ctx, tx, ` + SELECT rate_multiplier + FROM groups + WHERE id = $1 AND deleted_at IS NULL + FOR UPDATE + `, []any{groupID}, ¤tMultiplier); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, service.ErrGroupNotFound + } + return false, err + } + + now := time.Now() + changed := mathAbs(currentMultiplier-baseMultiplier) > groupRateScheduleMultiplierEpsilon + if changed { + if _, err := tx.ExecContext(ctx, ` + UPDATE groups + SET rate_multiplier = $2, updated_at = $3 + WHERE id = $1 AND deleted_at IS NULL + `, groupID, baseMultiplier, now); err != nil { + return false, err + } + if err := enqueueSchedulerOutbox(ctx, tx, service.SchedulerOutboxEventGroupChanged, nil, &groupID, nil); err != nil { + return false, err + } + } + + if _, err := tx.ExecContext(ctx, `DELETE FROM group_rate_schedule_states WHERE group_id = $1`, groupID); err != nil { + return false, err + } + if err := tx.Commit(); err != nil { + return false, err + } + return changed, nil +} + +func scanGroupRateSchedules(rows *sql.Rows) ([]service.GroupRateSchedule, error) { + defer func() { _ = rows.Close() }() + var out []service.GroupRateSchedule + for rows.Next() { + var schedule service.GroupRateSchedule + if err := rows.Scan( + &schedule.ID, + &schedule.GroupID, + &schedule.StartMinute, + &schedule.EndMinute, + &schedule.RateMultiplier, + &schedule.Enabled, + &schedule.CreatedAt, + &schedule.UpdatedAt, + ); err != nil { + return nil, err + } + out = append(out, schedule) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func mathAbs(v float64) float64 { + if v < 0 { + return -v + } + return v +} diff --git a/backend/internal/repository/usage_billing_repo.go b/backend/internal/repository/usage_billing_repo.go index b11381a03..2cc9eee26 100644 --- a/backend/internal/repository/usage_billing_repo.go +++ b/backend/internal/repository/usage_billing_repo.go @@ -124,26 +124,53 @@ func (r *usageBillingRepository) applyUsageBillingEffects(ctx context.Context, t } if cmd.BalanceCost > 0 { - newBalance, err := deductUsageBillingBalance(ctx, tx, cmd.UserID, cmd.BalanceCost) + newPointsBalance, newBalance, pointsDeducted, balanceDeducted, err := deductUsageBillingWallet(ctx, tx, cmd.UserID, cmd.BalanceCost, cmd.PreferPointsBilling) if err != nil { return err } - result.NewBalance = &newBalance - if err := insertUserBalanceLedger(ctx, tx, userBalanceLedgerInput{ - UserID: cmd.UserID, - Direction: "debit", - Amount: decimalFromFloat(cmd.BalanceCost), - Reason: "usage_charge", - RefType: "usage_log", - RefID: nullablePositiveInt64(usageLogID), - BalanceAfter: decimalFromFloat(newBalance), - Metadata: map[string]any{ - "request_id": cmd.RequestID, - "api_key_id": cmd.APIKeyID, - "account_id": cmd.AccountID, - }, - }); err != nil { - return err + if pointsDeducted > 0 { + result.NewPointsBalance = &newPointsBalance + result.PointsDeducted = pointsDeducted + if err := insertPointsLedger(ctx, tx, pointsLedgerInput{ + UserID: cmd.UserID, + Direction: "debit", + Amount: decimalFromFloat(pointsDeducted), + Reason: "usage_charge", + RefType: "usage_log", + RefID: nullablePositiveInt64(usageLogID), + BalanceBefore: decimalFromFloat(newPointsBalance + pointsDeducted), + BalanceAfter: decimalFromFloat(newPointsBalance), + Metadata: map[string]any{ + "request_id": cmd.RequestID, + "api_key_id": cmd.APIKeyID, + "account_id": cmd.AccountID, + "total_cost": cmd.BalanceCost, + }, + }); err != nil { + return err + } + } + if balanceDeducted > 0 { + result.NewBalance = &newBalance + result.BalanceDeducted = balanceDeducted + if err := insertUserBalanceLedger(ctx, tx, userBalanceLedgerInput{ + UserID: cmd.UserID, + Direction: "debit", + Amount: decimalFromFloat(balanceDeducted), + Reason: "usage_charge", + RefType: "usage_log", + RefID: nullablePositiveInt64(usageLogID), + BalanceAfter: decimalFromSignedFloat(newBalance), + Metadata: map[string]any{ + "request_id": cmd.RequestID, + "api_key_id": cmd.APIKeyID, + "account_id": cmd.AccountID, + "total_cost": cmd.BalanceCost, + "points_deducted": pointsDeducted, + }, + }); err != nil { + return err + } } } if cmd.PrivateGroupCommissionCost > 0 { @@ -152,6 +179,7 @@ func (r *usageBillingRepository) applyUsageBillingEffects(ctx context.Context, t return err } result.NewBalance = &newBalance + result.CommissionDeducted = cmd.PrivateGroupCommissionCost if err := insertUserBalanceLedger(ctx, tx, userBalanceLedgerInput{ UserID: cmd.UserID, Direction: "debit", @@ -311,6 +339,57 @@ func deductUsageBillingBalance(ctx context.Context, tx *sql.Tx, userID int64, am return newBalance, nil } +func deductUsageBillingWallet(ctx context.Context, tx *sql.Tx, userID int64, amount float64, preferPoints bool) (newPointsBalance float64, newBalance float64, pointsDeducted float64, balanceDeducted float64, err error) { + if amount <= 0 { + return 0, 0, 0, 0, nil + } + if !preferPoints { + newBalance, err = deductUsageBillingBalance(ctx, tx, userID, amount) + return 0, newBalance, 0, amount, err + } + + var currentBalance float64 + var currentPoints float64 + err = tx.QueryRowContext(ctx, ` + SELECT balance, points_balance + FROM users + WHERE id = $1 AND deleted_at IS NULL + FOR UPDATE + `, userID).Scan(¤tBalance, ¤tPoints) + if errors.Is(err, sql.ErrNoRows) { + return 0, 0, 0, 0, service.ErrUserNotFound + } + if err != nil { + return 0, 0, 0, 0, err + } + + pointsDeducted = amount + if currentPoints < pointsDeducted { + pointsDeducted = currentPoints + } + if pointsDeducted < 0 { + pointsDeducted = 0 + } + balanceDeducted = amount - pointsDeducted + if balanceDeducted < 0 { + balanceDeducted = 0 + } + + newPointsBalance = currentPoints - pointsDeducted + newBalance = currentBalance - balanceDeducted + _, err = tx.ExecContext(ctx, ` + UPDATE users + SET points_balance = $1::numeric, + balance = $2::numeric, + updated_at = NOW() + WHERE id = $3 AND deleted_at IS NULL + `, decimalFromFloat(newPointsBalance).StringFixed(10), decimalFromSignedFloat(newBalance).StringFixed(10), userID) + if err != nil { + return 0, 0, 0, 0, err + } + return newPointsBalance, newBalance, pointsDeducted, balanceDeducted, nil +} + func ensureUsageBillingLog(ctx context.Context, tx *sql.Tx, cmd *service.UsageBillingCommand) (int64, error) { if cmd == nil || cmd.UsageLog == nil { return 0, nil @@ -436,6 +515,47 @@ func insertUserBalanceLedger(ctx context.Context, tx *sql.Tx, in userBalanceLedg return err } +type pointsLedgerInput struct { + UserID int64 + Direction string + Amount decimal.Decimal + Reason string + RefType string + RefID any + BalanceBefore decimal.Decimal + BalanceAfter decimal.Decimal + OperatorUserID any + Metadata map[string]any +} + +func insertPointsLedger(ctx context.Context, tx *sql.Tx, in pointsLedgerInput) error { + if in.UserID <= 0 || in.Amount.IsNegative() { + return nil + } + metadata := in.Metadata + if metadata == nil { + metadata = map[string]any{} + } + rawMetadata, err := json.Marshal(metadata) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, ` + INSERT INTO points_ledger ( + user_id, direction, amount, reason, ref_type, ref_id, + balance_before, balance_after, operator_user_id, metadata + ) VALUES ( + $1, $2, $3::numeric, $4, $5, $6, + $7::numeric, $8::numeric, $9, $10::jsonb + ) + ON CONFLICT DO NOTHING + `, + in.UserID, in.Direction, in.Amount.StringFixed(10), in.Reason, in.RefType, in.RefID, + in.BalanceBefore.StringFixed(10), in.BalanceAfter.StringFixed(10), in.OperatorUserID, string(rawMetadata), + ) + return err +} + type accountShareSnapshot struct { OwnerUserID int64 ShareMode string @@ -998,6 +1118,10 @@ func decimalFromFloat(v float64) decimal.Decimal { return decimal.NewFromFloat(v).Round(10) } +func decimalFromSignedFloat(v float64) decimal.Decimal { + return decimal.NewFromFloat(v).Round(10) +} + func nullablePositiveInt64(v int64) any { if v <= 0 { return nil diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index 9de5a70f0..ff389ff42 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -1352,6 +1352,11 @@ func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *servic if err = rows.Err(); err != nil { return nil, err } + logs := []service.UsageLog{*log} + if err = r.hydrateUsageLogWalletDeductions(ctx, logs); err != nil { + return nil, err + } + *log = logs[0] return log, nil } @@ -4369,6 +4374,9 @@ func (r *usageLogRepository) listUsageLogsWithPagination(ctx context.Context, wh if err != nil { return nil, nil, err } + if err := r.hydrateUsageLogWalletDeductions(ctx, logs); err != nil { + return nil, nil, err + } return logs, paginationResultFromTotal(total, params), nil } @@ -4385,6 +4393,9 @@ func (r *usageLogRepository) listUsageLogsWithFastPagination(ctx context.Context if err != nil { return nil, nil, err } + if err := r.hydrateUsageLogWalletDeductions(ctx, logs); err != nil { + return nil, nil, err + } hasMore := false if len(logs) > limit { @@ -4450,6 +4461,106 @@ func (r *usageLogRepository) queryUsageLogs(ctx context.Context, query string, a return logs, nil } +func (r *usageLogRepository) hydrateUsageLogWalletDeductions(ctx context.Context, logs []service.UsageLog) error { + if len(logs) == 0 { + return nil + } + ids := make([]int64, 0, len(logs)) + byID := make(map[int64]*service.UsageLog, len(logs)) + for i := range logs { + if logs[i].ID <= 0 { + continue + } + ids = append(ids, logs[i].ID) + byID[logs[i].ID] = &logs[i] + } + if len(ids) == 0 { + return nil + } + if err := r.hydrateUsageLogPointsDeductions(ctx, byID, ids); err != nil { + return err + } + if err := r.hydrateUsageLogBalanceDeductions(ctx, byID, ids); err != nil { + return err + } + for i := range logs { + logs[i].BillingWalletType = usageLogWalletType(logs[i]) + } + return nil +} + +func (r *usageLogRepository) hydrateUsageLogPointsDeductions(ctx context.Context, byID map[int64]*service.UsageLog, ids []int64) error { + rows, err := r.sql.QueryContext(ctx, ` + SELECT ref_id, COALESCE(SUM(amount), 0)::double precision + FROM points_ledger + WHERE ref_type = 'usage_log' + AND reason = 'usage_charge' + AND direction = 'debit' + AND ref_id = ANY($1) + GROUP BY ref_id + `, pq.Array(ids)) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var id int64 + var amount float64 + if err := rows.Scan(&id, &amount); err != nil { + return err + } + if log := byID[id]; log != nil { + log.PointsDeducted = amount + } + } + return rows.Err() +} + +func (r *usageLogRepository) hydrateUsageLogBalanceDeductions(ctx context.Context, byID map[int64]*service.UsageLog, ids []int64) error { + rows, err := r.sql.QueryContext(ctx, ` + SELECT ref_id, COALESCE(SUM(amount), 0)::double precision + FROM user_balance_ledger + WHERE ref_type = 'usage_log' + AND reason = 'usage_charge' + AND direction = 'debit' + AND ref_id = ANY($1) + GROUP BY ref_id + `, pq.Array(ids)) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var id int64 + var amount float64 + if err := rows.Scan(&id, &amount); err != nil { + return err + } + if log := byID[id]; log != nil { + log.BalanceDeducted = amount + } + } + return rows.Err() +} + +func usageLogWalletType(log service.UsageLog) string { + if log.BillingType == service.BillingTypeSubscription { + return "subscription" + } + hasPoints := log.PointsDeducted > 0 + hasBalance := log.BalanceDeducted > 0 + switch { + case hasPoints && hasBalance: + return "mixed" + case hasPoints: + return "points" + case hasBalance: + return "balance" + default: + return "none" + } +} + func (r *usageLogRepository) hydrateUsageLogAssociations(ctx context.Context, logs []service.UsageLog) error { // 关联数据使用 Ent 批量加载,避免把复杂 SQL 继续膨胀。 if len(logs) == 0 { diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index d1f10cbdc..c6c9b37ef 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -3,6 +3,7 @@ package repository import ( "context" "database/sql" + "encoding/json" "errors" "fmt" "sort" @@ -88,6 +89,8 @@ func (r *userRepository) Create(ctx context.Context, userIn *service.User) error SetPasswordHash(userIn.PasswordHash). SetRole(userIn.Role). SetBalance(userIn.Balance). + SetPointsBalance(userIn.PointsBalance). + SetPreferPointsBilling(userIn.PreferPointsBilling). SetConcurrency(userIn.Concurrency). SetStatus(userIn.Status). SetSignupSource(userSignupSourceOrDefault(userIn.SignupSource)). @@ -214,6 +217,8 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error SetPasswordHash(userIn.PasswordHash). SetRole(userIn.Role). SetBalance(userIn.Balance). + SetPointsBalance(userIn.PointsBalance). + SetPreferPointsBilling(userIn.PreferPointsBilling). SetConcurrency(userIn.Concurrency). SetStatus(userIn.Status). SetBalanceNotifyEnabled(userIn.BalanceNotifyEnabled). @@ -533,6 +538,9 @@ func userListOrder(params pagination.PaginationParams) []func(*entsql.Selector) case "balance": field = dbuser.FieldBalance defaultField = false + case "points_balance": + field = dbuser.FieldPointsBalance + defaultField = false case "concurrency": field = dbuser.FieldConcurrency defaultField = false @@ -725,6 +733,163 @@ func (r *userRepository) DeductBalance(ctx context.Context, id int64, amount flo return nil } +func (r *userRepository) AdjustUsageBillingWallet(ctx context.Context, userID int64, amount float64, preferPoints bool, metadata map[string]any) (*service.UsageBillingApplyResult, error) { + if userID <= 0 { + return nil, service.ErrUserNotFound + } + if amount <= 0 { + return &service.UsageBillingApplyResult{Applied: true}, nil + } + + var tx *dbent.Tx + ownedTx := false + if existingTx := dbent.TxFromContext(ctx); existingTx != nil { + tx = existingTx + } else { + createdTx, err := r.client.Tx(ctx) + if err != nil { + return nil, err + } + tx = createdTx + ownedTx = true + defer func() { _ = tx.Rollback() }() + } + + exec := sqlExecutorFromEntClient(tx.Client()) + if exec == nil { + return nil, fmt.Errorf("sql executor is not configured") + } + + var currentBalance float64 + var currentPoints float64 + if err := scanSingleRow(ctx, exec, ` + SELECT balance, points_balance + FROM users + WHERE id = $1 AND deleted_at IS NULL + FOR UPDATE + `, []any{userID}, ¤tBalance, ¤tPoints); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, service.ErrUserNotFound + } + return nil, err + } + + pointsDeducted := 0.0 + balanceDeducted := 0.0 + newPointsBalance := currentPoints + newBalance := currentBalance + metadataJSON, err := usageBillingWalletMetadataJSON(metadata) + if err != nil { + return nil, err + } + + if preferPoints { + pointsDeducted = amount + if currentPoints < pointsDeducted { + pointsDeducted = currentPoints + } + if pointsDeducted < 0 { + pointsDeducted = 0 + } + balanceDeducted = amount - pointsDeducted + if balanceDeducted < 0 { + balanceDeducted = 0 + } + newPointsBalance = currentPoints - pointsDeducted + newBalance = currentBalance - balanceDeducted + if _, err := exec.ExecContext(ctx, ` + UPDATE users + SET points_balance = $1::numeric, + balance = $2::numeric, + updated_at = NOW() + WHERE id = $3 AND deleted_at IS NULL + `, decimalFromFloat(newPointsBalance).StringFixed(10), decimalFromSignedFloat(newBalance).StringFixed(10), userID); err != nil { + return nil, err + } + if pointsDeducted > 0 { + if _, err := exec.ExecContext(ctx, ` + INSERT INTO points_ledger ( + user_id, direction, amount, reason, ref_type, ref_id, + balance_before, balance_after, operator_user_id, metadata + ) VALUES ( + $1, $2, $3::numeric, $4, $5, $6, + $7::numeric, $8::numeric, $9, $10::jsonb + ) + ON CONFLICT DO NOTHING + `, userID, "debit", decimalFromFloat(pointsDeducted).StringFixed(10), "usage_charge", "usage_request", nil, + decimalFromFloat(currentPoints).StringFixed(10), + decimalFromFloat(newPointsBalance).StringFixed(10), nil, metadataJSON); err != nil { + return nil, err + } + } + if balanceDeducted > 0 { + if _, err := exec.ExecContext(ctx, ` + INSERT INTO user_balance_ledger ( + user_id, direction, amount, reason, ref_type, ref_id, balance_after, metadata + ) VALUES ( + $1, $2, $3::numeric, $4, $5, $6, $7::numeric, $8::jsonb + ) + ON CONFLICT DO NOTHING + `, userID, "debit", decimalFromFloat(balanceDeducted).StringFixed(10), "usage_charge", "usage_request", nil, + decimalFromSignedFloat(newBalance).StringFixed(10), metadataJSON); err != nil { + return nil, err + } + } + } else { + balanceDeducted = amount + newBalance = currentBalance - amount + if _, err := exec.ExecContext(ctx, ` + UPDATE users + SET balance = $1::numeric, + updated_at = NOW() + WHERE id = $2 AND deleted_at IS NULL + `, decimalFromSignedFloat(newBalance).StringFixed(10), userID); err != nil { + return nil, err + } + if _, err := exec.ExecContext(ctx, ` + INSERT INTO user_balance_ledger ( + user_id, direction, amount, reason, ref_type, ref_id, balance_after, metadata + ) VALUES ( + $1, $2, $3::numeric, $4, $5, $6, $7::numeric, $8::jsonb + ) + ON CONFLICT DO NOTHING + `, userID, "debit", decimalFromFloat(balanceDeducted).StringFixed(10), "usage_charge", "usage_request", nil, + decimalFromSignedFloat(newBalance).StringFixed(10), metadataJSON); err != nil { + return nil, err + } + } + + if ownedTx { + if err := tx.Commit(); err != nil { + return nil, err + } + } + + var newPointsPtr *float64 + if preferPoints { + newPointsPtr = &newPointsBalance + } + newBalancePtr := &newBalance + return &service.UsageBillingApplyResult{ + Applied: true, + NewBalance: newBalancePtr, + NewPointsBalance: newPointsPtr, + PointsDeducted: pointsDeducted, + BalanceDeducted: balanceDeducted, + }, nil +} + +func usageBillingWalletMetadataJSON(metadata map[string]any) (string, error) { + if metadata == nil { + return "{}", nil + } + raw, err := json.Marshal(metadata) + if err != nil { + return "", err + } + return string(raw), nil +} + func (r *userRepository) UpdateConcurrency(ctx context.Context, id int64, amount int) error { client := clientFromContext(ctx, r.client) n, err := client.User.Update().Where(dbuser.IDEQ(id)).AddConcurrency(amount).Save(ctx) diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 38a3b7a57..35a5318f3 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -92,6 +92,7 @@ var ProviderSet = wire.NewSet( NewUserAttributeDefinitionRepository, NewUserAttributeValueRepository, NewUserGroupRateRepository, + NewGroupRateScheduleRepository, NewErrorPassthroughRepository, NewTLSFingerprintProfileRepository, NewChannelRepository, diff --git a/backend/internal/server/middleware/api_key_auth.go b/backend/internal/server/middleware/api_key_auth.go index 5993cc3fa..8ad6ce240 100644 --- a/backend/internal/server/middleware/api_key_auth.go +++ b/backend/internal/server/middleware/api_key_auth.go @@ -196,7 +196,7 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti } } else { // 非订阅模式 或 订阅模式但 subscriptionService 未注入:回退到余额检查 - if apiKey.User.Balance <= 0 { + if !service.HasUsageBillingFunds(apiKey.User) { AbortWithError(c, 403, "INSUFFICIENT_BALANCE", "Insufficient account balance") return } diff --git a/backend/internal/server/middleware/api_key_auth_google.go b/backend/internal/server/middleware/api_key_auth_google.go index 8f953286b..f23c0673d 100644 --- a/backend/internal/server/middleware/api_key_auth_google.go +++ b/backend/internal/server/middleware/api_key_auth_google.go @@ -101,7 +101,7 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs subscriptionService.DoWindowMaintenance(&maintenanceCopy) } } else { - if apiKey.User.Balance <= 0 { + if !service.HasUsageBillingFunds(apiKey.User) { abortWithGoogleError(c, 403, "Insufficient account balance") return } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 0b4a98be1..d84c9ae23 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -321,6 +321,7 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) { users.PUT("/:id", h.Admin.User.Update) users.DELETE("/:id", h.Admin.User.Delete) users.POST("/:id/balance", h.Admin.User.UpdateBalance) + users.POST("/:id/points", h.Admin.User.UpdatePoints) users.GET("/:id/api-keys", h.Admin.User.GetUserAPIKeys) users.GET("/:id/usage", h.Admin.User.GetUserUsage) users.GET("/:id/balance-history", h.Admin.User.GetBalanceHistory) @@ -346,6 +347,8 @@ func registerGroupRoutes(admin *gin.RouterGroup, h *handler.Handlers) { groups.PUT("/:id", h.Admin.Group.Update) groups.DELETE("/:id", h.Admin.Group.Delete) groups.GET("/:id/stats", h.Admin.Group.GetStats) + groups.GET("/:id/rate-schedules", h.Admin.Group.GetGroupRateSchedules) + groups.PUT("/:id/rate-schedules", h.Admin.Group.ReplaceGroupRateSchedules) groups.GET("/:id/rate-multipliers", h.Admin.Group.GetGroupRateMultipliers) groups.PUT("/:id/rate-multipliers", h.Admin.Group.BatchSetGroupRateMultipliers) groups.DELETE("/:id/rate-multipliers", h.Admin.Group.ClearGroupRateMultipliers) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 88165d59a..88957f965 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -32,6 +32,7 @@ type AdminService interface { UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*User, error) DeleteUser(ctx context.Context, id int64) error UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) + UpdateUserPoints(ctx context.Context, userID int64, points float64, operation string, notes string, operatorUserID int64) (*User, error) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]APIKey, int64, error) GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error) GetUserRPMStatus(ctx context.Context, userID int64) (*UserRPMStatus, error) @@ -921,6 +922,102 @@ func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, return user, nil } +func (s *adminServiceImpl) UpdateUserPoints(ctx context.Context, userID int64, points float64, operation string, notes string, operatorUserID int64) (*User, error) { + if points <= 0 { + return nil, infraerrors.BadRequest("POINTS_AMOUNT_INVALID", "points amount must be greater than 0") + } + + var delta float64 + switch operation { + case "set": + case "add": + delta = points + case "subtract": + delta = -points + default: + return nil, infraerrors.BadRequest("POINTS_OPERATION_INVALID", "invalid points operation") + } + + tx, err := s.entClient.Tx(ctx) + if err != nil { + return nil, fmt.Errorf("begin points adjustment transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + txCtx := dbent.NewTxContext(ctx, tx) + + if operation == "set" { + currentPoints, err := currentPointsBalanceInTx(txCtx, tx, userID) + if err != nil { + return nil, fmt.Errorf("lock user points: %w", err) + } + delta = points - currentPoints + } + if delta == 0 { + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit noop points adjustment transaction: %w", err) + } + updated, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + return nil, err + } + return updated, nil + } + + code, err := GenerateRedeemCode() + if err != nil { + return nil, fmt.Errorf("generate points adjustment code: %w", err) + } + now := time.Now() + adjustmentRecord := &RedeemCode{ + Code: code, + Type: AdjustmentTypeAdminPoints, + Value: delta, + Status: StatusUsed, + UsedBy: &userID, + UsedAt: &now, + Notes: notes, + } + if err := s.redeemCodeRepo.Create(txCtx, adjustmentRecord); err != nil { + return nil, fmt.Errorf("create points adjustment redeem code: %w", err) + } + if err := applyPointsAdjustmentInTx(txCtx, tx, pointsAdjustmentInput{ + UserID: userID, + Delta: delta, + Reason: "admin_adjustment", + RefType: "redeem_code", + RefID: adjustmentRecord.ID, + OperatorUserID: operatorUserID, + Metadata: map[string]any{ + "operation": operation, + "notes": notes, + }, + }); err != nil { + return nil, fmt.Errorf("update user points: %w", err) + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit points adjustment transaction: %w", err) + } + + if s.authCacheInvalidator != nil { + s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID) + } + if s.billingCacheService != nil { + go func() { + cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := s.billingCacheService.InvalidateUserBalance(cacheCtx, userID); err != nil { + logger.LegacyPrintf("service.admin", "invalidate user balance cache after points update failed: user_id=%d err=%v", userID, err) + } + }() + } + + updated, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + return nil, err + } + return updated, nil +} + func (s *adminServiceImpl) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]APIKey, int64, error) { params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} keys, result, err := s.apiKeyRepo.ListByUserID(ctx, userID, params, APIKeyListFilters{}) @@ -2969,6 +3066,9 @@ func (s *adminServiceImpl) GenerateRedeemCodes(ctx context.Context, input *Gener return nil, errors.New("group must be subscription type") } } + if err := validateRedeemCodeValue(input.Type, input.Value); err != nil { + return nil, err + } codes := make([]RedeemCode, 0, input.Count) for i := 0; i < input.Count; i++ { diff --git a/backend/internal/service/admin_service_update_balance_test.go b/backend/internal/service/admin_service_update_balance_test.go index d3b3c7007..fc616184c 100644 --- a/backend/internal/service/admin_service_update_balance_test.go +++ b/backend/internal/service/admin_service_update_balance_test.go @@ -95,3 +95,21 @@ func TestAdminService_UpdateUserBalance_NoChangeNoInvalidate(t *testing.T) { require.Empty(t, invalidator.userIDs) require.Empty(t, redeemRepo.created) } + +func TestRedeemService_GenerateCodesRejectsNonPositivePointsValue(t *testing.T) { + svc := &RedeemService{redeemRepo: &redeemRepoStub{}} + + _, err := svc.GenerateCodes(context.Background(), GenerateCodesRequest{ + Count: 1, + Type: RedeemTypePoints, + Value: -1, + }) + require.Error(t, err) + + _, err = svc.GenerateCodes(context.Background(), GenerateCodesRequest{ + Count: 1, + Type: RedeemTypePoints, + Value: 0, + }) + require.Error(t, err) +} diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index 1f496636c..997ff4ef0 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -41,11 +41,13 @@ type APIKeyAuthGroupRouteSnapshot struct { // APIKeyAuthUserSnapshot 用户快照 type APIKeyAuthUserSnapshot struct { - ID int64 `json:"id"` - Status string `json:"status"` - Role string `json:"role"` - Balance float64 `json:"balance"` - Concurrency int `json:"concurrency"` + ID int64 `json:"id"` + Status string `json:"status"` + Role string `json:"role"` + Balance float64 `json:"balance"` + PointsBalance float64 `json:"points_balance"` + PreferPointsBilling bool `json:"prefer_points_billing"` + Concurrency int `json:"concurrency"` // Balance notification fields (required for CheckBalanceAfterDeduction) Email string `json:"email"` diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index 3ecd488e3..59063d1dc 100644 --- a/backend/internal/service/api_key_auth_cache_impl.go +++ b/backend/internal/service/api_key_auth_cache_impl.go @@ -14,7 +14,7 @@ import ( "github.com/dgraph-io/ristretto" ) -const apiKeyAuthSnapshotVersion = 10 // v10: require group scope for private-group billing decisions +const apiKeyAuthSnapshotVersion = 11 // v11: include user points fields for points billing eligibility type apiKeyAuthCacheConfig struct { l1Size int @@ -224,6 +224,8 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) Status: apiKey.User.Status, Role: apiKey.User.Role, Balance: apiKey.User.Balance, + PointsBalance: apiKey.User.PointsBalance, + PreferPointsBilling: apiKey.User.PreferPointsBilling, Concurrency: apiKey.User.Concurrency, Email: apiKey.User.Email, Username: apiKey.User.Username, @@ -318,6 +320,8 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho Status: snapshot.User.Status, Role: snapshot.User.Role, Balance: snapshot.User.Balance, + PointsBalance: snapshot.User.PointsBalance, + PreferPointsBilling: snapshot.User.PreferPointsBilling, Concurrency: snapshot.User.Concurrency, Email: snapshot.User.Email, Username: snapshot.User.Username, diff --git a/backend/internal/service/billing_cache_service.go b/backend/internal/service/billing_cache_service.go index b383d903b..1812f651e 100644 --- a/backend/internal/service/billing_cache_service.go +++ b/backend/internal/service/billing_cache_service.go @@ -679,12 +679,12 @@ func (s *BillingCacheService) CheckBillingEligibility(ctx context.Context, user return err } if group.IsUserPrivateScope() { - if err := s.checkBalanceEligibility(ctx, user.ID); err != nil { + if err := s.checkBalanceOrPointsEligibility(ctx, user); err != nil { return err } } } else { - if err := s.checkBalanceEligibility(ctx, user.ID); err != nil { + if err := s.checkBalanceOrPointsEligibility(ctx, user); err != nil { return err } } @@ -809,6 +809,18 @@ func (s *BillingCacheService) checkBalanceEligibility(ctx context.Context, userI return nil } +// checkBalanceOrPointsEligibility allows requests when either withdrawable balance +// or user-enabled points can pay for the next usage request. +func (s *BillingCacheService) checkBalanceOrPointsEligibility(ctx context.Context, user *User) error { + if user == nil { + return ErrInsufficientBalance + } + if CanUsePointsForUsage(user) { + return nil + } + return s.checkBalanceEligibility(ctx, user.ID) +} + // checkSubscriptionEligibility 检查订阅模式资格 func (s *BillingCacheService) checkSubscriptionEligibility(ctx context.Context, userID int64, group *Group, subscription *UserSubscription) error { // 获取订阅缓存数据 diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 0981342cf..92cbd6241 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -57,6 +57,7 @@ const ( // Redeem type constants const ( RedeemTypeBalance = domain.RedeemTypeBalance + RedeemTypePoints = domain.RedeemTypePoints RedeemTypeConcurrency = domain.RedeemTypeConcurrency RedeemTypeSubscription = domain.RedeemTypeSubscription RedeemTypeInvitation = domain.RedeemTypeInvitation @@ -71,6 +72,7 @@ const ( // Admin adjustment type constants const ( AdjustmentTypeAdminBalance = domain.AdjustmentTypeAdminBalance // 管理员调整余额 + AdjustmentTypeAdminPoints = domain.AdjustmentTypeAdminPoints // 管理员调整积分 AdjustmentTypeAdminConcurrency = domain.AdjustmentTypeAdminConcurrency // 管理员调整并发数 ) diff --git a/backend/internal/service/gateway_record_usage_test.go b/backend/internal/service/gateway_record_usage_test.go index f18a98209..81bed9c0c 100644 --- a/backend/internal/service/gateway_record_usage_test.go +++ b/backend/internal/service/gateway_record_usage_test.go @@ -116,6 +116,48 @@ func TestGatewayServiceRecordUsage_BillingUsesDetachedContext(t *testing.T) { require.NoError(t, quotaSvc.lastQuotaCtxErr) } +func TestGatewayServiceRecordUsage_LegacyBillingHonorsPreferPoints(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: false, err: context.DeadlineExceeded} + userRepo := &openAIRecordUsageWalletRepoStub{} + subRepo := &openAIRecordUsageSubRepoStub{} + quotaSvc := &openAIRecordUsageAPIKeyQuotaStub{} + billingCache := &openAIRecordUsageBillingCacheStub{} + svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, subRepo) + svc.billingCacheService = NewBillingCacheService(billingCache, nil, nil, nil, nil, nil, &config.Config{}) + t.Cleanup(svc.billingCacheService.Stop) + + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gateway_legacy_points", + Usage: ClaudeUsage{ + InputTokens: 10, + OutputTokens: 6, + }, + Model: "claude-sonnet-4", + Duration: time.Second, + }, + APIKey: &APIKey{ID: 501}, + User: &User{ + ID: 601, + PointsBalance: 10, + PreferPointsBilling: true, + }, + Account: &Account{ID: 701}, + APIKeyService: quotaSvc, + }) + + require.NoError(t, err) + require.Equal(t, 1, userRepo.walletCalls) + require.True(t, userRepo.lastPreferPoints) + require.Greater(t, userRepo.lastWalletAmount, 0.0) + require.Equal(t, 0, userRepo.deductCalls) + require.NoError(t, userRepo.lastWalletCtxErr) + require.Equal(t, 1, billingCache.invalidateCalls) + require.Equal(t, int64(601), billingCache.lastInvalidatedUser) + require.Equal(t, 1, quotaSvc.authInvalidateCalls) + require.Equal(t, int64(601), quotaSvc.lastAuthUserID) +} + func TestGatewayServiceRecordUsage_BillingFingerprintIncludesRequestPayloadHash(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{} billingRepo := &openAIRecordUsageBillingRepoStub{result: &UsageBillingApplyResult{Applied: true}} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 05aec68df..b18e8720e 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -7916,6 +7916,10 @@ type apiKeyAuthCacheInvalidator interface { InvalidateAuthCacheByKey(ctx context.Context, key string) } +type apiKeyAuthCacheUserInvalidator interface { + InvalidateAuthCacheByUserID(ctx context.Context, userID int64) +} + type usageLogBestEffortWriter interface { CreateBestEffort(ctx context.Context, log *UsageLog) error } @@ -7969,7 +7973,25 @@ func postUsageBilling(ctx context.Context, p *postUsageBillingParams, deps *bill } } else { if cost.ActualCost > 0 { - if err := deps.userRepo.DeductBalance(billingCtx, p.User.ID, cost.ActualCost); err != nil { + if adjuster, ok := deps.userRepo.(usageBillingWalletAdjuster); ok { + result, err := adjuster.AdjustUsageBillingWallet( + billingCtx, + p.User.ID, + cost.ActualCost, + p.User.PreferPointsBilling, + map[string]any{ + "request_id": p.RequestPayloadHash, + "api_key_id": p.APIKey.ID, + "account_id": p.Account.ID, + "total_cost": cost.ActualCost, + }, + ) + if err != nil { + slog.Error("adjust usage billing wallet failed", "user_id", p.User.ID, "error", err) + } else { + finalizeLegacyUsageBillingWallet(p, deps, result) + } + } else if err := deps.userRepo.DeductBalance(billingCtx, p.User.ID, cost.ActualCost); err != nil { slog.Error("deduct balance failed", "user_id", p.User.ID, "error", err) } } @@ -8000,6 +8022,35 @@ func postUsageBilling(ctx context.Context, p *postUsageBillingParams, deps *bill // by the caller after recording the usage log. } +func finalizeLegacyUsageBillingWallet(p *postUsageBillingParams, deps *billingDeps, result *UsageBillingApplyResult) { + if p == nil || deps == nil || result == nil || p.User == nil { + return + } + + if result.PointsDeducted > 0 { + if invalidator, ok := p.APIKeyService.(apiKeyAuthCacheUserInvalidator); ok { + invalidator.InvalidateAuthCacheByUserID(context.Background(), p.User.ID) + } + if deps.billingCacheService != nil { + _ = deps.billingCacheService.InvalidateUserBalance(context.Background(), p.User.ID) + } + return + } + + if result.BalanceDeducted > 0 { + if deps.billingCacheService != nil { + _ = deps.billingCacheService.InvalidateUserBalance(context.Background(), p.User.ID) + } + return + } + + if result.CommissionDeducted > 0 { + if deps.billingCacheService != nil { + _ = deps.billingCacheService.InvalidateUserBalance(context.Background(), p.User.ID) + } + } +} + func resolveUsageBillingRequestID(ctx context.Context, upstreamRequestID string) string { if ctx != nil { if clientRequestID, _ := ctx.Value(ctxkey.ClientRequestID).(string); strings.TrimSpace(clientRequestID) != "" { @@ -8082,6 +8133,7 @@ func buildUsageBillingCommand(requestID string, usageLog *UsageLog, p *postUsage } } else if p.Cost.ActualCost > 0 { cmd.BalanceCost = p.Cost.ActualCost + cmd.PreferPointsBilling = p.User.PreferPointsBilling } if p.shouldDeductAPIKeyQuota() { @@ -8212,11 +8264,20 @@ func finalizePostUsageBilling(p *postUsageBillingParams, deps *billingDeps, resu if p.Cost.ActualCost > 0 && p.User != nil && p.APIKey != nil && p.APIKey.GroupID != nil { deps.billingCacheService.QueueUpdateSubscriptionUsage(p.User.ID, *p.APIKey.GroupID, p.Cost.ActualCost) } - if result != nil && result.NewBalance != nil && p.User != nil { - deps.billingCacheService.QueueDeductBalance(p.User.ID, calculatePrivateGroupCommissionCost(p)) + if result != nil && result.CommissionDeducted > 0 && p.User != nil { + deps.billingCacheService.QueueDeductBalance(p.User.ID, result.CommissionDeducted) } } else if p.Cost.ActualCost > 0 && p.User != nil { - deps.billingCacheService.QueueDeductBalance(p.User.ID, p.Cost.ActualCost) + if result != nil && result.PointsDeducted > 0 { + if invalidator, ok := p.APIKeyService.(apiKeyAuthCacheUserInvalidator); ok { + invalidator.InvalidateAuthCacheByUserID(context.Background(), p.User.ID) + } + _ = deps.billingCacheService.InvalidateUserBalance(context.Background(), p.User.ID) + } else if result != nil && result.BalanceDeducted > 0 { + deps.billingCacheService.QueueDeductBalance(p.User.ID, result.BalanceDeducted) + } else { + deps.billingCacheService.QueueDeductBalance(p.User.ID, p.Cost.ActualCost) + } } if result != nil { @@ -8250,7 +8311,13 @@ func notifyBalanceLow(p *postUsageBillingParams, deps *billingDeps, result *Usag }() deductedCost := p.Cost.ActualCost if p.IsSubscriptionBill { - deductedCost = calculatePrivateGroupCommissionCost(p) + if result != nil && result.CommissionDeducted > 0 { + deductedCost = result.CommissionDeducted + } else { + deductedCost = calculatePrivateGroupCommissionCost(p) + } + } else if result != nil { + deductedCost = result.BalanceDeducted } if deductedCost <= 0 || p.User == nil || deps.balanceNotifyService == nil { slog.Debug("notifyBalanceLow: skipped", @@ -8277,8 +8344,11 @@ func notifyBalanceLow(p *postUsageBillingParams, deps *billingDeps, result *Usag // resolveOldBalance returns the pre-deduction balance. // Prefers the DB transaction result (newBalance + cost) over snapshot. func resolveOldBalance(p *postUsageBillingParams, result *UsageBillingApplyResult) float64 { - if result != nil && result.NewBalance != nil { - return *result.NewBalance + p.Cost.ActualCost + if result != nil && result.NewBalance != nil && result.BalanceDeducted > 0 { + return *result.NewBalance + result.BalanceDeducted + } + if result != nil && result.NewBalance != nil && result.CommissionDeducted > 0 { + return *result.NewBalance + result.CommissionDeducted } // Legacy fallback: snapshot balance from request context return p.User.Balance @@ -8344,6 +8414,10 @@ type billingDeps struct { balanceNotifyService *BalanceNotifyService } +type usageBillingWalletAdjuster interface { + AdjustUsageBillingWallet(ctx context.Context, userID int64, amount float64, preferPoints bool, metadata map[string]any) (*UsageBillingApplyResult, error) +} + func (s *GatewayService) billingDeps() *billingDeps { return &billingDeps{ accountRepo: s.accountRepo, diff --git a/backend/internal/service/group_rate_schedule.go b/backend/internal/service/group_rate_schedule.go new file mode 100644 index 000000000..e09629071 --- /dev/null +++ b/backend/internal/service/group_rate_schedule.go @@ -0,0 +1,270 @@ +package service + +import ( + "context" + "fmt" + "math" + "sort" + "sync" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" +) + +const ( + defaultGroupRateScheduleInterval = time.Minute + groupRateScheduleApplyTimeout = 30 * time.Second +) + +type GroupRateSchedule struct { + ID int64 `json:"id"` + GroupID int64 `json:"group_id"` + StartMinute int `json:"start_minute"` + EndMinute int `json:"end_minute"` + RateMultiplier float64 `json:"rate_multiplier"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type GroupRateScheduleInput struct { + StartMinute int + EndMinute int + RateMultiplier float64 + Enabled bool +} + +type GroupRateScheduleRepository interface { + ListByGroupID(ctx context.Context, groupID int64) ([]GroupRateSchedule, error) + ReplaceForGroup(ctx context.Context, groupID int64, schedules []GroupRateScheduleInput) ([]GroupRateSchedule, error) + ListEnabled(ctx context.Context) ([]GroupRateSchedule, error) + ListManagedGroupIDs(ctx context.Context) ([]int64, error) + ApplyScheduledMultiplier(ctx context.Context, groupID int64, scheduleID int64, rateMultiplier float64) (bool, error) + RestoreBaseMultiplier(ctx context.Context, groupID int64) (bool, error) +} + +type GroupRateScheduleService struct { + repo GroupRateScheduleRepository + groupRepo GroupRepository + authCacheInvalidator APIKeyAuthCacheInvalidator + interval time.Duration + applyMu sync.Mutex + startOnce sync.Once + stopOnce sync.Once + stopCh chan struct{} + doneCh chan struct{} +} + +func NewGroupRateScheduleService( + repo GroupRateScheduleRepository, + groupRepo GroupRepository, + authCacheInvalidator APIKeyAuthCacheInvalidator, + interval time.Duration, +) *GroupRateScheduleService { + if interval <= 0 { + interval = defaultGroupRateScheduleInterval + } + return &GroupRateScheduleService{ + repo: repo, + groupRepo: groupRepo, + authCacheInvalidator: authCacheInvalidator, + interval: interval, + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + } +} + +func (s *GroupRateScheduleService) Start() { + if s == nil { + return + } + s.startOnce.Do(func() { + go s.run() + }) +} + +func (s *GroupRateScheduleService) Stop() { + if s == nil { + return + } + s.stopOnce.Do(func() { + close(s.stopCh) + s.startOnce.Do(func() { + close(s.doneCh) + }) + <-s.doneCh + }) +} + +func (s *GroupRateScheduleService) run() { + defer close(s.doneCh) + s.applyWithTimeout() + + ticker := time.NewTicker(s.interval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + s.applyWithTimeout() + case <-s.stopCh: + return + } + } +} + +func (s *GroupRateScheduleService) applyWithTimeout() { + ctx, cancel := context.WithTimeout(context.Background(), groupRateScheduleApplyTimeout) + defer cancel() + if err := s.ApplyOnce(ctx); err != nil { + logger.LegacyPrintf("service.group_rate_schedule", "apply schedules failed: %v", err) + } +} + +func (s *GroupRateScheduleService) List(ctx context.Context, groupID int64) ([]GroupRateSchedule, error) { + if groupID <= 0 { + return nil, infraerrors.BadRequest("INVALID_GROUP_ID", "group_id must be greater than 0") + } + if _, err := s.groupRepo.GetByIDLite(ctx, groupID); err != nil { + return nil, err + } + return s.repo.ListByGroupID(ctx, groupID) +} + +func (s *GroupRateScheduleService) Replace(ctx context.Context, groupID int64, schedules []GroupRateScheduleInput) ([]GroupRateSchedule, error) { + if groupID <= 0 { + return nil, infraerrors.BadRequest("INVALID_GROUP_ID", "group_id must be greater than 0") + } + if _, err := s.groupRepo.GetByIDLite(ctx, groupID); err != nil { + return nil, err + } + if err := validateGroupRateSchedules(schedules); err != nil { + return nil, err + } + + updated, err := s.repo.ReplaceForGroup(ctx, groupID, schedules) + if err != nil { + return nil, err + } + if err := s.ApplyGroup(ctx, groupID); err != nil { + return nil, err + } + return updated, nil +} + +func (s *GroupRateScheduleService) ApplyOnce(ctx context.Context) error { + if s == nil || s.repo == nil { + return nil + } + s.applyMu.Lock() + defer s.applyMu.Unlock() + + enabledSchedules, err := s.repo.ListEnabled(ctx) + if err != nil { + return err + } + managedGroupIDs, err := s.repo.ListManagedGroupIDs(ctx) + if err != nil { + return err + } + schedulesByGroup := make(map[int64][]GroupRateSchedule) + for _, schedule := range enabledSchedules { + schedulesByGroup[schedule.GroupID] = append(schedulesByGroup[schedule.GroupID], schedule) + } + currentMinute := currentScheduleMinute() + for _, groupID := range managedGroupIDs { + if err := s.applyGroupLocked(ctx, groupID, schedulesByGroup[groupID], currentMinute); err != nil { + logger.LegacyPrintf("service.group_rate_schedule", "apply group schedule failed: group=%d err=%v", groupID, err) + } + } + return nil +} + +func (s *GroupRateScheduleService) ApplyGroup(ctx context.Context, groupID int64) error { + if s == nil || s.repo == nil { + return nil + } + s.applyMu.Lock() + defer s.applyMu.Unlock() + + schedules, err := s.repo.ListByGroupID(ctx, groupID) + if err != nil { + return err + } + enabled := make([]GroupRateSchedule, 0, len(schedules)) + for _, schedule := range schedules { + if schedule.Enabled { + enabled = append(enabled, schedule) + } + } + return s.applyGroupLocked(ctx, groupID, enabled, currentScheduleMinute()) +} + +func (s *GroupRateScheduleService) applyGroupLocked(ctx context.Context, groupID int64, schedules []GroupRateSchedule, currentMinute int) error { + active := findActiveGroupRateSchedule(schedules, currentMinute) + var changed bool + var err error + if active == nil { + changed, err = s.repo.RestoreBaseMultiplier(ctx, groupID) + } else { + changed, err = s.repo.ApplyScheduledMultiplier(ctx, groupID, active.ID, active.RateMultiplier) + } + if err != nil { + return err + } + if changed && s.authCacheInvalidator != nil { + s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, groupID) + } + return nil +} + +func validateGroupRateSchedules(schedules []GroupRateScheduleInput) error { + for i := range schedules { + schedule := schedules[i] + if schedule.StartMinute < 0 || schedule.StartMinute >= 1440 { + return infraerrors.BadRequest("INVALID_RATE_SCHEDULE", fmt.Sprintf("start_minute must be between 0 and 1439 (index=%d)", i)) + } + if schedule.EndMinute <= 0 || schedule.EndMinute > 1440 { + return infraerrors.BadRequest("INVALID_RATE_SCHEDULE", fmt.Sprintf("end_minute must be between 1 and 1440 (index=%d)", i)) + } + if schedule.EndMinute <= schedule.StartMinute { + return infraerrors.BadRequest("INVALID_RATE_SCHEDULE", fmt.Sprintf("end_minute must be greater than start_minute (index=%d)", i)) + } + if schedule.RateMultiplier <= 0 || math.IsNaN(schedule.RateMultiplier) || math.IsInf(schedule.RateMultiplier, 0) { + return infraerrors.BadRequest("INVALID_RATE_SCHEDULE", fmt.Sprintf("rate_multiplier must be greater than 0 (index=%d)", i)) + } + } + + ordered := append([]GroupRateScheduleInput(nil), schedules...) + sort.SliceStable(ordered, func(i, j int) bool { + if ordered[i].StartMinute == ordered[j].StartMinute { + return ordered[i].EndMinute < ordered[j].EndMinute + } + return ordered[i].StartMinute < ordered[j].StartMinute + }) + for i := 1; i < len(ordered); i++ { + if ordered[i].StartMinute < ordered[i-1].EndMinute { + return infraerrors.Conflict("RATE_SCHEDULE_OVERLAP", "schedule time ranges cannot overlap") + } + } + return nil +} + +func findActiveGroupRateSchedule(schedules []GroupRateSchedule, currentMinute int) *GroupRateSchedule { + for i := range schedules { + schedule := schedules[i] + if !schedule.Enabled { + continue + } + if currentMinute >= schedule.StartMinute && currentMinute < schedule.EndMinute { + return &schedule + } + } + return nil +} + +func currentScheduleMinute() int { + now := timezone.Now() + return now.Hour()*60 + now.Minute() +} diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index 0a3f01b68..9c7b03b30 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -68,6 +68,82 @@ func (s *openAIRecordUsageUserRepoStub) DeductBalance(ctx context.Context, id in return s.deductErr } +type openAIRecordUsageWalletRepoStub struct { + openAIRecordUsageUserRepoStub + + walletCalls int + lastPreferPoints bool + lastWalletAmount float64 + lastWalletCtxErr error + lastWalletMetadata map[string]any +} + +func (s *openAIRecordUsageWalletRepoStub) AdjustUsageBillingWallet(ctx context.Context, userID int64, amount float64, preferPoints bool, metadata map[string]any) (*UsageBillingApplyResult, error) { + s.walletCalls++ + s.lastPreferPoints = preferPoints + s.lastWalletAmount = amount + s.lastWalletCtxErr = ctx.Err() + s.lastWalletMetadata = metadata + return &UsageBillingApplyResult{Applied: true, PointsDeducted: amount}, nil +} + +type openAIRecordUsageBillingCacheStub struct { + BillingCache + + invalidateCalls int + lastInvalidatedUser int64 +} + +func (s *openAIRecordUsageBillingCacheStub) GetUserBalance(ctx context.Context, userID int64) (float64, error) { + return 0, nil +} + +func (s *openAIRecordUsageBillingCacheStub) SetUserBalance(ctx context.Context, userID int64, balance float64) error { + return nil +} + +func (s *openAIRecordUsageBillingCacheStub) DeductUserBalance(ctx context.Context, userID int64, amount float64) error { + return nil +} + +func (s *openAIRecordUsageBillingCacheStub) InvalidateUserBalance(ctx context.Context, userID int64) error { + s.invalidateCalls++ + s.lastInvalidatedUser = userID + return nil +} + +func (s *openAIRecordUsageBillingCacheStub) GetSubscriptionCache(ctx context.Context, userID, groupID int64) (*SubscriptionCacheData, error) { + return nil, nil +} + +func (s *openAIRecordUsageBillingCacheStub) SetSubscriptionCache(ctx context.Context, userID, groupID int64, data *SubscriptionCacheData) error { + return nil +} + +func (s *openAIRecordUsageBillingCacheStub) UpdateSubscriptionUsage(ctx context.Context, userID, groupID int64, cost float64) error { + return nil +} + +func (s *openAIRecordUsageBillingCacheStub) InvalidateSubscriptionCache(ctx context.Context, userID, groupID int64) error { + return nil +} + +func (s *openAIRecordUsageBillingCacheStub) GetAPIKeyRateLimit(ctx context.Context, keyID int64) (*APIKeyRateLimitCacheData, error) { + return nil, nil +} + +func (s *openAIRecordUsageBillingCacheStub) SetAPIKeyRateLimit(ctx context.Context, keyID int64, data *APIKeyRateLimitCacheData) error { + return nil +} + +func (s *openAIRecordUsageBillingCacheStub) UpdateAPIKeyRateLimitUsage(ctx context.Context, keyID int64, cost float64) error { + return nil +} + +func (s *openAIRecordUsageBillingCacheStub) InvalidateAPIKeyRateLimit(ctx context.Context, keyID int64) error { + return nil +} + type openAIRecordUsageSubRepoStub struct { UserSubscriptionRepository @@ -85,6 +161,8 @@ func (s *openAIRecordUsageSubRepoStub) IncrementUsage(ctx context.Context, id in type openAIRecordUsageAPIKeyQuotaStub struct { quotaCalls int rateLimitCalls int + authInvalidateCalls int + lastAuthUserID int64 err error lastAmount float64 lastQuotaCtxErr error @@ -105,6 +183,11 @@ func (s *openAIRecordUsageAPIKeyQuotaStub) UpdateRateLimitUsage(ctx context.Cont return s.err } +func (s *openAIRecordUsageAPIKeyQuotaStub) InvalidateAuthCacheByUserID(ctx context.Context, userID int64) { + s.authInvalidateCalls++ + s.lastAuthUserID = userID +} + type openAIUserGroupRateRepoStub struct { UserGroupRateRepository diff --git a/backend/internal/service/payment_config_service_test.go b/backend/internal/service/payment_config_service_test.go index 74c40b6a5..d7e59366f 100644 --- a/backend/internal/service/payment_config_service_test.go +++ b/backend/internal/service/payment_config_service_test.go @@ -355,12 +355,40 @@ func newPaymentConfigServiceTestClient(t *testing.T) *dbent.Client { if err != nil { t.Fatalf("open sqlite: %v", err) } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) t.Cleanup(func() { _ = db.Close() }) if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { t.Fatalf("enable foreign keys: %v", err) } + if _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS points_ledger ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL, + direction varchar(10) NOT NULL, + amount decimal(20,10) NOT NULL, + reason varchar(50) NOT NULL, + ref_type varchar(50) NOT NULL, + ref_id integer, + balance_before decimal(20,10) NOT NULL, + balance_after decimal(20,10) NOT NULL, + operator_user_id integer, + metadata text NOT NULL DEFAULT '{}', + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); err != nil { + t.Fatalf("create points ledger test table: %v", err) + } + if _, err := db.Exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_points_ledger_unique_ref_reason + ON points_ledger (user_id, direction, reason, ref_type, ref_id) + WHERE ref_id IS NOT NULL + `); err != nil { + t.Fatalf("create points ledger test index: %v", err) + } + drv := entsql.OpenDB(dialect.SQLite, db) client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv))) t.Cleanup(func() { _ = client.Close() }) diff --git a/backend/internal/service/redeem_service.go b/backend/internal/service/redeem_service.go index 68b48962f..8199670a6 100644 --- a/backend/internal/service/redeem_service.go +++ b/backend/internal/service/redeem_service.go @@ -3,15 +3,19 @@ package service import ( "context" "crypto/rand" + "database/sql" "encoding/hex" + "encoding/json" "errors" "fmt" "strings" "time" + "entgo.io/ent/dialect" dbent "github.com/Wei-Shaw/sub2api/ent" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/shopspring/decimal" ) var ( @@ -71,6 +75,22 @@ type RedeemCodeResponse struct { CreatedAt time.Time `json:"created_at"` } +func validateRedeemCodeValue(codeType string, value float64) error { + switch codeType { + case RedeemTypeInvitation: + return nil + case RedeemTypePoints: + if value <= 0 { + return errors.New("points value must be greater than 0") + } + default: + if value == 0 { + return errors.New("value must not be zero") + } + } + return nil +} + // RedeemService 兑换码服务 type RedeemService struct { redeemRepo RedeemCodeRepository @@ -131,18 +151,17 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ return nil, errors.New("count must be greater than 0") } - // 邀请码类型不需要数值,其他类型需要非零值(支持负数用于退款) - if req.Type != RedeemTypeInvitation && req.Value == 0 { - return nil, errors.New("value must not be zero") + codeType := req.Type + if codeType == "" { + codeType = RedeemTypeBalance } if req.Count > 1000 { return nil, errors.New("cannot generate more than 1000 codes at once") } - codeType := req.Type - if codeType == "" { - codeType = RedeemTypeBalance + if err := validateRedeemCodeValue(codeType, req.Value); err != nil { + return nil, err } // 邀请码类型的 value 设为 0 @@ -188,8 +207,8 @@ func (s *RedeemService) CreateCode(ctx context.Context, code *RedeemCode) error if code.Type == "" { code.Type = RedeemTypeBalance } - if code.Type != RedeemTypeInvitation && code.Value == 0 { - return errors.New("value must not be zero") + if err := validateRedeemCodeValue(code.Type, code.Value); err != nil { + return err } if code.Status == "" { code.Status = StatusUnused @@ -283,6 +302,9 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) ( } // 验证兑换码类型的前置条件 + if redeemCode.Type == RedeemTypePoints && redeemCode.Value <= 0 { + return nil, infraerrors.BadRequest("REDEEM_CODE_INVALID", "invalid points redeem code: value must be greater than 0") + } if redeemCode.Type == RedeemTypeSubscription && redeemCode.GroupID == nil { return nil, infraerrors.BadRequest("REDEEM_CODE_INVALID", "invalid subscription redeem code: missing group_id") } @@ -324,6 +346,19 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) ( return nil, fmt.Errorf("update user balance: %w", err) } + case RedeemTypePoints: + if err := applyPointsAdjustmentInTx(txCtx, tx, pointsAdjustmentInput{ + UserID: userID, + Delta: redeemCode.Value, + Reason: "redeem_code", + RefType: "redeem_code", + RefID: redeemCode.ID, + Metadata: map[string]any{"code": redeemCode.Code}, + ClampZero: true, + }); err != nil { + return nil, fmt.Errorf("update user points: %w", err) + } + case RedeemTypeConcurrency: delta := int(redeemCode.Value) if user.Role == RoleUser { @@ -389,7 +424,7 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) ( // invalidateRedeemCaches 失效兑换相关的缓存 func (s *RedeemService) invalidateRedeemCaches(ctx context.Context, userID int64, redeemCode *RedeemCode) { switch redeemCode.Type { - case RedeemTypeBalance: + case RedeemTypeBalance, RedeemTypePoints: if s.authCacheInvalidator != nil { s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID) } @@ -498,6 +533,192 @@ func (s *RedeemService) GetUserHistory(ctx context.Context, userID int64, limit return codes, nil } +type serviceSQLQueryer interface { + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) +} + +type serviceSQLExecer interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + +type pointsAdjustmentInput struct { + UserID int64 + Delta float64 + Reason string + RefType string + RefID int64 + OperatorUserID int64 + Metadata map[string]any + ClampZero bool +} + +func currentPointsBalanceInTx(ctx context.Context, tx *dbent.Tx, userID int64) (float64, error) { + if tx == nil { + return 0, errors.New("points balance lookup requires transaction") + } + if userID <= 0 { + return 0, ErrUserNotFound + } + queryer, ok := tx.Driver().(serviceSQLQueryer) + if !ok { + return 0, errors.New("points balance lookup requires QueryContext support") + } + return currentPointsBalanceWithQueryer(ctx, queryer, userID, tx.Driver().Dialect() == dialect.Postgres) +} + +func applyPointsAdjustmentInTx(ctx context.Context, tx *dbent.Tx, in pointsAdjustmentInput) error { + if tx == nil { + return errors.New("points adjustment requires transaction") + } + if in.UserID <= 0 { + return ErrUserNotFound + } + if in.Delta == 0 { + return nil + } + queryer, ok := tx.Driver().(serviceSQLQueryer) + if !ok { + return errors.New("points adjustment requires QueryContext support") + } + execer, ok := tx.Driver().(serviceSQLExecer) + if !ok { + return errors.New("points adjustment requires ExecContext support") + } + + balanceBefore, err := currentPointsBalanceWithQueryer(ctx, queryer, in.UserID, tx.Driver().Dialect() == dialect.Postgres) + if err != nil { + return err + } + + delta := in.Delta + if in.ClampZero && delta < 0 && balanceBefore+delta < 0 { + delta = -balanceBefore + } + balanceAfter := balanceBefore + delta + if balanceAfter < -1e-9 { + return infraerrors.BadRequest("POINTS_BALANCE_NEGATIVE", "points balance cannot be negative") + } + if balanceAfter < 0 { + balanceAfter = 0 + } + + amount := delta + direction := "credit" + if amount < 0 { + direction = "debit" + amount = -amount + } + dialectName := tx.Driver().Dialect() + amountValue := decimal.NewFromFloat(amount).Round(10).StringFixed(10) + balanceBeforeValue := decimal.NewFromFloat(balanceBefore).Round(10).StringFixed(10) + balanceAfterValue := decimal.NewFromFloat(balanceAfter).Round(10).StringFixed(10) + updateQuery := ` + UPDATE users + SET points_balance = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND deleted_at IS NULL + ` + if dialectName == dialect.Postgres { + updateQuery = ` + UPDATE users + SET points_balance = $1::numeric, + updated_at = NOW() + WHERE id = $2 AND deleted_at IS NULL + ` + } + if _, err := execer.ExecContext(ctx, updateQuery, balanceAfterValue, in.UserID); err != nil { + return err + } + + if amount == 0 { + return nil + } + metadata := in.Metadata + if metadata == nil { + metadata = map[string]any{} + } + rawMetadata, err := json.Marshal(metadata) + if err != nil { + return err + } + var refID any + if in.RefID > 0 { + refID = in.RefID + } + var operatorUserID any + if in.OperatorUserID > 0 { + operatorUserID = in.OperatorUserID + } + insertQuery := ` + INSERT INTO points_ledger ( + user_id, direction, amount, reason, ref_type, ref_id, + balance_before, balance_after, operator_user_id, metadata + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10 + ) + ON CONFLICT DO NOTHING + ` + if dialectName == dialect.Postgres { + insertQuery = ` + INSERT INTO points_ledger ( + user_id, direction, amount, reason, ref_type, ref_id, + balance_before, balance_after, operator_user_id, metadata + ) VALUES ( + $1, $2, $3::numeric, $4, $5, $6, + $7::numeric, $8::numeric, $9, $10::jsonb + ) + ON CONFLICT DO NOTHING + ` + } + _, err = execer.ExecContext(ctx, insertQuery, + in.UserID, + direction, + amountValue, + strings.TrimSpace(in.Reason), + strings.TrimSpace(in.RefType), + refID, + balanceBeforeValue, + balanceAfterValue, + operatorUserID, + string(rawMetadata), + ) + return err +} + +func currentPointsBalanceWithQueryer(ctx context.Context, queryer serviceSQLQueryer, userID int64, forUpdate bool) (float64, error) { + var balanceBefore float64 + query := ` + SELECT points_balance + FROM users + WHERE id = $1 AND deleted_at IS NULL + ` + if forUpdate { + query += " FOR UPDATE" + } + rows, err := queryer.QueryContext(ctx, query, userID) + if err != nil { + return 0, err + } + if rows.Next() { + if err := rows.Scan(&balanceBefore); err != nil { + _ = rows.Close() + return 0, err + } + } else { + if err := rows.Err(); err != nil { + _ = rows.Close() + return 0, err + } + _ = rows.Close() + return 0, ErrUserNotFound + } + if err := rows.Close(); err != nil { + return 0, err + } + return balanceBefore, nil +} + // reduceOrCancelSubscription 缩短订阅天数,剩余天数 <= 0 时取消订阅 func (s *RedeemService) reduceOrCancelSubscription(ctx context.Context, userID, groupID int64, reduceDays int, code string) error { sub, err := s.subscriptionService.userSubRepo.GetByUserIDAndGroupID(ctx, userID, groupID) diff --git a/backend/internal/service/revenue_service.go b/backend/internal/service/revenue_service.go index 4c0cd0ffe..e6878da25 100644 --- a/backend/internal/service/revenue_service.go +++ b/backend/internal/service/revenue_service.go @@ -77,11 +77,14 @@ type RevenueCashStats struct { } type RevenueUsageStats struct { - Requests int64 `json:"requests"` - TotalTokens int64 `json:"total_tokens"` - StandardCost float64 `json:"standard_cost"` - ConsumedRevenue float64 `json:"consumed_revenue"` - AccountCost float64 `json:"account_cost"` + Requests int64 `json:"requests"` + TotalTokens int64 `json:"total_tokens"` + StandardCost float64 `json:"standard_cost"` + ConsumedRevenue float64 `json:"consumed_revenue"` + BalanceConsumedAmount float64 `json:"balance_consumed_amount"` + PointsConsumedAmount float64 `json:"points_consumed_amount"` + PointsIssuedAmount float64 `json:"points_issued_amount"` + AccountCost float64 `json:"account_cost"` } type RevenueAdjustmentStats struct { @@ -111,6 +114,9 @@ type RevenueTrendPoint struct { NetPaidAmount float64 `json:"net_paid_amount"` Requests int64 `json:"requests"` ConsumedRevenue float64 `json:"consumed_revenue"` + BalanceConsumedAmount float64 `json:"balance_consumed_amount"` + PointsConsumedAmount float64 `json:"points_consumed_amount"` + PointsIssuedAmount float64 `json:"points_issued_amount"` AccountCost float64 `json:"account_cost"` UsageGrossProfit float64 `json:"usage_gross_profit"` AffiliateRebate float64 `json:"affiliate_rebate"` @@ -220,6 +226,9 @@ func (s *RevenueService) GetSummary(ctx context.Context, params RevenueQueryPara if err := s.fillRevenueUsageStats(ctx, params, out, pointIndex); err != nil { return nil, err } + if err := s.fillRevenueWalletBreakdownStats(ctx, params, out, pointIndex); err != nil { + return nil, err + } if err := s.fillRevenueAffiliateStats(ctx, params, out, pointIndex); err != nil { return nil, err } @@ -916,6 +925,125 @@ func (s *RevenueService) fillRevenueUsageStatsFromSnapshots(ctx context.Context, return nil } +func (s *RevenueService) fillRevenueWalletBreakdownStats(ctx context.Context, params RevenueQueryParams, out *RevenueSummary, pointIndex map[string]int) error { + userBalanceFilter, userBalanceArgs := revenueUserFilter("user_id", params.UserID, 4) + balanceQuery := ` + SELECT COALESCE(SUM(amount), 0)::double precision + FROM user_balance_ledger + WHERE created_at >= $1 AND created_at < $2 + AND direction = 'debit' + AND reason = $3 + ` + balanceQuery += userBalanceFilter + balanceArgs := []any{params.StartTime, params.EndTime, "usage_charge"} + balanceArgs = append(balanceArgs, userBalanceArgs...) + if err := s.querySingle(ctx, balanceQuery, balanceArgs, &out.Usage.BalanceConsumedAmount); err != nil { + return fmt.Errorf("query revenue balance consumed stats: %w", err) + } + + userPointsFilter, userPointsArgs := revenueUserFilter("user_id", params.UserID, 8) + pointsQuery := ` + SELECT + COALESCE(SUM(amount) FILTER (WHERE direction = 'debit' AND reason IN ($3, $4)), 0)::double precision AS points_consumed, + COALESCE(SUM(amount) FILTER (WHERE direction = 'credit' AND reason IN ($5, $6, $7)), 0)::double precision AS points_issued + FROM points_ledger + WHERE created_at >= $1 AND created_at < $2 + ` + pointsQuery += userPointsFilter + pointsArgs := []any{ + params.StartTime, + params.EndTime, + "usage_charge", + "shop_order", + "redeem_code", + "admin_adjustment", + "shop_draw_reward", + } + pointsArgs = append(pointsArgs, userPointsArgs...) + if err := s.querySingle(ctx, pointsQuery, pointsArgs, &out.Usage.PointsConsumedAmount, &out.Usage.PointsIssuedAmount); err != nil { + return fmt.Errorf("query revenue points stats: %w", err) + } + + bucketExpr := revenueBucketExpression("created_at", params.Granularity) + trendBalanceUserFilter, trendBalanceUserArgs := revenueUserFilter("user_id", params.UserID, 5) + trendBalanceQuery := fmt.Sprintf(` + SELECT %s AS bucket, COALESCE(SUM(amount), 0)::double precision AS balance_consumed + FROM user_balance_ledger + WHERE created_at >= $1 AND created_at < $2 + AND direction = 'debit' + AND reason = $4 + %s + GROUP BY 1 + ORDER BY 1 + `, bucketExpr, trendBalanceUserFilter) + trendBalanceArgs := []any{params.StartTime, params.EndTime, params.Timezone, "usage_charge"} + trendBalanceArgs = append(trendBalanceArgs, trendBalanceUserArgs...) + rows, err := s.entClient.QueryContext(ctx, trendBalanceQuery, trendBalanceArgs...) + if err != nil { + return fmt.Errorf("query revenue balance consumed trend: %w", err) + } + for rows.Next() { + var bucket string + var amount float64 + if err := rows.Scan(&bucket, &amount); err != nil { + _ = rows.Close() + return fmt.Errorf("scan revenue balance consumed trend: %w", err) + } + if idx, ok := pointIndex[bucket]; ok { + out.Trend[idx].BalanceConsumedAmount = amount + } + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return fmt.Errorf("iterate revenue balance consumed trend: %w", err) + } + _ = rows.Close() + + trendPointsUserFilter, trendPointsUserArgs := revenueUserFilter("user_id", params.UserID, 9) + trendPointsQuery := fmt.Sprintf(` + SELECT + %s AS bucket, + COALESCE(SUM(amount) FILTER (WHERE direction = 'debit' AND reason IN ($4, $5)), 0)::double precision AS points_consumed, + COALESCE(SUM(amount) FILTER (WHERE direction = 'credit' AND reason IN ($6, $7, $8)), 0)::double precision AS points_issued + FROM points_ledger + WHERE created_at >= $1 AND created_at < $2 + %s + GROUP BY 1 + ORDER BY 1 + `, bucketExpr, trendPointsUserFilter) + trendPointsArgs := []any{ + params.StartTime, + params.EndTime, + params.Timezone, + "usage_charge", + "shop_order", + "redeem_code", + "admin_adjustment", + "shop_draw_reward", + } + trendPointsArgs = append(trendPointsArgs, trendPointsUserArgs...) + rows, err = s.entClient.QueryContext(ctx, trendPointsQuery, trendPointsArgs...) + if err != nil { + return fmt.Errorf("query revenue points trend: %w", err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var bucket string + var consumed, issued float64 + if err := rows.Scan(&bucket, &consumed, &issued); err != nil { + return fmt.Errorf("scan revenue points trend: %w", err) + } + if idx, ok := pointIndex[bucket]; ok { + out.Trend[idx].PointsConsumedAmount = consumed + out.Trend[idx].PointsIssuedAmount = issued + } + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterate revenue points trend: %w", err) + } + return nil +} + func (s *RevenueService) fillRevenueAffiliateStats(ctx context.Context, params RevenueQueryParams, out *RevenueSummary, pointIndex map[string]int) error { userFilter, userArgs := revenueUserFilter("user_id", params.UserID, 5) query := ` @@ -1233,6 +1361,9 @@ func finalizeRevenueSummary(out *RevenueSummary) { out.Usage.StandardCost = roundRevenue(out.Usage.StandardCost) out.Usage.ConsumedRevenue = roundRevenue(out.Usage.ConsumedRevenue) + out.Usage.BalanceConsumedAmount = roundRevenue(out.Usage.BalanceConsumedAmount) + out.Usage.PointsConsumedAmount = roundRevenue(out.Usage.PointsConsumedAmount) + out.Usage.PointsIssuedAmount = roundRevenue(out.Usage.PointsIssuedAmount) out.Usage.AccountCost = roundRevenue(out.Usage.AccountCost) out.Adjustments.AffiliateRebate = roundRevenue(out.Adjustments.AffiliateRebate) @@ -1255,6 +1386,9 @@ func finalizeRevenueSummary(out *RevenueSummary) { p.RefundAmount = roundRevenue(p.RefundAmount) p.NetPaidAmount = roundRevenue(p.PaidAmount - p.RefundAmount) p.ConsumedRevenue = roundRevenue(p.ConsumedRevenue) + p.BalanceConsumedAmount = roundRevenue(p.BalanceConsumedAmount) + p.PointsConsumedAmount = roundRevenue(p.PointsConsumedAmount) + p.PointsIssuedAmount = roundRevenue(p.PointsIssuedAmount) p.AccountCost = roundRevenue(p.AccountCost) p.UsageGrossProfit = roundRevenue(p.ConsumedRevenue - p.AccountCost) p.AffiliateRebate = roundRevenue(p.AffiliateRebate) diff --git a/backend/internal/service/shop.go b/backend/internal/service/shop.go index ee0ddbc67..d92a44c4b 100644 --- a/backend/internal/service/shop.go +++ b/backend/internal/service/shop.go @@ -36,6 +36,7 @@ const ( ShopProductTypeCardKey = "card_key" ShopProductTypeBalanceDraw = "balance_draw" + ShopProductTypePointsDraw = "points_draw" ShopFileCardStorageProviderOSS = "oss" ShopFileCardMaxSizeBytes = int64(200 * 1024) @@ -47,6 +48,7 @@ const ( ShopOrderStatusFailed = "failed" ShopPaymentMethodBalance = "balance" + ShopPaymentMethodPoints = "points" ShopBalanceLedgerEntryNet = "net" ) @@ -64,6 +66,7 @@ var ( ErrShopInvalidQuantity = infraerrors.BadRequest("SHOP_INVALID_QUANTITY", "invalid purchase quantity") ErrShopInsufficientStock = infraerrors.Conflict("SHOP_INSUFFICIENT_STOCK", "insufficient shop stock") ErrShopInsufficientBalance = infraerrors.Forbidden("SHOP_INSUFFICIENT_BALANCE", "insufficient balance") + ErrShopInsufficientPoints = infraerrors.Forbidden("SHOP_INSUFFICIENT_POINTS", "insufficient points") ErrShopUnsupportedPayment = infraerrors.BadRequest("SHOP_UNSUPPORTED_PAYMENT_METHOD", "unsupported shop payment method") ErrShopInvalidOrderStatus = infraerrors.Conflict("SHOP_INVALID_ORDER_STATUS", "invalid shop order status") ErrShopPaymentAmountMismatch = infraerrors.Conflict("SHOP_PAYMENT_AMOUNT_MISMATCH", "shop payment amount mismatch") @@ -150,27 +153,30 @@ type ShopCategoryDTO struct { } type ShopProductDTO struct { - ID int64 `json:"id"` - CategoryID *int64 `json:"category_id,omitempty"` - Category *ShopCategoryDTO `json:"category,omitempty"` - Name string `json:"name"` - CoverURL *string `json:"cover_url,omitempty"` - Description *string `json:"description,omitempty"` - Price float64 `json:"price"` - OriginalPrice *float64 `json:"original_price,omitempty"` - Enabled bool `json:"enabled"` - SortOrder int `json:"sort_order"` - MinPurchase int `json:"min_purchase"` - MaxPurchase int `json:"max_purchase"` - AutoDelivery bool `json:"auto_delivery"` - ProductType string `json:"product_type"` - BalanceOnly bool `json:"balance_only"` - DrawConfig *ShopDrawConfigDTO `json:"draw_config,omitempty"` - DrawProgress *ShopDrawProgressDTO `json:"draw_progress,omitempty"` - Stock int `json:"stock"` - StockUnlimited bool `json:"stock_unlimited"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + CategoryID *int64 `json:"category_id,omitempty"` + Category *ShopCategoryDTO `json:"category,omitempty"` + Name string `json:"name"` + CoverURL *string `json:"cover_url,omitempty"` + Description *string `json:"description,omitempty"` + Price float64 `json:"price"` + OriginalPrice *float64 `json:"original_price,omitempty"` + Enabled bool `json:"enabled"` + SortOrder int `json:"sort_order"` + MinPurchase int `json:"min_purchase"` + MaxPurchase int `json:"max_purchase"` + AutoDelivery bool `json:"auto_delivery"` + ProductType string `json:"product_type"` + BalanceOnly bool `json:"balance_only"` + AllowBalancePayment bool `json:"allow_balance_payment"` + AllowPointsPayment bool `json:"allow_points_payment"` + AllowPlatformPayment bool `json:"allow_platform_payment"` + DrawConfig *ShopDrawConfigDTO `json:"draw_config,omitempty"` + DrawProgress *ShopDrawProgressDTO `json:"draw_progress,omitempty"` + Stock int `json:"stock"` + StockUnlimited bool `json:"stock_unlimited"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ShopDrawConfigDTO struct { @@ -194,15 +200,18 @@ type ShopOrderDTO struct { ProductName string `json:"product_name"` ProductCoverURL *string `json:"product_cover_url,omitempty"` ProductDescription *string `json:"product_description,omitempty"` + ProductType string `json:"product_type"` UnitPrice float64 `json:"unit_price"` Quantity int `json:"quantity"` TotalAmount float64 `json:"total_amount"` + PointsAmount float64 `json:"points_amount"` PaymentMethod string `json:"payment_method"` PaymentOrderID *int64 `json:"payment_order_id,omitempty"` Status string `json:"status"` DeliveredCards []string `json:"delivered_cards"` DeliveredFiles []ShopDeliveredFileDTO `json:"delivered_files"` DrawRewardAmount *float64 `json:"draw_reward_amount,omitempty"` + DrawRewardType string `json:"draw_reward_type,omitempty"` DrawCycleID *int64 `json:"draw_cycle_id,omitempty"` DrawCycleIndex *int `json:"draw_cycle_index,omitempty"` PaidAt *time.Time `json:"paid_at,omitempty"` @@ -293,39 +302,45 @@ type ShopUpdateCategoryRequest struct { } type ShopCreateProductRequest struct { - CategoryID *int64 `json:"category_id"` - Name string `json:"name"` - CoverURL *string `json:"cover_url"` - Description *string `json:"description"` - Price float64 `json:"price"` - OriginalPrice *float64 `json:"original_price"` - Enabled *bool `json:"enabled"` - SortOrder int `json:"sort_order"` - MinPurchase int `json:"min_purchase"` - MaxPurchase int `json:"max_purchase"` - AutoDelivery *bool `json:"auto_delivery"` - ProductType string `json:"product_type"` - BalanceOnly *bool `json:"balance_only"` - DrawConfig *ShopDrawConfigInput `json:"draw_config"` + CategoryID *int64 `json:"category_id"` + Name string `json:"name"` + CoverURL *string `json:"cover_url"` + Description *string `json:"description"` + Price float64 `json:"price"` + OriginalPrice *float64 `json:"original_price"` + Enabled *bool `json:"enabled"` + SortOrder int `json:"sort_order"` + MinPurchase int `json:"min_purchase"` + MaxPurchase int `json:"max_purchase"` + AutoDelivery *bool `json:"auto_delivery"` + ProductType string `json:"product_type"` + BalanceOnly *bool `json:"balance_only"` + AllowBalancePayment *bool `json:"allow_balance_payment"` + AllowPointsPayment *bool `json:"allow_points_payment"` + AllowPlatformPayment *bool `json:"allow_platform_payment"` + DrawConfig *ShopDrawConfigInput `json:"draw_config"` } type ShopUpdateProductRequest struct { - CategoryID *int64 `json:"category_id"` - ClearCategory bool `json:"clear_category"` - Name *string `json:"name"` - CoverURL *string `json:"cover_url"` - Description *string `json:"description"` - Price *float64 `json:"price"` - OriginalPrice *float64 `json:"original_price"` - ClearOriginalPrice bool `json:"clear_original_price"` - Enabled *bool `json:"enabled"` - SortOrder *int `json:"sort_order"` - MinPurchase *int `json:"min_purchase"` - MaxPurchase *int `json:"max_purchase"` - AutoDelivery *bool `json:"auto_delivery"` - ProductType *string `json:"product_type"` - BalanceOnly *bool `json:"balance_only"` - DrawConfig *ShopDrawConfigInput `json:"draw_config"` + CategoryID *int64 `json:"category_id"` + ClearCategory bool `json:"clear_category"` + Name *string `json:"name"` + CoverURL *string `json:"cover_url"` + Description *string `json:"description"` + Price *float64 `json:"price"` + OriginalPrice *float64 `json:"original_price"` + ClearOriginalPrice bool `json:"clear_original_price"` + Enabled *bool `json:"enabled"` + SortOrder *int `json:"sort_order"` + MinPurchase *int `json:"min_purchase"` + MaxPurchase *int `json:"max_purchase"` + AutoDelivery *bool `json:"auto_delivery"` + ProductType *string `json:"product_type"` + BalanceOnly *bool `json:"balance_only"` + AllowBalancePayment *bool `json:"allow_balance_payment"` + AllowPointsPayment *bool `json:"allow_points_payment"` + AllowPlatformPayment *bool `json:"allow_platform_payment"` + DrawConfig *ShopDrawConfigInput `json:"draw_config"` } type ShopDrawConfigInput struct { @@ -415,7 +430,7 @@ func (s *ShopService) ListProducts(ctx context.Context, params ShopListProductsP out := make([]ShopProductDTO, 0, len(products)) for _, item := range products { itemStock := stock[item.ID] - if item.ProductType == ShopProductTypeBalanceDraw { + if isShopDrawProductType(item.ProductType) { itemStock = 0 } dto := mapShopProduct(item, itemStock) @@ -447,7 +462,7 @@ func (s *ShopService) GetProduct(ctx context.Context, id int64, admin bool) (*Sh if err != nil { return nil, err } - if product.ProductType == ShopProductTypeBalanceDraw { + if isShopDrawProductType(product.ProductType) { stock = 0 } dto := mapShopProduct(product, stock) @@ -456,7 +471,7 @@ func (s *ShopService) GetProduct(ctx context.Context, id int64, admin bool) (*Sh func (s *ShopService) ListDrawProgress(ctx context.Context, userID int64) (map[int64]*ShopDrawProgressDTO, error) { products, err := s.entClient.ShopProduct.Query(). - Where(shopproduct.EnabledEQ(true), shopproduct.ProductTypeEQ(ShopProductTypeBalanceDraw)). + Where(shopproduct.EnabledEQ(true), shopproduct.ProductTypeIn(ShopProductTypeBalanceDraw, ShopProductTypePointsDraw)). All(ctx) if err != nil { return nil, fmt.Errorf("list shop draw products for progress: %w", err) @@ -506,6 +521,9 @@ func (s *ShopService) CreateOrder(ctx context.Context, req ShopCreateOrderReques if req.PaymentMethod == ShopPaymentMethodBalance { return s.createBalanceOrder(ctx, req) } + if req.PaymentMethod == ShopPaymentMethodPoints { + return s.createPointsOrder(ctx, req) + } if req.IsWeChatBrowser && strings.TrimSpace(req.OpenID) == "" && payment.GetBasePaymentType(req.PaymentMethod) == payment.TypeWxpay && @@ -527,6 +545,9 @@ func (s *ShopService) createBalanceOrder(ctx context.Context, req ShopCreateOrde if err != nil { return nil, err } + if !product.AllowBalancePayment { + return nil, ErrShopUnsupportedPayment + } totalAmount := normalizeShopAmount(product.Price * float64(req.Quantity)) userQuery := tx.User.Query().Where(user.IDEQ(req.UserID)) @@ -545,7 +566,7 @@ func (s *ShopService) createBalanceOrder(ctx context.Context, req ShopCreateOrde } var drawReward *shopDrawRewardResult - if product.ProductType == ShopProductTypeBalanceDraw { + if isShopDrawProductType(product.ProductType) { drawReward, err = s.nextDrawRewardInTx(ctx, tx, req.UserID, product) if err != nil { return nil, err @@ -584,16 +605,29 @@ func (s *ShopService) createBalanceOrder(ctx context.Context, req ShopCreateOrde return nil, fmt.Errorf("complete shop balance order: %w", err) } balanceDelta := -totalAmount - if drawReward != nil { + if drawReward != nil && product.ProductType == ShopProductTypeBalanceDraw { balanceDelta = normalizeShopAmount(balanceDelta + drawReward.Amount) } balanceAfter := normalizeShopAmount(u.Balance + balanceDelta) - if err := s.createShopBalanceLedgerInTx(ctx, tx, order.ID, req.UserID, u.Balance, totalAmount, drawReward); err != nil { + if err := s.createShopBalanceLedgerInTx(ctx, tx, order.ID, req.UserID, u.Balance, totalAmount, drawRewardForBalance(product, drawReward)); err != nil { return nil, err } if _, err := tx.User.UpdateOneID(req.UserID).AddBalance(balanceDelta).Save(ctx); err != nil { return nil, fmt.Errorf("deduct shop balance: %w", err) } + if drawReward != nil && product.ProductType == ShopProductTypePointsDraw { + if err := applyPointsAdjustmentInTx(ctx, tx, pointsAdjustmentInput{ + UserID: req.UserID, + Delta: drawReward.Amount, + Reason: "shop_draw_reward", + RefType: "shop_order", + RefID: order.ID, + Metadata: map[string]any{"product_id": product.ID, "quantity": req.Quantity}, + ClampZero: false, + }); err != nil { + return nil, fmt.Errorf("credit shop draw reward points: %w", err) + } + } u.Balance = balanceAfter if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit shop balance order: %w", err) @@ -607,6 +641,121 @@ func (s *ShopService) createBalanceOrder(ctx context.Context, req ShopCreateOrde return &dto, nil } +func (s *ShopService) createPointsOrder(ctx context.Context, req ShopCreateOrderRequest) (*ShopOrderDTO, error) { + tx, err := s.entClient.Tx(ctx) + if err != nil { + return nil, fmt.Errorf("begin shop points transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + product, err := s.validateProductForPurchase(ctx, tx, req.ProductID, req.Quantity) + if err != nil { + return nil, err + } + if !product.AllowPointsPayment { + return nil, ErrShopUnsupportedPayment + } + totalAmount := normalizeShopAmount(product.Price * float64(req.Quantity)) + + userQuery := tx.User.Query().Where(user.IDEQ(req.UserID)) + if shopTxSupportsRowLock(tx) { + userQuery.ForUpdate() + } + u, err := userQuery.Only(ctx) + if err != nil { + if dbent.IsNotFound(err) { + return nil, ErrUserNotFound + } + return nil, fmt.Errorf("lock user points: %w", err) + } + if u.PointsBalance+1e-9 < totalAmount { + return nil, ErrShopInsufficientPoints + } + + var drawReward *shopDrawRewardResult + if isShopDrawProductType(product.ProductType) { + drawReward, err = s.nextDrawRewardInTx(ctx, tx, req.UserID, product) + if err != nil { + return nil, err + } + } + order, err := s.createShopOrderInTx(ctx, tx, req, product, totalAmount, ShopOrderStatusPaid) + if err != nil { + return nil, err + } + delivered := []string{} + if drawReward != nil { + order, err = tx.ShopOrder.UpdateOneID(order.ID). + SetDrawRewardAmount(drawReward.Amount). + SetDrawCycleID(drawReward.CycleID). + SetDrawCycleIndex(drawReward.CycleIndex). + Save(ctx) + if err != nil { + return nil, fmt.Errorf("attach shop draw reward: %w", err) + } + delivered = []string{fmt.Sprintf("%.2f", drawReward.Amount)} + } else { + delivered, err = s.deliverOrderInTx(ctx, tx, order) + } + if err != nil { + return nil, err + } + now := time.Now() + order, err = tx.ShopOrder.UpdateOneID(order.ID). + SetStatus(ShopOrderStatusCompleted). + SetPointsAmount(totalAmount). + SetPaidAt(now). + SetCompletedAt(now). + SetDeliveredCards(delivered). + ClearFailedReason(). + Save(ctx) + if err != nil { + return nil, fmt.Errorf("complete shop points order: %w", err) + } + if err := applyPointsAdjustmentInTx(ctx, tx, pointsAdjustmentInput{ + UserID: req.UserID, + Delta: -totalAmount, + Reason: "shop_order", + RefType: "shop_order", + RefID: order.ID, + Metadata: map[string]any{"product_id": product.ID, "quantity": req.Quantity}, + ClampZero: false, + }); err != nil { + return nil, fmt.Errorf("deduct shop points: %w", err) + } + if drawReward != nil && product.ProductType == ShopProductTypeBalanceDraw { + if err := s.createShopBalanceLedgerInTx(ctx, tx, order.ID, req.UserID, u.Balance, 0, drawReward); err != nil { + return nil, err + } + if _, err := tx.User.UpdateOneID(req.UserID).AddBalance(drawReward.Amount).Save(ctx); err != nil { + return nil, fmt.Errorf("credit shop draw reward balance: %w", err) + } + } + if drawReward != nil && product.ProductType == ShopProductTypePointsDraw { + if err := applyPointsAdjustmentInTx(ctx, tx, pointsAdjustmentInput{ + UserID: req.UserID, + Delta: drawReward.Amount, + Reason: "shop_draw_reward", + RefType: "shop_order", + RefID: order.ID, + Metadata: map[string]any{"product_id": product.ID, "quantity": req.Quantity}, + ClampZero: false, + }); err != nil { + return nil, fmt.Errorf("credit shop draw reward points: %w", err) + } + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit shop points order: %w", err) + } + s.invalidateUserBalance(ctx, req.UserID) + order.Unwrap() + dto := mapShopOrder(order, nil) + if err := s.hydrateOrderDeliveredFiles(ctx, &dto); err != nil { + return nil, err + } + return &dto, nil +} + func (s *ShopService) createPlatformPaymentOrder(ctx context.Context, req ShopCreateOrderRequest) (*ShopOrderDTO, error) { if s.paymentService == nil { return nil, infraerrors.ServiceUnavailable("PAYMENT_SERVICE_NOT_CONFIGURED", "payment service is not configured") @@ -621,7 +770,7 @@ func (s *ShopService) createPlatformPaymentOrder(ctx context.Context, req ShopCr if err != nil { return nil, err } - if product.BalanceOnly || product.ProductType == ShopProductTypeBalanceDraw { + if !product.AllowPlatformPayment { return nil, ErrShopUnsupportedPayment } totalAmount := normalizeShopAmount(product.Price * float64(req.Quantity)) @@ -662,8 +811,10 @@ func (s *ShopService) createPlatformPaymentOrder(ctx context.Context, req ShopCr if err != nil { return nil, fmt.Errorf("link shop payment order: %w", err) } - if _, err := s.reserveCardsForOrderInTx(ctx, tx, order, paymentOrder.ExpiresAt); err != nil { - return nil, err + if !isShopDrawProductType(product.ProductType) { + if _, err := s.reserveCardsForOrderInTx(ctx, tx, order, paymentOrder.ExpiresAt); err != nil { + return nil, err + } } if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit shop platform payment order: %w", err) @@ -755,7 +906,7 @@ func (s *ShopService) ConfirmPaidAndDeliver(ctx context.Context, paymentOrderID return ErrShopPaymentAmountMismatch } - delivered, err := s.deliverOrderInTx(ctx, tx, order) + delivered, err := s.fulfillPaidPlatformShopOrderInTx(ctx, tx, order) if err != nil { _ = s.markShopFulfillmentFailedInTx(ctx, tx, order.ID, paymentOrderID, err.Error()) if commitErr := tx.Commit(); commitErr != nil { @@ -791,6 +942,62 @@ func (s *ShopService) ConfirmPaidAndDeliver(ctx context.Context, paymentOrderID return nil } +func (s *ShopService) fulfillPaidPlatformShopOrderInTx(ctx context.Context, tx *dbent.Tx, order *dbent.ShopOrder) ([]string, error) { + if !isShopDrawProductType(order.ProductType) { + return s.deliverOrderInTx(ctx, tx, order) + } + productQuery := tx.ShopProduct.Query().Where(shopproduct.IDEQ(order.ProductID)) + if shopTxSupportsRowLock(tx) { + productQuery.ForUpdate() + } + product, err := productQuery.Only(ctx) + if err != nil { + if dbent.IsNotFound(err) { + return nil, ErrShopProductNotFound + } + return nil, fmt.Errorf("lock shop draw product: %w", err) + } + drawReward, err := s.nextDrawRewardInTx(ctx, tx, order.UserID, product) + if err != nil { + return nil, err + } + if _, err := tx.ShopOrder.UpdateOneID(order.ID). + SetDrawRewardAmount(drawReward.Amount). + SetDrawCycleID(drawReward.CycleID). + SetDrawCycleIndex(drawReward.CycleIndex). + Save(ctx); err != nil { + return nil, fmt.Errorf("attach shop draw reward: %w", err) + } + if product.ProductType == ShopProductTypeBalanceDraw { + u, err := tx.User.Query().Where(user.IDEQ(order.UserID)).Only(ctx) + if err != nil { + if dbent.IsNotFound(err) { + return nil, ErrUserNotFound + } + return nil, fmt.Errorf("get user for shop draw balance reward: %w", err) + } + if err := s.createShopBalanceLedgerInTx(ctx, tx, order.ID, order.UserID, u.Balance, 0, drawReward); err != nil { + return nil, err + } + if _, err := tx.User.UpdateOneID(order.UserID).AddBalance(drawReward.Amount).Save(ctx); err != nil { + return nil, fmt.Errorf("credit shop draw reward balance: %w", err) + } + return []string{fmt.Sprintf("%.2f", drawReward.Amount)}, nil + } + if err := applyPointsAdjustmentInTx(ctx, tx, pointsAdjustmentInput{ + UserID: order.UserID, + Delta: drawReward.Amount, + Reason: "shop_draw_reward", + RefType: "shop_order", + RefID: order.ID, + Metadata: map[string]any{"product_id": product.ID, "quantity": order.Quantity}, + ClampZero: false, + }); err != nil { + return nil, fmt.Errorf("credit shop draw reward points: %w", err) + } + return []string{fmt.Sprintf("%.2f", drawReward.Amount)}, nil +} + func (s *ShopService) markShopFulfillmentFailedInTx(ctx context.Context, tx *dbent.Tx, shopOrderID, paymentOrderID int64, reason string) error { now := time.Now() if _, err := tx.ShopOrder.UpdateOneID(shopOrderID). @@ -928,8 +1135,8 @@ func (s *ShopService) validateProductForPurchase(ctx context.Context, tx *dbent. if !product.AutoDelivery { return nil, ErrShopAutoDeliveryRequired } - if product.ProductType == ShopProductTypeBalanceDraw { - if err := validateShopDrawProductConfig(product.Price, product.MinPurchase, product.MaxPurchase, product.AutoDelivery, product.ProductType, product.BalanceOnly, shopDrawConfigInputFromProduct(product)); err != nil { + if isShopDrawProductType(product.ProductType) { + if err := validateShopDrawProductConfig(product.Price, product.MinPurchase, product.MaxPurchase, product.AutoDelivery, product.ProductType, product.BalanceOnly, product.AllowBalancePayment, product.AllowPointsPayment, product.AllowPlatformPayment, shopDrawConfigInputFromProduct(product)); err != nil { return nil, err } if quantity != 1 { @@ -972,15 +1179,31 @@ func (s *ShopService) createShopOrderInTx(ctx context.Context, tx *dbent.Tx, req SetProductName(product.Name). SetNillableProductCoverURL(product.CoverURL). SetNillableProductDescription(product.Description). + SetProductType(product.ProductType). SetUnitPrice(normalizeShopAmount(product.Price)). SetQuantity(req.Quantity). SetTotalAmount(totalAmount). + SetPointsAmount(pointsAmountForOrder(req.PaymentMethod, totalAmount)). SetPaymentMethod(req.PaymentMethod). SetStatus(status). SetDeliveredCards([]string{}). Save(ctx) } +func pointsAmountForOrder(paymentMethod string, totalAmount float64) float64 { + if paymentMethod == ShopPaymentMethodPoints { + return totalAmount + } + return 0 +} + +func drawRewardForBalance(product *dbent.ShopProduct, reward *shopDrawRewardResult) *shopDrawRewardResult { + if product == nil || product.ProductType != ShopProductTypeBalanceDraw { + return nil + } + return reward +} + func (s *ShopService) createShopBalanceLedgerInTx(ctx context.Context, tx *dbent.Tx, orderID, userID int64, balanceBefore, totalAmount float64, drawReward *shopDrawRewardResult) error { creditAmount := 0.0 ledger := tx.ShopBalanceLedger.Create(). @@ -1514,7 +1737,7 @@ func (s *ShopService) AdminCreateProduct(ctx context.Context, req ShopCreateProd if productType == "" { return nil, ErrShopInvalidInput } - if productType == ShopProductTypeBalanceDraw { + if isShopDrawProductType(productType) { req.MinPurchase = 1 req.MaxPurchase = 1 autoDelivery := true @@ -1547,8 +1770,23 @@ func (s *ShopService) AdminCreateProduct(ctx context.Context, req ShopCreateProd if req.BalanceOnly != nil { balanceOnly = *req.BalanceOnly } + allowBalancePayment := true + if req.AllowBalancePayment != nil { + allowBalancePayment = *req.AllowBalancePayment + } + allowPointsPayment := false + if req.AllowPointsPayment != nil { + allowPointsPayment = *req.AllowPointsPayment + } + allowPlatformPayment := !balanceOnly + if req.AllowPlatformPayment != nil { + allowPlatformPayment = *req.AllowPlatformPayment + } drawConfig := normalizeShopDrawConfig(req.DrawConfig) - if err := validateShopDrawProductConfig(req.Price, minPurchase, maxPurchase, autoDelivery, productType, balanceOnly, drawConfig); err != nil { + if err := validateShopPaymentMethods(allowBalancePayment, allowPointsPayment, allowPlatformPayment); err != nil { + return nil, err + } + if err := validateShopDrawProductConfig(req.Price, minPurchase, maxPurchase, autoDelivery, productType, balanceOnly, allowBalancePayment, allowPointsPayment, allowPlatformPayment, drawConfig); err != nil { return nil, err } create := s.entClient.ShopProduct.Create(). @@ -1565,6 +1803,9 @@ func (s *ShopService) AdminCreateProduct(ctx context.Context, req ShopCreateProd SetAutoDelivery(autoDelivery). SetProductType(productType). SetBalanceOnly(balanceOnly). + SetAllowBalancePayment(allowBalancePayment). + SetAllowPointsPayment(allowPointsPayment). + SetAllowPlatformPayment(allowPlatformPayment). SetDrawEnabled(drawConfig.Enabled). SetDrawMinAmount(drawConfig.MinAmount). SetDrawMaxAmount(drawConfig.MaxAmount). @@ -1595,6 +1836,7 @@ func (s *ShopService) AdminUpdateProduct(ctx context.Context, id int64, req Shop return nil, ErrShopInvalidInput } } + productTypeChanged := productType != current.ProductType if req.MinPurchase != nil { minPurchase = *req.MinPurchase } @@ -1609,7 +1851,25 @@ func (s *ShopService) AdminUpdateProduct(ctx context.Context, id int64, req Shop if req.BalanceOnly != nil { balanceOnly = *req.BalanceOnly } - if productType == ShopProductTypeBalanceDraw { + allowBalancePayment := current.AllowBalancePayment + if req.AllowBalancePayment != nil { + allowBalancePayment = *req.AllowBalancePayment + } else if productTypeChanged { + allowBalancePayment = true + } + allowPointsPayment := current.AllowPointsPayment + if req.AllowPointsPayment != nil { + allowPointsPayment = *req.AllowPointsPayment + } + allowPlatformPayment := current.AllowPlatformPayment + if req.AllowPlatformPayment != nil { + allowPlatformPayment = *req.AllowPlatformPayment + } else if req.BalanceOnly != nil { + allowPlatformPayment = !balanceOnly + } else if productTypeChanged { + allowPlatformPayment = true + } + if isShopDrawProductType(productType) { minPurchase = 1 maxPurchase = 1 autoDelivery = true @@ -1640,10 +1900,13 @@ func (s *ShopService) AdminUpdateProduct(ctx context.Context, id int64, req Shop if productType == ShopProductTypeCardKey { drawConfig = &ShopDrawConfigInput{} } - if err := validateShopDrawProductConfig(price, minPurchase, maxPurchase, autoDelivery, productType, balanceOnly, drawConfig); err != nil { + if err := validateShopPaymentMethods(allowBalancePayment, allowPointsPayment, allowPlatformPayment); err != nil { return nil, err } - if shopDrawEconomicsChanged(current, price, minPurchase, maxPurchase, autoDelivery, productType, balanceOnly, drawConfig) { + if err := validateShopDrawProductConfig(price, minPurchase, maxPurchase, autoDelivery, productType, balanceOnly, allowBalancePayment, allowPointsPayment, allowPlatformPayment, drawConfig); err != nil { + return nil, err + } + if shopDrawEconomicsChanged(current, price, minPurchase, maxPurchase, autoDelivery, productType, balanceOnly, allowBalancePayment, allowPointsPayment, allowPlatformPayment, drawConfig) { hasActiveCycles, err := s.hasActiveShopDrawCycles(ctx, id) if err != nil { return nil, err @@ -1698,6 +1961,9 @@ func (s *ShopService) AdminUpdateProduct(ctx context.Context, id int64, req Shop SetAutoDelivery(autoDelivery). SetProductType(productType). SetBalanceOnly(balanceOnly). + SetAllowBalancePayment(allowBalancePayment). + SetAllowPointsPayment(allowPointsPayment). + SetAllowPlatformPayment(allowPlatformPayment). SetDrawEnabled(drawConfig.Enabled). SetDrawMinAmount(drawConfig.MinAmount). SetDrawMaxAmount(drawConfig.MaxAmount). @@ -2056,13 +2322,28 @@ func normalizeShopProductType(productType string) string { return ShopProductTypeCardKey } switch productType { - case ShopProductTypeCardKey, ShopProductTypeBalanceDraw: + case ShopProductTypeCardKey, ShopProductTypeBalanceDraw, ShopProductTypePointsDraw: return productType default: return "" } } +func isShopDrawProductType(productType string) bool { + return productType == ShopProductTypeBalanceDraw || productType == ShopProductTypePointsDraw +} + +func drawRewardTypeForProductType(productType string) string { + switch productType { + case ShopProductTypeBalanceDraw: + return "balance" + case ShopProductTypePointsDraw: + return "points" + default: + return "" + } +} + func shopDrawConfigInputFromProduct(product *dbent.ShopProduct) *ShopDrawConfigInput { if product == nil { return nil @@ -2089,7 +2370,14 @@ func normalizeShopDrawConfig(input *ShopDrawConfigInput) *ShopDrawConfigInput { } } -func validateShopDrawProductConfig(price float64, minPurchase, maxPurchase int, autoDelivery bool, productType string, balanceOnly bool, input *ShopDrawConfigInput) error { +func validateShopPaymentMethods(allowBalancePayment, allowPointsPayment, allowPlatformPayment bool) error { + if !allowBalancePayment && !allowPointsPayment && !allowPlatformPayment { + return ErrShopUnsupportedPayment + } + return nil +} + +func validateShopDrawProductConfig(price float64, minPurchase, maxPurchase int, autoDelivery bool, productType string, balanceOnly bool, _ bool, _ bool, _ bool, input *ShopDrawConfigInput) error { productType = normalizeShopProductType(productType) if productType == "" { return ErrShopInvalidInput @@ -2101,6 +2389,9 @@ func validateShopDrawProductConfig(price float64, minPurchase, maxPurchase int, } return nil } + if !isShopDrawProductType(productType) { + return ErrShopInvalidInput + } if !balanceOnly || !autoDelivery || minPurchase != 1 || maxPurchase != 1 || !config.Enabled { return ErrShopInvalidInput } @@ -2121,7 +2412,7 @@ func validateShopDrawProductConfig(price float64, minPurchase, maxPurchase int, return nil } -func shopDrawEconomicsChanged(current *dbent.ShopProduct, price float64, minPurchase, maxPurchase int, autoDelivery bool, productType string, balanceOnly bool, input *ShopDrawConfigInput) bool { +func shopDrawEconomicsChanged(current *dbent.ShopProduct, price float64, minPurchase, maxPurchase int, autoDelivery bool, productType string, balanceOnly bool, allowBalancePayment bool, allowPointsPayment bool, allowPlatformPayment bool, input *ShopDrawConfigInput) bool { if current == nil { return false } @@ -2132,6 +2423,9 @@ func shopDrawEconomicsChanged(current *dbent.ShopProduct, price float64, minPurc current.AutoDelivery != autoDelivery || current.ProductType != productType || current.BalanceOnly != balanceOnly || + current.AllowBalancePayment != allowBalancePayment || + current.AllowPointsPayment != allowPointsPayment || + current.AllowPlatformPayment != allowPlatformPayment || current.DrawEnabled != config.Enabled || normalizeShopAmount(current.DrawMinAmount) != config.MinAmount || normalizeShopAmount(current.DrawMaxAmount) != config.MaxAmount || @@ -2154,7 +2448,7 @@ func generateShopDrawRewardPool(price float64, input *ShopDrawConfigInput) ([]fl targetCents := int64(math.Round(price * float64(config.GuaranteeCount) * config.ReturnRate * shopDrawAmountScale)) minCents := int64(math.Round(config.MinAmount * shopDrawAmountScale)) maxCents := int64(math.Round(config.MaxAmount * shopDrawAmountScale)) - if err := validateShopDrawProductConfig(price, 1, 1, true, ShopProductTypeBalanceDraw, true, config); err != nil { + if err := validateShopDrawProductConfig(price, 1, 1, true, ShopProductTypeBalanceDraw, true, true, false, false, config); err != nil { return nil, 0, err } amountCents := make([]int64, config.GuaranteeCount) @@ -2272,24 +2566,27 @@ func mapShopCategory(item *dbent.ShopCategory) ShopCategoryDTO { func mapShopProduct(item *dbent.ShopProduct, stock int) ShopProductDTO { dto := ShopProductDTO{ - ID: item.ID, - CategoryID: item.CategoryID, - Name: item.Name, - CoverURL: item.CoverURL, - Description: item.Description, - Price: item.Price, - OriginalPrice: item.OriginalPrice, - Enabled: item.Enabled, - SortOrder: item.SortOrder, - MinPurchase: item.MinPurchase, - MaxPurchase: item.MaxPurchase, - AutoDelivery: item.AutoDelivery, - ProductType: item.ProductType, - BalanceOnly: item.BalanceOnly, - Stock: stock, - StockUnlimited: item.ProductType == ShopProductTypeBalanceDraw, - CreatedAt: item.CreatedAt, - UpdatedAt: item.UpdatedAt, + ID: item.ID, + CategoryID: item.CategoryID, + Name: item.Name, + CoverURL: item.CoverURL, + Description: item.Description, + Price: item.Price, + OriginalPrice: item.OriginalPrice, + Enabled: item.Enabled, + SortOrder: item.SortOrder, + MinPurchase: item.MinPurchase, + MaxPurchase: item.MaxPurchase, + AutoDelivery: item.AutoDelivery, + ProductType: item.ProductType, + BalanceOnly: item.BalanceOnly, + AllowBalancePayment: item.AllowBalancePayment, + AllowPointsPayment: item.AllowPointsPayment, + AllowPlatformPayment: item.AllowPlatformPayment, + Stock: stock, + StockUnlimited: isShopDrawProductType(item.ProductType), + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, } if item.DrawEnabled { dto.DrawConfig = &ShopDrawConfigDTO{ @@ -2320,15 +2617,18 @@ func mapShopOrder(item *dbent.ShopOrder, paymentResp *CreateOrderResponse) ShopO ProductName: item.ProductName, ProductCoverURL: item.ProductCoverURL, ProductDescription: item.ProductDescription, + ProductType: item.ProductType, UnitPrice: item.UnitPrice, Quantity: item.Quantity, TotalAmount: item.TotalAmount, + PointsAmount: item.PointsAmount, PaymentMethod: item.PaymentMethod, PaymentOrderID: item.PaymentOrderID, Status: item.Status, DeliveredCards: delivered, DeliveredFiles: []ShopDeliveredFileDTO{}, DrawRewardAmount: item.DrawRewardAmount, + DrawRewardType: drawRewardTypeForProductType(item.ProductType), DrawCycleID: item.DrawCycleID, DrawCycleIndex: item.DrawCycleIndex, PaidAt: item.PaidAt, diff --git a/backend/internal/service/shop_test.go b/backend/internal/service/shop_test.go index 4aa92c3c3..60ff8410e 100644 --- a/backend/internal/service/shop_test.go +++ b/backend/internal/service/shop_test.go @@ -4,6 +4,7 @@ import ( "archive/zip" "bytes" "context" + "database/sql" "errors" "io" "strconv" @@ -209,6 +210,160 @@ func TestShopBalanceDrawBlocksEconomicsUpdateWhenCycleActive(t *testing.T) { require.Equal(t, "SHOP_DRAW_CYCLE_ACTIVE", errorCodeForTest(err)) } +func TestShopPointsPaymentBalanceDrawCreditsBalanceAndDeductsPoints(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + svc := NewShopService(client, nil, nil, nil) + user := createShopTestUser(t, ctx, client, "points-balance-draw@example.com") + user, err := client.User.UpdateOneID(user.ID).SetPointsBalance(10).Save(ctx) + require.NoError(t, err) + product := createShopTestBalanceDrawProduct(t, ctx, client, "Points pay balance draw product") + product, err = client.ShopProduct.UpdateOneID(product.ID).SetAllowPointsPayment(true).Save(ctx) + require.NoError(t, err) + + order, err := svc.CreateOrder(ctx, ShopCreateOrderRequest{ + UserID: user.ID, + ProductID: product.ID, + Quantity: 1, + PaymentMethod: ShopPaymentMethodPoints, + }) + require.NoError(t, err) + require.Equal(t, ShopOrderStatusCompleted, order.Status) + require.Equal(t, ShopPaymentMethodPoints, order.PaymentMethod) + require.Equal(t, ShopProductTypeBalanceDraw, order.ProductType) + require.Equal(t, "balance", order.DrawRewardType) + require.Equal(t, product.Price, normalizeShopAmount(order.PointsAmount)) + require.NotNil(t, order.DrawRewardAmount) + require.NotNil(t, order.DrawCycleID) + require.NotNil(t, order.DrawCycleIndex) + + user, err = client.User.Get(ctx, user.ID) + require.NoError(t, err) + require.Equal(t, 7.0, normalizeShopAmount(user.PointsBalance)) + require.Equal(t, normalizeShopAmount(*order.DrawRewardAmount), normalizeShopAmount(user.Balance)) + ledger, err := client.ShopBalanceLedger.Query(). + Where(shopbalanceledger.ShopOrderIDEQ(order.ID)). + Only(ctx) + require.NoError(t, err) + require.Equal(t, 0.0, normalizeShopAmount(ledger.DebitAmount)) + require.Equal(t, normalizeShopAmount(*order.DrawRewardAmount), normalizeShopAmount(ledger.CreditAmount)) + require.Equal(t, normalizeShopAmount(*order.DrawRewardAmount), normalizeShopAmount(ledger.BalanceAfter)) + require.Equal(t, 1, countShopTestPointsLedger(t, ctx, client, order.ID, "debit", "shop_order")) + require.Equal(t, 0, countShopTestPointsLedger(t, ctx, client, order.ID, "credit", "shop_draw_reward")) +} + +func TestShopProductPaymentMethodToggles(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + svc := NewShopService(client, nil, nil, nil) + user := createShopTestUser(t, ctx, client, "shop-methods@example.com") + _, err := client.User.UpdateOneID(user.ID).SetBalance(100).SetPointsBalance(100).Save(ctx) + require.NoError(t, err) + product := createShopTestProduct(t, ctx, client, "Method toggle product") + createShopTestCard(t, ctx, client, product.ID, "METHOD-CARD-1") + + _, err = client.ShopProduct.UpdateOneID(product.ID). + SetAllowBalancePayment(false). + SetAllowPointsPayment(true). + SetAllowPlatformPayment(false). + Save(ctx) + require.NoError(t, err) + + _, err = svc.CreateOrder(ctx, ShopCreateOrderRequest{ + UserID: user.ID, + ProductID: product.ID, + Quantity: 1, + PaymentMethod: ShopPaymentMethodBalance, + }) + require.Error(t, err) + require.Equal(t, "SHOP_UNSUPPORTED_PAYMENT_METHOD", errorCodeForTest(err)) + + order, err := svc.CreateOrder(ctx, ShopCreateOrderRequest{ + UserID: user.ID, + ProductID: product.ID, + Quantity: 1, + PaymentMethod: ShopPaymentMethodPoints, + }) + require.NoError(t, err) + require.Equal(t, ShopOrderStatusCompleted, order.Status) + require.Equal(t, ShopPaymentMethodPoints, order.PaymentMethod) +} + +func TestAdminProductRejectsNoPaymentMethods(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + svc := NewShopService(client, nil, nil, nil) + allowBalance := false + allowPoints := false + allowPlatform := false + + _, err := svc.AdminCreateProduct(ctx, ShopCreateProductRequest{ + Name: "No payment methods", + Price: 1, + MinPurchase: 1, + MaxPurchase: 1, + ProductType: ShopProductTypeCardKey, + AllowBalancePayment: &allowBalance, + AllowPointsPayment: &allowPoints, + AllowPlatformPayment: &allowPlatform, + }) + require.Error(t, err) + require.Equal(t, "SHOP_UNSUPPORTED_PAYMENT_METHOD", errorCodeForTest(err)) +} + +func TestShopPointsDrawCreditsPointsReward(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + svc := NewShopService(client, nil, nil, nil) + user := createShopTestUser(t, ctx, client, "points-draw@example.com") + user, err := client.User.UpdateOneID(user.ID).SetBalance(10).SetPointsBalance(10).Save(ctx) + require.NoError(t, err) + product := createShopTestPointsDrawProduct(t, ctx, client, "Points draw product") + + balanceOrder, err := svc.CreateOrder(ctx, ShopCreateOrderRequest{ + UserID: user.ID, + ProductID: product.ID, + Quantity: 1, + PaymentMethod: ShopPaymentMethodBalance, + }) + require.NoError(t, err) + require.Equal(t, ShopOrderStatusCompleted, balanceOrder.Status) + require.Equal(t, ShopProductTypePointsDraw, balanceOrder.ProductType) + require.Equal(t, "points", balanceOrder.DrawRewardType) + require.NotNil(t, balanceOrder.DrawRewardAmount) + user, err = client.User.Get(ctx, user.ID) + require.NoError(t, err) + require.Equal(t, 7.0, normalizeShopAmount(user.Balance)) + require.Equal(t, normalizeShopAmount(10+*balanceOrder.DrawRewardAmount), normalizeShopAmount(user.PointsBalance)) + ledger, err := client.ShopBalanceLedger.Query(). + Where(shopbalanceledger.ShopOrderIDEQ(balanceOrder.ID)). + Only(ctx) + require.NoError(t, err) + require.Equal(t, product.Price, normalizeShopAmount(ledger.DebitAmount)) + require.Equal(t, 0.0, normalizeShopAmount(ledger.CreditAmount)) + require.Equal(t, 1, countShopTestPointsLedger(t, ctx, client, balanceOrder.ID, "credit", "shop_draw_reward")) + + pointsOrder, err := svc.CreateOrder(ctx, ShopCreateOrderRequest{ + UserID: user.ID, + ProductID: product.ID, + Quantity: 1, + PaymentMethod: ShopPaymentMethodPoints, + }) + require.NoError(t, err) + require.Equal(t, ShopOrderStatusCompleted, pointsOrder.Status) + require.Equal(t, ShopProductTypePointsDraw, pointsOrder.ProductType) + require.Equal(t, "points", pointsOrder.DrawRewardType) + require.NotNil(t, pointsOrder.DrawRewardAmount) + user, err = client.User.Get(ctx, user.ID) + require.NoError(t, err) + expectedPoints := 10 + *balanceOrder.DrawRewardAmount - product.Price + *pointsOrder.DrawRewardAmount + require.Equal(t, normalizeShopAmount(expectedPoints), normalizeShopAmount(user.PointsBalance)) + require.Equal(t, 7.0, normalizeShopAmount(user.Balance)) + require.Equal(t, 0, countShopTestBalanceLedger(t, ctx, client, pointsOrder.ID)) + require.Equal(t, 1, countShopTestPointsLedger(t, ctx, client, pointsOrder.ID, "debit", "shop_order")) + require.Equal(t, 1, countShopTestPointsLedger(t, ctx, client, pointsOrder.ID, "credit", "shop_draw_reward")) +} + func TestShopPlatformFulfillmentReallocatesAvailableCardAfterReservationReleased(t *testing.T) { ctx := context.Background() client := newPaymentConfigServiceTestClient(t) @@ -396,6 +551,32 @@ func createShopTestBalanceDrawProduct(t *testing.T, ctx context.Context, client SetAutoDelivery(true). SetProductType(ShopProductTypeBalanceDraw). SetBalanceOnly(true). + SetAllowBalancePayment(true). + SetAllowPlatformPayment(false). + SetDrawEnabled(true). + SetDrawMinAmount(1). + SetDrawMaxAmount(5). + SetDrawGuaranteeCount(20). + SetDrawReturnRate(1). + Save(ctx) + require.NoError(t, err) + return product +} + +func createShopTestPointsDrawProduct(t *testing.T, ctx context.Context, client *dbent.Client, name string) *dbent.ShopProduct { + t.Helper() + product, err := client.ShopProduct.Create(). + SetName(name). + SetPrice(3). + SetEnabled(true). + SetMinPurchase(1). + SetMaxPurchase(1). + SetAutoDelivery(true). + SetProductType(ShopProductTypePointsDraw). + SetBalanceOnly(true). + SetAllowBalancePayment(true). + SetAllowPointsPayment(true). + SetAllowPlatformPayment(false). SetDrawEnabled(true). SetDrawMinAmount(1). SetDrawMaxAmount(5). @@ -458,6 +639,37 @@ func execShopTestSQL(t *testing.T, ctx context.Context, client *dbent.Client, qu require.NoError(t, err) } +func queryShopTestRow(t *testing.T, ctx context.Context, client *dbent.Client, query string, args ...any) *sql.Row { + t.Helper() + drv, ok := client.Driver().(*entsql.Driver) + require.True(t, ok, "test client must use ent sql driver") + return drv.DB().QueryRowContext(ctx, query, args...) +} + +func countShopTestBalanceLedger(t *testing.T, ctx context.Context, client *dbent.Client, orderID int64) int { + t.Helper() + count, err := client.ShopBalanceLedger.Query(). + Where(shopbalanceledger.ShopOrderIDEQ(orderID)). + Count(ctx) + require.NoError(t, err) + return count +} + +func countShopTestPointsLedger(t *testing.T, ctx context.Context, client *dbent.Client, orderID int64, direction, reason string) int { + t.Helper() + var count int + err := queryShopTestRow(t, ctx, client, ` + SELECT COUNT(*) + FROM points_ledger + WHERE ref_type = 'shop_order' + AND ref_id = $1 + AND direction = $2 + AND reason = $3 + `, orderID, direction, reason).Scan(&count) + require.NoError(t, err) + return count +} + func newShopFileCardTestSettingRepo() *paymentConfigSettingRepoStub { return &paymentConfigSettingRepoStub{values: map[string]string{ settingShopFileCardOSSEnabled: "true", diff --git a/backend/internal/service/usage_billing.go b/backend/internal/service/usage_billing.go index 92a352e2e..e28f9320f 100644 --- a/backend/internal/service/usage_billing.go +++ b/backend/internal/service/usage_billing.go @@ -38,12 +38,13 @@ type UsageBillingCommand struct { ImageCount int MediaType string - BalanceCost float64 - SubscriptionCost float64 + BalanceCost float64 + PreferPointsBilling bool + SubscriptionCost float64 PrivateGroupCommissionCost float64 - APIKeyQuotaCost float64 - APIKeyRateLimitCost float64 - AccountQuotaCost float64 + APIKeyQuotaCost float64 + APIKeyRateLimitCost float64 + AccountQuotaCost float64 LeaseUsageRequests int64 LeaseUsageTokens int64 @@ -85,7 +86,7 @@ func buildUsageBillingFingerprint(c *UsageBillingCommand) string { return "" } raw := fmt.Sprintf( - "%d|%d|%d|%s|%s|%s|%s|%d|%d|%d|%d|%d|%d|%s|%d|%0.10f|%0.10f|%0.10f|%0.10f|%0.10f|%0.10f", + "%d|%d|%d|%s|%s|%s|%s|%d|%d|%d|%d|%d|%d|%s|%d|%0.10f|%t|%0.10f|%0.10f|%0.10f|%0.10f|%0.10f", c.UserID, c.AccountID, c.APIKeyID, @@ -102,6 +103,7 @@ func buildUsageBillingFingerprint(c *UsageBillingCommand) string { strings.TrimSpace(c.MediaType), valueOrZero(c.SubscriptionID), c.BalanceCost, + c.PreferPointsBilling, c.SubscriptionCost, c.PrivateGroupCommissionCost, c.APIKeyQuotaCost, @@ -145,6 +147,10 @@ type UsageBillingApplyResult struct { Applied bool APIKeyQuotaExhausted bool NewBalance *float64 // post-deduction balance (nil = no balance deduction) + NewPointsBalance *float64 // post-deduction points balance (nil = no points deduction) + PointsDeducted float64 // points deducted for the request + BalanceDeducted float64 // balance deducted for the request + CommissionDeducted float64 // balance deducted for private-group commission QuotaState *AccountQuotaState // post-increment quota state (nil = no quota increment) UsageLogID *int64 // persisted usage log id when the billing transaction wrote one BalanceCreditUserIDs []int64 // users credited by settlement side effects; callers should invalidate balance caches diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go index e29d282eb..1eabbeb48 100644 --- a/backend/internal/service/usage_log.go +++ b/backend/internal/service/usage_log.go @@ -144,6 +144,9 @@ type UsageLog struct { TotalCost float64 ActualCost float64 RateMultiplier float64 + PointsDeducted float64 + BalanceDeducted float64 + BillingWalletType string // AccountRateMultiplier 账号计费倍率快照(nil 表示历史数据,按 1.0 处理) AccountRateMultiplier *float64 // AccountStatsCost 账号统计定价预计算费用(nil = 使用默认公式 total_cost × account_rate_multiplier) diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go index f98336111..20c0d7906 100644 --- a/backend/internal/service/user.go +++ b/backend/internal/service/user.go @@ -7,22 +7,24 @@ import ( ) type User struct { - ID int64 - Email string - Username string - Notes string - AvatarURL string - AvatarSource string - AvatarMIME string - AvatarByteSize int - AvatarSHA256 string - PasswordHash string - Role string - Balance float64 - Concurrency int - Status string - AllowedGroups []int64 - TokenVersion int64 // Incremented on password change to invalidate existing tokens + ID int64 + Email string + Username string + Notes string + AvatarURL string + AvatarSource string + AvatarMIME string + AvatarByteSize int + AvatarSHA256 string + PasswordHash string + Role string + Balance float64 + PointsBalance float64 + PreferPointsBilling bool + Concurrency int + Status string + AllowedGroups []int64 + TokenVersion int64 // Incremented on password change to invalidate existing tokens // TokenVersionResolved indicates TokenVersion already contains the fingerprint-derived // value expected in JWT claims and refresh-token state. TokenVersionResolved bool @@ -70,6 +72,14 @@ func (u *User) IsActive() bool { return u.Status == StatusActive } +func CanUsePointsForUsage(user *User) bool { + return user != nil && user.PreferPointsBilling && user.PointsBalance > 0 +} + +func HasUsageBillingFunds(user *User) bool { + return user != nil && (user.Balance > 0 || CanUsePointsForUsage(user)) +} + // CanBindGroup checks whether a user can bind to a given group. // For standard groups: // - Public groups (non-exclusive): all users can bind diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index e8e1147a5..237dd221d 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -184,6 +184,7 @@ type UpdateProfileRequest struct { Username *string `json:"username"` AvatarURL *string `json:"avatar_url"` Concurrency *int `json:"concurrency"` + PreferPointsBilling *bool `json:"prefer_points_billing"` BalanceNotifyEnabled *bool `json:"balance_notify_enabled"` BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"` } @@ -390,28 +391,25 @@ func (s *UserService) UnbindUserAuthProviderWithResult(ctx context.Context, user // UpdateProfile 更新用户资料 func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req UpdateProfileRequest) (*User, error) { if txRunner, ok := s.userRepo.(userProfileIdentityTxRunner); ok { - var ( - updated *User - oldConcurrency int - ) + var updated *User if err := txRunner.WithUserProfileIdentityTx(ctx, func(txCtx context.Context) error { var err error - updated, oldConcurrency, err = s.updateProfile(txCtx, userID, req) + updated, _, err = s.updateProfile(txCtx, userID, req) return err }); err != nil { return nil, err } - if s.authCacheInvalidator != nil && updated != nil && updated.Concurrency != oldConcurrency { + if s.authCacheInvalidator != nil && updated != nil { s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID) } return updated, nil } - updated, oldConcurrency, err := s.updateProfile(ctx, userID, req) + updated, _, err := s.updateProfile(ctx, userID, req) if err != nil { return nil, err } - if s.authCacheInvalidator != nil && updated.Concurrency != oldConcurrency { + if s.authCacheInvalidator != nil && updated != nil { s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID) } return updated, nil @@ -452,6 +450,9 @@ func (s *UserService) updateProfile(ctx context.Context, userID int64, req Updat if req.Concurrency != nil { user.Concurrency = *req.Concurrency } + if req.PreferPointsBilling != nil { + user.PreferPointsBilling = *req.PreferPointsBilling + } if req.BalanceNotifyEnabled != nil { user.BalanceNotifyEnabled = *req.BalanceNotifyEnabled diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go index ff55c2a50..5c4bdc502 100644 --- a/backend/internal/service/user_service_test.go +++ b/backend/internal/service/user_service_test.go @@ -486,6 +486,29 @@ func TestUnbindUserAuthProviderRemovesProviderAndReturnsUpdatedProfile(t *testin require.True(t, summaries.LinuxDo.CanBind) } +func TestUpdateProfileInvalidateAuthCacheWhenPreferPointsBillingChanges(t *testing.T) { + repo := &mockUserRepo{ + getByIDUser: &User{ + ID: 16, + Email: "points-profile@example.com", + Username: "points-profile", + Concurrency: 2, + PreferPointsBilling: false, + }, + } + invalidator := &mockAuthCacheInvalidator{} + svc := NewUserService(repo, nil, invalidator, nil) + preferPoints := true + + updated, err := svc.UpdateProfile(context.Background(), 16, UpdateProfileRequest{ + PreferPointsBilling: &preferPoints, + }) + + require.NoError(t, err) + require.True(t, updated.PreferPointsBilling) + require.Equal(t, []int64{16}, invalidator.invalidatedUserIDs) +} + func TestGetProfileIdentitySummaries_HidesBindActionWhenProviderExplicitlyDisabled(t *testing.T) { repo := &mockUserRepo{ getByIDUser: &User{ diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 6eeaf3393..3b59831de 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -438,6 +438,17 @@ func ProvideBillingCacheService( return NewBillingCacheService(cache, userRepo, subRepo, apiKeyRepo, rpmCache, rateRepo, cfg) } +// ProvideGroupRateScheduleService creates and starts the group rate schedule worker. +func ProvideGroupRateScheduleService( + repo GroupRateScheduleRepository, + groupRepo GroupRepository, + authCacheInvalidator APIKeyAuthCacheInvalidator, +) *GroupRateScheduleService { + svc := NewGroupRateScheduleService(repo, groupRepo, authCacheInvalidator, defaultGroupRateScheduleInterval) + svc.Start() + return svc +} + func ProvideAuthService( entClient *dbent.Client, userRepo UserRepository, @@ -535,6 +546,7 @@ var ProviderSet = wire.NewSet( ProvideAPIKeyService, ProvideAPIKeyAuthCacheInvalidator, NewGroupService, + ProvideGroupRateScheduleService, ProvideAccountService, NewAccountSharePolicyService, NewProxyService, diff --git a/backend/migrations/160_points.sql b/backend/migrations/160_points.sql new file mode 100644 index 000000000..2392f40b8 --- /dev/null +++ b/backend/migrations/160_points.sql @@ -0,0 +1,46 @@ +-- Points are a non-withdrawable platform credit balance. + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS points_balance DECIMAL(20, 10) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS prefer_points_billing BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE shop_products + ADD COLUMN IF NOT EXISTS allow_points_payment BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE shop_orders + ADD COLUMN IF NOT EXISTS points_amount DECIMAL(20, 2) NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS points_ledger ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + direction VARCHAR(10) NOT NULL, + amount DECIMAL(20, 10) NOT NULL, + reason VARCHAR(50) NOT NULL, + ref_type VARCHAR(50) NOT NULL, + ref_id BIGINT, + balance_before DECIMAL(20, 10) NOT NULL, + balance_after DECIMAL(20, 10) NOT NULL, + operator_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT points_ledger_direction_check + CHECK (direction IN ('debit', 'credit')), + CONSTRAINT points_ledger_amount_check + CHECK (amount >= 0), + CONSTRAINT points_ledger_balance_check + CHECK (balance_before >= 0 AND balance_after >= 0) +); + +CREATE INDEX IF NOT EXISTS idx_points_ledger_user_time + ON points_ledger (user_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_points_ledger_ref + ON points_ledger (ref_type, ref_id); + +CREATE INDEX IF NOT EXISTS idx_points_ledger_operator_time + ON points_ledger (operator_user_id, created_at DESC) + WHERE operator_user_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_points_ledger_unique_ref_reason + ON points_ledger (user_id, direction, reason, ref_type, ref_id) + WHERE ref_id IS NOT NULL; diff --git a/backend/migrations/161_shop_points_draw_revenue_points.sql b/backend/migrations/161_shop_points_draw_revenue_points.sql new file mode 100644 index 000000000..dd92467f2 --- /dev/null +++ b/backend/migrations/161_shop_points_draw_revenue_points.sql @@ -0,0 +1,38 @@ +ALTER TABLE shop_products + DROP CONSTRAINT IF EXISTS shop_products_product_type_valid, + ADD CONSTRAINT shop_products_product_type_valid CHECK (product_type IN ('card_key', 'balance_draw', 'points_draw')); + +ALTER TABLE shop_products + DROP CONSTRAINT IF EXISTS shop_products_draw_config_valid, + ADD CONSTRAINT shop_products_draw_config_valid CHECK ( + ( + product_type = 'card_key' + AND draw_enabled = FALSE + ) + OR + ( + product_type IN ('balance_draw', 'points_draw') + AND balance_only = TRUE + AND auto_delivery = TRUE + AND min_purchase = 1 + AND max_purchase = 1 + AND draw_enabled = TRUE + AND draw_min_amount > 0 + AND draw_max_amount >= draw_min_amount + AND draw_guarantee_count > 0 + AND draw_return_rate > 0 + AND ROUND(price * draw_guarantee_count * draw_return_rate * 100) >= ROUND(draw_min_amount * 100) * draw_guarantee_count + AND ROUND(price * draw_guarantee_count * draw_return_rate * 100) <= ROUND(draw_max_amount * 100) * draw_guarantee_count + ) + ); + +ALTER TABLE shop_orders + ADD COLUMN IF NOT EXISTS product_type VARCHAR(30) NOT NULL DEFAULT 'card_key'; + +UPDATE shop_orders o +SET product_type = p.product_type +FROM shop_products p +WHERE o.product_id = p.id + AND (o.product_type IS NULL OR o.product_type = 'card_key'); + +CREATE INDEX IF NOT EXISTS idx_shop_orders_product_type ON shop_orders(product_type); diff --git a/backend/migrations/162_shop_product_payment_methods.sql b/backend/migrations/162_shop_product_payment_methods.sql new file mode 100644 index 000000000..ae629f133 --- /dev/null +++ b/backend/migrations/162_shop_product_payment_methods.sql @@ -0,0 +1,44 @@ +ALTER TABLE shop_products + ADD COLUMN IF NOT EXISTS allow_balance_payment BOOLEAN NOT NULL DEFAULT TRUE, + ADD COLUMN IF NOT EXISTS allow_platform_payment BOOLEAN NOT NULL DEFAULT TRUE; + +UPDATE shop_products +SET allow_platform_payment = FALSE +WHERE balance_only = TRUE; + +UPDATE shop_products +SET allow_balance_payment = TRUE +WHERE product_type IN ('balance_draw', 'points_draw'); + +ALTER TABLE shop_products + DROP CONSTRAINT IF EXISTS shop_products_payment_method_valid, + ADD CONSTRAINT shop_products_payment_method_valid CHECK ( + allow_balance_payment = TRUE + OR allow_points_payment = TRUE + OR allow_platform_payment = TRUE + ); + +ALTER TABLE shop_products + DROP CONSTRAINT IF EXISTS shop_products_draw_config_valid, + ADD CONSTRAINT shop_products_draw_config_valid CHECK ( + ( + product_type = 'card_key' + AND draw_enabled = FALSE + ) + OR + ( + product_type IN ('balance_draw', 'points_draw') + AND balance_only = TRUE + AND allow_balance_payment = TRUE + AND auto_delivery = TRUE + AND min_purchase = 1 + AND max_purchase = 1 + AND draw_enabled = TRUE + AND draw_min_amount > 0 + AND draw_max_amount >= draw_min_amount + AND draw_guarantee_count > 0 + AND draw_return_rate > 0 + AND ROUND(price * draw_guarantee_count * draw_return_rate * 100) >= ROUND(draw_min_amount * 100) * draw_guarantee_count + AND ROUND(price * draw_guarantee_count * draw_return_rate * 100) <= ROUND(draw_max_amount * 100) * draw_guarantee_count + ) + ); diff --git a/backend/migrations/163_shop_draw_payment_methods.sql b/backend/migrations/163_shop_draw_payment_methods.sql new file mode 100644 index 000000000..f10602e5e --- /dev/null +++ b/backend/migrations/163_shop_draw_payment_methods.sql @@ -0,0 +1,23 @@ +ALTER TABLE shop_products + DROP CONSTRAINT IF EXISTS shop_products_draw_config_valid, + ADD CONSTRAINT shop_products_draw_config_valid CHECK ( + ( + product_type = 'card_key' + AND draw_enabled = FALSE + ) + OR + ( + product_type IN ('balance_draw', 'points_draw') + AND balance_only = TRUE + AND auto_delivery = TRUE + AND min_purchase = 1 + AND max_purchase = 1 + AND draw_enabled = TRUE + AND draw_min_amount > 0 + AND draw_max_amount >= draw_min_amount + AND draw_guarantee_count > 0 + AND draw_return_rate > 0 + AND ROUND(price * draw_guarantee_count * draw_return_rate * 100) >= ROUND(draw_min_amount * 100) * draw_guarantee_count + AND ROUND(price * draw_guarantee_count * draw_return_rate * 100) <= ROUND(draw_max_amount * 100) * draw_guarantee_count + ) + ); diff --git a/backend/migrations/164_group_rate_schedules.sql b/backend/migrations/164_group_rate_schedules.sql new file mode 100644 index 000000000..cc5ab799b --- /dev/null +++ b/backend/migrations/164_group_rate_schedules.sql @@ -0,0 +1,47 @@ +-- 分组按时间区间自动切换倍率策略。 +-- start_minute/end_minute 使用本系统配置时区下的一天内分钟数,区间语义为 [start_minute, end_minute)。 +CREATE TABLE IF NOT EXISTS group_rate_schedules ( + id BIGSERIAL PRIMARY KEY, + group_id BIGINT NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + start_minute INTEGER NOT NULL, + end_minute INTEGER NOT NULL, + rate_multiplier DECIMAL(10,4) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_group_rate_schedules_start_minute + CHECK (start_minute >= 0 AND start_minute < 1440), + CONSTRAINT chk_group_rate_schedules_end_minute + CHECK (end_minute > 0 AND end_minute <= 1440), + CONSTRAINT chk_group_rate_schedules_range + CHECK (start_minute < end_minute), + CONSTRAINT chk_group_rate_schedules_multiplier + CHECK (rate_multiplier > 0) +); + +CREATE INDEX IF NOT EXISTS idx_group_rate_schedules_group_enabled + ON group_rate_schedules(group_id, enabled); + +CREATE INDEX IF NOT EXISTS idx_group_rate_schedules_group_range + ON group_rate_schedules(group_id, start_minute, end_minute); + +COMMENT ON TABLE group_rate_schedules IS '分组时间区间倍率策略'; +COMMENT ON COLUMN group_rate_schedules.start_minute IS '开始分钟,闭区间,0 表示 00:00'; +COMMENT ON COLUMN group_rate_schedules.end_minute IS '结束分钟,开区间,1440 表示 24:00'; +COMMENT ON COLUMN group_rate_schedules.rate_multiplier IS '该时间区间内自动切换到的分组倍率'; +COMMENT ON COLUMN group_rate_schedules.enabled IS '是否启用该时间区间'; + +-- 运行态状态:进入时间段时保存原倍率,离开所有时间段后恢复原倍率。 +CREATE TABLE IF NOT EXISTS group_rate_schedule_states ( + group_id BIGINT PRIMARY KEY REFERENCES groups(id) ON DELETE CASCADE, + base_rate_multiplier DECIMAL(10,4) NOT NULL, + applied_schedule_id BIGINT NULL REFERENCES group_rate_schedules(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_group_rate_schedule_states_base_multiplier + CHECK (base_rate_multiplier > 0) +); + +COMMENT ON TABLE group_rate_schedule_states IS '分组时间区间倍率策略运行态,用于恢复原倍率'; +COMMENT ON COLUMN group_rate_schedule_states.base_rate_multiplier IS '进入策略时间段前的分组原倍率'; +COMMENT ON COLUMN group_rate_schedule_states.applied_schedule_id IS '当前最近一次应用的策略 ID'; diff --git a/frontend/src/api/admin/groups.ts b/frontend/src/api/admin/groups.ts index d601f6731..1713742e8 100644 --- a/frontend/src/api/admin/groups.ts +++ b/frontend/src/api/admin/groups.ts @@ -175,6 +175,24 @@ export interface GroupRateMultiplierEntry { rpm_override?: number | null } +export interface GroupRateSchedule { + id: number + group_id: number + start_minute: number + end_minute: number + rate_multiplier: number + enabled: boolean + created_at: string + updated_at: string +} + +export interface GroupRateScheduleInput { + start_minute: number + end_minute: number + rate_multiplier: number + enabled: boolean +} + /** * Get rate multipliers for users in a group * @param id - Group ID @@ -187,6 +205,22 @@ export async function getGroupRateMultipliers(id: number): Promise { + const { data } = await apiClient.get(`/admin/groups/${id}/rate-schedules`) + return data +} + +export async function replaceGroupRateSchedules( + id: number, + entries: GroupRateScheduleInput[] +): Promise { + const { data } = await apiClient.put( + `/admin/groups/${id}/rate-schedules`, + { entries } + ) + return data +} + /** * Update group sort orders * @param updates - Array of { id, sort_order } objects @@ -319,6 +353,8 @@ export const groupsAPI = { toggleStatus, getStats, getGroupApiKeys, + getGroupRateSchedules, + replaceGroupRateSchedules, getGroupRateMultipliers, clearGroupRateMultipliers, batchSetGroupRateMultipliers, diff --git a/frontend/src/api/admin/revenue.ts b/frontend/src/api/admin/revenue.ts index 1eaa2efc2..b89c62204 100644 --- a/frontend/src/api/admin/revenue.ts +++ b/frontend/src/api/admin/revenue.ts @@ -37,6 +37,9 @@ export interface RevenueUsageStats { total_tokens: number standard_cost: number consumed_revenue: number + balance_consumed_amount: number + points_consumed_amount: number + points_issued_amount: number account_cost: number } @@ -67,6 +70,9 @@ export interface RevenueTrendPoint { net_paid_amount: number requests: number consumed_revenue: number + balance_consumed_amount: number + points_consumed_amount: number + points_issued_amount: number account_cost: number usage_gross_profit: number affiliate_rebate: number diff --git a/frontend/src/api/admin/store.ts b/frontend/src/api/admin/store.ts index f72e01ef7..d5f15c301 100644 --- a/frontend/src/api/admin/store.ts +++ b/frontend/src/api/admin/store.ts @@ -25,6 +25,9 @@ function productToView(product: StoreProduct): StoreProduct { purchase_limit: product.max_purchase, product_type: product.product_type || 'card_key', balance_only: product.balance_only === true, + allow_balance_payment: product.allow_balance_payment !== false, + allow_points_payment: product.allow_points_payment === true, + allow_platform_payment: product.allow_platform_payment !== false, stock_unlimited: product.stock_unlimited === true, status: product.enabled ? 'active' : 'inactive', } @@ -41,6 +44,9 @@ function cardToView(card: StoreCard): StoreCard { function normalizeOrder(order: StoreOrder): StoreOrder { return { ...order, + product_type: order.product_type || 'card_key', + draw_reward_type: order.draw_reward_type || null, + points_amount: Number(order.points_amount || 0), delivered_cards: order.delivered_cards || [], delivered_files: order.delivered_files || [], } @@ -92,6 +98,9 @@ function productPayload(data: UpsertStoreProductRequest) { auto_delivery: data.auto_delivery ?? true, product_type: data.product_type || 'card_key', balance_only: data.balance_only === true, + allow_balance_payment: data.allow_balance_payment !== false, + allow_points_payment: data.allow_points_payment === true, + allow_platform_payment: data.allow_platform_payment !== false, draw_config: data.draw_config ?? null, } return payload diff --git a/frontend/src/api/admin/users.ts b/frontend/src/api/admin/users.ts index 3c75a6c4f..0612bc5cf 100644 --- a/frontend/src/api/admin/users.ts +++ b/frontend/src/api/admin/users.ts @@ -166,6 +166,28 @@ export async function updateBalance( return data } +/** + * Update user points + * @param id - User ID + * @param points - Points amount + * @param operation - Operation type ('set', 'add', 'subtract') + * @param notes - Optional notes for the points adjustment + * @returns Updated user + */ +export async function updatePoints( + id: number, + points: number, + operation: 'set' | 'add' | 'subtract' = 'set', + notes?: string +): Promise { + const { data } = await apiClient.post(`/admin/users/${id}/points`, { + points, + operation, + notes: notes || '' + }) + return data +} + /** * Update user concurrency * @param id - User ID @@ -304,6 +326,7 @@ export const usersAPI = { update, delete: deleteUser, updateBalance, + updatePoints, updateConcurrency, toggleStatus, getUserApiKeys, diff --git a/frontend/src/api/redeem.ts b/frontend/src/api/redeem.ts index 22abf4d80..3fe84f7f2 100644 --- a/frontend/src/api/redeem.ts +++ b/frontend/src/api/redeem.ts @@ -31,20 +31,24 @@ export interface RedeemHistoryItem { * @returns Redemption result with updated balance or concurrency */ export async function redeem(code: string): Promise<{ - message: string + message?: string type: string value: number new_balance?: number new_concurrency?: number + group_name?: string + validity_days?: number }> { const payload: RedeemCodeRequest = { code } const { data } = await apiClient.post<{ - message: string + message?: string type: string value: number new_balance?: number new_concurrency?: number + group_name?: string + validity_days?: number }>('/redeem', payload) return data diff --git a/frontend/src/api/store.ts b/frontend/src/api/store.ts index 9ecb45983..38e9e09f2 100644 --- a/frontend/src/api/store.ts +++ b/frontend/src/api/store.ts @@ -22,6 +22,9 @@ function normalizeProduct(product: T): T { purchase_limit: product.max_purchase, product_type: product.product_type || 'card_key', balance_only: product.balance_only === true, + allow_balance_payment: product.allow_balance_payment !== false, + allow_points_payment: product.allow_points_payment === true, + allow_platform_payment: product.allow_platform_payment !== false, stock_unlimited: product.stock_unlimited === true, status: product.enabled ? 'active' : 'inactive', } @@ -30,6 +33,9 @@ function normalizeProduct(product: T): T { function normalizeOrder(order: StoreOrder): StoreOrder { return { ...order, + product_type: order.product_type || 'card_key', + points_amount: Number(order.points_amount || 0), + draw_reward_type: order.draw_reward_type || null, delivered_cards: order.delivered_cards || [], delivered_files: order.delivered_files || [], } diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index 4f153cbb9..504eab990 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -42,6 +42,7 @@ export async function updateProfile(profile: { balance_notify_enabled?: boolean balance_notify_threshold?: number | null balance_notify_extra_emails?: NotifyEmailEntry[] + prefer_points_billing?: boolean }): Promise { const { data } = await apiClient.put('/user', profile) return data diff --git a/frontend/src/components/admin/group/GroupRateScheduleModal.vue b/frontend/src/components/admin/group/GroupRateScheduleModal.vue new file mode 100644 index 000000000..bd8a957af --- /dev/null +++ b/frontend/src/components/admin/group/GroupRateScheduleModal.vue @@ -0,0 +1,364 @@ + + + + + diff --git a/frontend/src/components/admin/user/UserBalanceHistoryModal.vue b/frontend/src/components/admin/user/UserBalanceHistoryModal.vue index 1a79e4e3a..7ae9cb84b 100644 --- a/frontend/src/components/admin/user/UserBalanceHistoryModal.vue +++ b/frontend/src/components/admin/user/UserBalanceHistoryModal.vue @@ -197,6 +197,8 @@ const typeOptions = computed(() => [ { value: '', label: t('admin.users.allTypes') }, { value: 'balance', label: t('admin.users.typeBalance') }, { value: 'admin_balance', label: t('admin.users.typeAdminBalance') }, + { value: 'points', label: t('admin.users.typePoints') }, + { value: 'admin_points', label: t('admin.users.typeAdminPoints') }, { value: 'concurrency', label: t('admin.users.typeConcurrency') }, { value: 'admin_concurrency', label: t('admin.users.typeAdminConcurrency') }, { value: 'subscription', label: t('admin.users.typeSubscription') } @@ -232,16 +234,20 @@ const loadHistory = async (page: number) => { } // Helper: check if admin type -const isAdminType = (type: string) => type === 'admin_balance' || type === 'admin_concurrency' +const isAdminType = (type: string) => type === 'admin_balance' || type === 'admin_points' || type === 'admin_concurrency' // Helper: check if balance type (includes admin_balance) const isBalanceType = (type: string) => type === 'balance' || type === 'admin_balance' +// Helper: check if points type (includes admin_points) +const isPointsType = (type: string) => type === 'points' || type === 'admin_points' + // Helper: check if subscription type const isSubscriptionType = (type: string) => type === 'subscription' // Icon name based on type const getIconName = (item: BalanceHistoryItem) => { + if (isPointsType(item.type)) return 'gift' if (isBalanceType(item.type)) return 'dollar' if (isSubscriptionType(item.type)) return 'badge' return 'bolt' // concurrency @@ -249,6 +255,11 @@ const getIconName = (item: BalanceHistoryItem) => { // Icon background color const getIconBg = (item: BalanceHistoryItem) => { + if (isPointsType(item.type)) { + return item.value >= 0 + ? 'bg-cyan-100 dark:bg-cyan-900/30' + : 'bg-red-100 dark:bg-red-900/30' + } if (isBalanceType(item.type)) { return item.value >= 0 ? 'bg-emerald-100 dark:bg-emerald-900/30' @@ -262,6 +273,11 @@ const getIconBg = (item: BalanceHistoryItem) => { // Icon text color const getIconColor = (item: BalanceHistoryItem) => { + if (isPointsType(item.type)) { + return item.value >= 0 + ? 'text-cyan-600 dark:text-cyan-400' + : 'text-red-600 dark:text-red-400' + } if (isBalanceType(item.type)) { return item.value >= 0 ? 'text-emerald-600 dark:text-emerald-400' @@ -275,6 +291,11 @@ const getIconColor = (item: BalanceHistoryItem) => { // Value text color const getValueColor = (item: BalanceHistoryItem) => { + if (isPointsType(item.type)) { + return item.value >= 0 + ? 'text-cyan-600 dark:text-cyan-400' + : 'text-red-600 dark:text-red-400' + } if (isBalanceType(item.type)) { return item.value >= 0 ? 'text-emerald-600 dark:text-emerald-400' @@ -293,6 +314,10 @@ const getItemTitle = (item: BalanceHistoryItem) => { return t('redeem.balanceAddedRedeem') case 'admin_balance': return item.value >= 0 ? t('redeem.balanceAddedAdmin') : t('redeem.balanceDeductedAdmin') + case 'points': + return t('redeem.pointsAddedRedeem') + case 'admin_points': + return item.value >= 0 ? t('redeem.pointsAddedAdmin') : t('redeem.pointsDeductedAdmin') case 'concurrency': return t('redeem.concurrencyAddedRedeem') case 'admin_concurrency': @@ -310,6 +335,10 @@ const formatValue = (item: BalanceHistoryItem) => { const sign = item.value >= 0 ? '+' : '' return `${sign}$${item.value.toFixed(2)}` } + if (isPointsType(item.type)) { + const sign = item.value >= 0 ? '+' : '' + return `${sign}${item.value.toFixed(2)}` + } if (isSubscriptionType(item.type)) { const days = item.validity_days || Math.round(item.value) const groupName = item.group?.name || '' diff --git a/frontend/src/components/admin/user/UserPointsModal.vue b/frontend/src/components/admin/user/UserPointsModal.vue new file mode 100644 index 000000000..61d6b6cbe --- /dev/null +++ b/frontend/src/components/admin/user/UserPointsModal.vue @@ -0,0 +1,114 @@ + + + diff --git a/frontend/src/components/auth/EmailOAuthButtons.vue b/frontend/src/components/auth/EmailOAuthButtons.vue index 87dff216c..beaffc035 100644 --- a/frontend/src/components/auth/EmailOAuthButtons.vue +++ b/frontend/src/components/auth/EmailOAuthButtons.vue @@ -43,6 +43,7 @@ const props = withDefaults(defineProps<{ githubEnabled?: boolean googleEnabled?: boolean showDivider?: boolean + beforeStart?: () => boolean }>(), { showDivider: true }) @@ -72,6 +73,9 @@ function providerLabel(provider: EmailOAuthProvider): string { } function startLogin(provider: EmailOAuthProvider): void { + if (props.beforeStart && !props.beforeStart()) { + return + } const redirectTo = (route.query.redirect as string) || '/dashboard' const affiliateCode = resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code) storeOAuthAffiliateCode(affiliateCode) diff --git a/frontend/src/components/auth/LinuxDoOAuthSection.vue b/frontend/src/components/auth/LinuxDoOAuthSection.vue index 6c8f5fd20..64acc7836 100644 --- a/frontend/src/components/auth/LinuxDoOAuthSection.vue +++ b/frontend/src/components/auth/LinuxDoOAuthSection.vue @@ -49,6 +49,7 @@ const props = withDefaults(defineProps<{ affCode?: string loginAgreementRevision?: string showDivider?: boolean + beforeStart?: () => boolean }>(), { showDivider: true }) @@ -57,6 +58,9 @@ const route = useRoute() const { t } = useI18n() function startLogin(): void { + if (props.beforeStart && !props.beforeStart()) { + return + } const redirectTo = (route.query.redirect as string) || '/dashboard' storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code)) const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1' diff --git a/frontend/src/components/auth/LoginAgreementPrompt.vue b/frontend/src/components/auth/LoginAgreementPrompt.vue index dd71cbdcb..08f28ead5 100644 --- a/frontend/src/components/auth/LoginAgreementPrompt.vue +++ b/frontend/src/components/auth/LoginAgreementPrompt.vue @@ -1,14 +1,18 @@

+
- +
-

继续登录前需要先同意最新条款。

-

- 未同意前,账号密码输入和快捷登录会保持禁用。 +

继续{{ actionName }}前需要先同意最新条款。

+

+ {{ hasError ? errorMessage : '提交前请先查看并同意条款。' }}

+
+
+
+

+ {{ t('profile.preferPointsBilling') }} +

+

+ {{ t('profile.preferPointsBillingHint') }} +

+
+ +
+
+
-
+
+
+

+ {{ t('profile.points') }} +

+

+ {{ formatPoints(user?.points_balance || 0) }} +

+
@@ -274,6 +286,10 @@ function formatCurrency(value: number): string { return `$${value.toFixed(2)}` } +function formatPoints(value: number): string { + return Number(value || 0).toFixed(10).replace(/\.?0+$/, '') || '0' +} + function normalizeProvider(value: string): UserAuthProvider | null { const normalized = value.trim().toLowerCase() if ( diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index a8186e230..694553b78 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -290,6 +290,7 @@ export default { disabled: 'Disabled', total: 'Total', balance: 'Balance', + points: 'Points', available: 'Available', copiedToClipboard: 'Copied to clipboard', copied: 'Copied', @@ -393,7 +394,7 @@ export default { store: { badge: 'Digital goods', title: 'Self-service Card Store', - description: 'Browse available card products, pay with balance or an enabled platform payment method, and receive delivery after purchase.', + description: 'Browse available card products, pay with balance, points, or an enabled platform payment method, and receive delivery after purchase.', productCount: 'Products', availableStock: 'Stock', empty: 'No products available', @@ -412,8 +413,13 @@ export default { balancePay: 'Balance', balanceEnough: 'Enough balance', balanceNotEnough: 'Insufficient balance', + pointsPay: 'Points', + pointsEnough: 'Enough points', + pointsNotEnough: 'Insufficient points', + currentPoints: 'Current points', gatewayPay: 'Platform payment', gatewayPayHint: 'Use Alipay, WeChat Pay or Stripe', + paymentMethodUnavailable: 'Not enabled for this product', confirmBuy: 'Confirm purchase', purchaseSuccess: 'Purchase successful', deliveryReady: 'Order {orderNo} is complete and cards are delivered', @@ -431,6 +437,14 @@ export default { PRODUCT_NOT_FOUND: 'Product is unavailable', STOCK_NOT_ENOUGH: 'Insufficient stock', BALANCE_NOT_ENOUGH: 'Insufficient balance', + SHOP_INVALID_INPUT: 'Invalid purchase request', + SHOP_PRODUCT_NOT_FOUND: 'Product is unavailable', + SHOP_PRODUCT_UNAVAILABLE: 'Product is unavailable', + SHOP_INVALID_QUANTITY: 'Invalid purchase quantity', + SHOP_INSUFFICIENT_STOCK: 'Insufficient stock', + SHOP_INSUFFICIENT_BALANCE: 'Insufficient balance', + SHOP_INSUFFICIENT_POINTS: 'Insufficient points', + SHOP_UNSUPPORTED_PAYMENT_METHOD: 'This product does not support the selected payment method', SHOP_WECHAT_OAUTH_UNSUPPORTED: 'WeChat in-app store payment is not supported yet. Please open this page in a normal browser or choose another payment method.', UNHANDLED_PAYMENT_SCENARIO: 'This payment method cannot be launched in the current environment', }, @@ -743,6 +757,7 @@ export default { shareValidationStillPending: 'Revalidation finished, but the account is still pending. Check the validation hint for the reason.', shareValidationFailedToRun: 'Failed to revalidate public sharing', noGroups: 'No groups', + privateDefaultGroupOnly: 'Private default group only', allPlatforms: 'All Platforms', allTypes: 'All Types', allStatus: 'All Status', @@ -1035,6 +1050,14 @@ export default { inboundEndpoint: 'Inbound Endpoint', upstreamEndpoint: 'Upstream Endpoint', type: 'Type', + paymentSource: 'Payment Source', + paymentSources: { + subscription: 'Subscription', + balance: 'Balance', + points: 'Points', + mixed: 'Points + Balance', + none: 'No charge', + }, tokens: 'Tokens', cost: 'Cost', firstToken: 'First Token', @@ -1287,7 +1310,7 @@ export default { // Redeem redeem: { title: 'Redeem Code', - description: 'Enter your redeem code to add balance or increase concurrency', + description: 'Enter your redeem code to add balance, points, or increase concurrency', currentBalance: 'Current Balance', concurrency: 'Concurrency', requests: 'requests', @@ -1301,17 +1324,21 @@ export default { added: 'Added', concurrentRequests: 'concurrent requests', newBalance: 'New Balance', + newPoints: 'New Points', newConcurrency: 'New Concurrency', aboutCodes: 'About Redeem Codes', codeRule1: 'Each code can only be used once', - codeRule2: 'Codes may add balance, increase concurrency, or grant trial access', + codeRule2: 'Codes may add balance, points, increase concurrency, or grant trial access', codeRule3: 'Contact support if you have issues redeeming a code', - codeRule4: 'Balance and concurrency updates are immediate', + codeRule4: 'Balance, points, and concurrency updates are immediate', recentActivity: 'Recent Activity', historyWillAppear: 'Your redemption history will appear here', balanceAddedRedeem: 'Balance Added (Redeem)', balanceAddedAdmin: 'Balance Added (Admin)', balanceDeductedAdmin: 'Balance Deducted (Admin)', + pointsAddedRedeem: 'Points Added (Redeem)', + pointsAddedAdmin: 'Points Added (Admin)', + pointsDeductedAdmin: 'Points Deducted (Admin)', concurrencyAddedRedeem: 'Concurrency Added (Redeem)', concurrencyAddedAdmin: 'Concurrency Added (Admin)', concurrencyReducedAdmin: 'Concurrency Reduced (Admin)', @@ -1331,6 +1358,9 @@ export default { title: 'Profile Settings', description: 'Manage your account information and settings', accountBalance: 'Account Balance', + points: 'Points', + preferPointsBilling: 'Use points first for model calls', + preferPointsBillingHint: 'When enabled, model call costs consume points first, then balance for the remainder. Store payments are configured per product.', concurrencyLimit: 'Concurrency Limit', rpmLimit: 'RPM Limit', rpmUnlimited: 'Unlimited', @@ -1602,16 +1632,28 @@ export default { stockLabel: 'Stock', unlimitedStock: 'Unlimited', purchaseLimit: 'Purchase Limit', + paymentMethods: 'Payment Methods', + paymentMethodRequired: 'Enable at least one payment method.', + balancePayment: 'Balance payment', + platformPayment: 'Platform payment', + allowBalancePayment: 'Allow balance payment', + allowBalancePaymentHint: 'When enabled, this product can be purchased with account balance.', + pointsPayment: 'Points payment', + allowPointsPayment: 'Allow points payment', + allowPointsPaymentHint: 'When enabled, this product can be purchased with points. The user profile preference does not affect store payments.', + allowPlatformPayment: 'Allow platform payment', + allowPlatformPaymentHint: 'When enabled, this product can be purchased through Alipay, WeChat Pay, Stripe, or other enabled providers.', productType: 'Product Type', productTypes: { cardKey: 'Card product', balanceDraw: 'Balance draw', + pointsDraw: 'Points draw', }, drawMinAmount: 'Min reward', drawMaxAmount: 'Max reward', drawGuaranteeCount: 'Guarantee count', drawReturnRate: 'Cycle return rate', - drawConfigHint: 'Current cycle target reward is {amount}. The system creates a guaranteed random pool per user and product.', + drawConfigHint: 'Current cycle target reward is {amount} {unit}. The system creates a guaranteed random pool per user and product.', imageUrl: 'Image URL', cardContent: 'Card Content', cardTypeLabel: 'Type', @@ -1727,7 +1769,7 @@ export default { netCash: 'Net Cash Income', netCashMeta: 'Paid {paid} / Refunds {refunds}', consumedRevenue: 'Consumed Revenue', - consumedRevenueMeta: '{requests} requests', + consumedRevenueMeta: '{requests} requests / balance {balance} / points {points}', accountCost: 'Account Cost', accountCostMeta: '{tokens} tokens', grossProfit: 'Usage Gross Profit', @@ -1741,11 +1783,14 @@ export default { title: 'Revenue Trend', paid: 'Net Cash', consumed: 'Usage Revenue', + balanceConsumed: 'Balance Consumed', + pointsConsumed: 'Points Consumed', cost: 'Account Cost', netProfit: 'Estimated Net Profit' }, sections: { cash: 'Cash Income', + usage: 'Usage Breakdown', adjustments: 'Adjustments', breakdown: 'Top Breakdown' }, @@ -1758,6 +1803,14 @@ export default { paidOrders: 'Paid Orders', refundOrders: 'Refund Orders', pendingOrders: 'Pending Orders', + consumedRevenue: 'Usage Consumed Revenue', + balanceConsumed: 'Balance Consumed', + pointsConsumed: 'Points Consumed', + pointsIssuedCost: 'Points Issued Cost', + standardCost: 'Standard Cost', + accountCost: 'Account Cost', + requests: 'Requests', + tokens: 'Tokens', affiliateRebate: 'Affiliate Rebate', affiliateTransfer: 'Affiliate Transfer', affiliateRebateCount: 'Affiliate Rebate Count', @@ -2239,6 +2292,7 @@ export default { groups: 'Groups', subscriptions: 'Subscriptions', balance: 'Balance', + points: 'Points', usage: 'Usage', concurrency: 'Concurrency', status: 'Status', @@ -2290,6 +2344,7 @@ export default { soraStorageQuotaHint: 'In GB, 0 means use group or system default quota', amountRequired: 'Please enter a valid amount', insufficientBalance: 'Insufficient balance', + insufficientPoints: 'Insufficient points', deleteConfirm: "Are you sure you want to delete '{email}'? This action cannot be undone.", setAllowedGroups: 'Set Allowed Groups', allowedGroupsHint: @@ -2324,6 +2379,7 @@ export default { withdrawAmount: 'Withdraw Amount', withdrawAll: 'All', currentBalance: 'Current Balance', + currentPoints: 'Current Points', depositNotesPlaceholder: 'e.g., New user registration bonus, promotional credit, compensation, etc.', withdrawNotesPlaceholder: @@ -2331,6 +2387,12 @@ export default { notesOptional: 'Notes are optional but helpful for record keeping', amountHint: 'Please enter a positive amount', newBalance: 'New Balance', + addPoints: 'Add Points', + deductPoints: 'Deduct Points', + addPointsAmount: 'Points to Add', + deductPointsAmount: 'Points to Deduct', + deductAllPoints: 'All', + newPoints: 'New Points', depositing: 'Depositing...', withdrawing: 'Withdrawing...', confirmDeposit: 'Confirm Deposit', @@ -2348,6 +2410,8 @@ export default { allTypes: 'All Types', typeBalance: 'Balance (Redeem)', typeAdminBalance: 'Balance (Admin)', + typePoints: 'Points (Redeem)', + typeAdminPoints: 'Points (Admin)', typeConcurrency: 'Concurrency (Redeem)', typeAdminConcurrency: 'Concurrency (Admin)', typeSubscription: 'Subscription', @@ -2518,6 +2582,23 @@ export default { rateMultipliers: 'Rate Multipliers', rateMultipliersTitle: 'Group Rate Multipliers', addUserRate: 'Add User Rate Multiplier', + rateSchedules: 'Rate Schedule', + rateSchedulesTitle: 'Time Range Rate Schedule', + addRateSchedule: 'Add Time Range', + noRateSchedules: 'No time range rate schedules configured', + startTime: 'Start Time', + endTime: 'End Time', + targetRate: 'Target Rate', + enabled: 'Enabled', + disabled: 'Disabled', + rowLabel: 'Row {index}', + invalidScheduleTime: 'Enter time in HH:mm format', + invalidTimeRange: 'End time must be later than start time; split overnight ranges into two rows', + invalidScheduleRate: 'Target rate must be greater than 0', + overlapTimeRange: 'Time ranges cannot overlap', + rateSchedulesSaved: 'Rate schedule saved', + failedToLoadSchedules: 'Failed to load rate schedules', + failedToSaveSchedules: 'Failed to save rate schedules', rpmOverrides: 'RPM Overrides', rpmOverridesTitle: 'Group RPM Overrides', addUserRpm: 'Add User RPM Override', @@ -3196,7 +3277,7 @@ export default { allStatus: 'All Status', allGroups: 'All Groups', allProxies: 'All Proxies', - ungroupedGroup: 'Ungrouped', + ungroupedGroup: 'Private default group only', oauthType: 'OAuth', setupToken: 'Setup Token', apiKey: 'API Key', @@ -4369,6 +4450,7 @@ export default { allTypes: 'All Types', allStatus: 'All Status', balance: 'Balance', + points: 'Points', concurrency: 'Concurrency', subscription: 'Subscription', invitation: 'Invitation', @@ -4398,6 +4480,7 @@ export default { codesCreated: '{count} redeem code(s) created', codeType: 'Code Type', amount: 'Amount ($)', + pointsValue: 'Points', value: 'Value', count: 'Count', generating: 'Generating...', @@ -4417,11 +4500,13 @@ export default { failedToCopy: 'Failed to copy codes', types: { balance: 'Balance', + points: 'Points', concurrency: 'Concurrency', subscription: 'Subscription', invitation: 'Invitation', // Admin adjustment types (created when admin modifies user balance/concurrency) admin_balance: 'Balance (Admin)', + admin_points: 'Points (Admin)', admin_concurrency: 'Concurrency (Admin)' }, selectGroup: 'Select Group', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 094d75cf5..ead643bbd 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -290,6 +290,7 @@ export default { disabled: '已禁用', total: '总计', balance: '余额', + points: '积分', available: '可用', copiedToClipboard: '已复制到剪贴板', copied: '已复制', @@ -393,7 +394,7 @@ export default { store: { badge: '数字商品', title: '自助发卡商城', - description: '浏览可售卡密商品,支持余额或已启用的平台支付方式购买,支付完成后自动交付。', + description: '浏览可售卡密商品,支持余额、积分或已启用的平台支付方式购买,支付完成后自动交付。', productCount: '商品数', availableStock: '可售库存', empty: '暂无可售商品', @@ -412,8 +413,13 @@ export default { balancePay: '余额支付', balanceEnough: '余额充足', balanceNotEnough: '余额不足', + pointsPay: '积分支付', + pointsEnough: '积分充足', + pointsNotEnough: '积分不足', + currentPoints: '当前积分', gatewayPay: '平台支付', gatewayPayHint: '支付宝、微信或 Stripe', + paymentMethodUnavailable: '该商品未启用', confirmBuy: '确认购买', purchaseSuccess: '购买成功', deliveryReady: '订单 {orderNo} 已完成,卡密已发放', @@ -431,6 +437,14 @@ export default { PRODUCT_NOT_FOUND: '商品不可用', STOCK_NOT_ENOUGH: '库存不足', BALANCE_NOT_ENOUGH: '余额不足', + SHOP_INVALID_INPUT: '购买参数无效', + SHOP_PRODUCT_NOT_FOUND: '商品不可用', + SHOP_PRODUCT_UNAVAILABLE: '商品不可用', + SHOP_INVALID_QUANTITY: '购买数量无效', + SHOP_INSUFFICIENT_STOCK: '库存不足', + SHOP_INSUFFICIENT_BALANCE: '余额不足', + SHOP_INSUFFICIENT_POINTS: '积分不足', + SHOP_UNSUPPORTED_PAYMENT_METHOD: '该商品不支持当前支付方式', SHOP_WECHAT_OAUTH_UNSUPPORTED: '商城暂不支持微信内置浏览器 OAuth 支付,请用普通浏览器打开或选择其他支付方式。', UNHANDLED_PAYMENT_SCENARIO: '当前环境无法拉起该支付方式', }, @@ -742,6 +756,7 @@ export default { shareValidationStillPending: '重新校验完成,账号仍处于待校验状态,请查看提示原因。', shareValidationFailedToRun: '重新校验失败', noGroups: '无分组', + privateDefaultGroupOnly: '仅私有默认分组', allPlatforms: '全部平台', allTypes: '全部类型', allStatus: '全部状态', @@ -1039,6 +1054,14 @@ export default { inboundEndpoint: '入站端点', upstreamEndpoint: '上游端点', type: '类型', + paymentSource: '支付来源', + paymentSources: { + subscription: '订阅', + balance: '余额', + points: '积分', + mixed: '积分+余额', + none: '未扣费' + }, tokens: 'Token', cost: '费用', firstToken: '首 Token', @@ -1291,7 +1314,7 @@ export default { // Redeem redeem: { title: '兑换码', - description: '输入兑换码以充值余额或增加并发数', + description: '输入兑换码以充值余额、积分或增加并发数', currentBalance: '当前余额', concurrency: '并发数', requests: '请求', @@ -1305,17 +1328,21 @@ export default { added: '已添加', concurrentRequests: '并发请求', newBalance: '新余额', + newPoints: '新积分', newConcurrency: '新并发数', aboutCodes: '关于兑换码', codeRule1: '每个兑换码只能使用一次', - codeRule2: '兑换码可以增加余额、并发数或试用权限', + codeRule2: '兑换码可以增加余额、积分、并发数或试用权限', codeRule3: '如有兑换问题,请联系客服', - codeRule4: '余额和并发数即时更新', + codeRule4: '余额、积分和并发数即时更新', recentActivity: '最近活动', historyWillAppear: '您的兑换历史将显示在这里', balanceAddedRedeem: '余额充值(兑换)', balanceAddedAdmin: '余额充值(管理员)', balanceDeductedAdmin: '余额扣除(管理员)', + pointsAddedRedeem: '积分发放(兑换)', + pointsAddedAdmin: '积分发放(管理员)', + pointsDeductedAdmin: '积分扣除(管理员)', concurrencyAddedRedeem: '并发增加(兑换)', concurrencyAddedAdmin: '并发增加(管理员)', concurrencyReducedAdmin: '并发减少(管理员)', @@ -1335,6 +1362,9 @@ export default { title: '个人设置', description: '管理您的账户信息和设置', accountBalance: '账户余额', + points: '积分', + preferPointsBilling: '模型调用优先使用积分', + preferPointsBillingHint: '开启后,模型调用产生的消耗会先扣积分,不足部分再扣余额;商城支付不受此开关影响。', concurrencyLimit: '并发限制', rpmLimit: 'RPM 限制', rpmUnlimited: '不限制', @@ -1606,16 +1636,28 @@ export default { stockLabel: '库存', unlimitedStock: '不限库存', purchaseLimit: '购买限制', + paymentMethods: '支付方式', + paymentMethodRequired: '至少需要启用一种支付方式。', + balancePayment: '余额支付', + platformPayment: '平台支付', + allowBalancePayment: '允许余额支付', + allowBalancePaymentHint: '开启后,该商品可用账户余额购买。', + pointsPayment: '积分支付', + allowPointsPayment: '允许积分支付', + allowPointsPaymentHint: '开启后,该商品可用积分购买;用户资料中的优先积分开关不会影响商城。', + allowPlatformPayment: '允许平台支付', + allowPlatformPaymentHint: '开启后,该商品可用支付宝、微信、Stripe 等平台支付方式购买。', productType: '商品类型', productTypes: { cardKey: '卡密商品', balanceDraw: '余额抽卡', + pointsDraw: '积分抽奖', }, drawMinAmount: '最小返还', drawMaxAmount: '最大返还', drawGuaranteeCount: '保底次数', drawReturnRate: '周期返还率', - drawConfigHint: '当前周期目标返还 {amount} 额度,系统会按用户和商品生成保底随机池。', + drawConfigHint: '当前周期目标返还 {amount} {unit},系统会按用户和商品生成保底随机池。', imageUrl: '图片 URL', cardContent: '卡密内容', cardTypeLabel: '类型', @@ -1748,7 +1790,7 @@ export default { netCash: '净现金收入', netCashMeta: '已支付 {paid} / 退款 {refunds}', consumedRevenue: '消耗收入', - consumedRevenueMeta: '{requests} 次请求', + consumedRevenueMeta: '{requests} 次请求 / 余额 {balance} / 积分 {points}', accountCost: '账号成本', accountCostMeta: '{tokens} Token', grossProfit: '用量毛利', @@ -1762,11 +1804,14 @@ export default { title: '收益趋势', paid: '净现金', consumed: '用量收入', + balanceConsumed: '余额消耗', + pointsConsumed: '积分消耗', cost: '账号成本', netProfit: '预估净收益' }, sections: { cash: '现金收入', + usage: '用量拆账', adjustments: '调整项', breakdown: 'Top 明细' }, @@ -1779,6 +1824,14 @@ export default { paidOrders: '已支付订单', refundOrders: '退款订单', pendingOrders: '待支付订单', + consumedRevenue: '用量消耗收入', + balanceConsumed: '余额消耗', + pointsConsumed: '积分消耗', + pointsIssuedCost: '积分发放成本', + standardCost: '标准成本', + accountCost: '账号成本', + requests: '请求数', + tokens: 'Token 数', affiliateRebate: '邀请返利', affiliateTransfer: '返利转入余额', affiliateRebateCount: '返利笔数', @@ -2260,6 +2313,7 @@ export default { groups: '分组', subscriptions: '订阅分组', balance: '余额', + points: '积分', usage: '用量', concurrency: '并发数', status: '状态', @@ -2352,6 +2406,7 @@ export default { soraStorageQuotaHint: '单位 GB,0 表示使用分组或系统默认配额', amountRequired: '请输入有效金额', insufficientBalance: '余额不足', + insufficientPoints: '积分不足', setAllowedGroups: '设置允许分组', allowedGroupsHint: '选择此用户可以使用的标准分组。订阅类型分组请在订阅管理中配置。', noStandardGroups: '暂无标准分组', @@ -2383,11 +2438,18 @@ export default { depositAmount: '充值金额', withdrawAmount: '退款金额', withdrawAll: '全部', + currentPoints: '当前积分', depositNotesPlaceholder: '例如:新用户注册奖励、活动充值、补偿充值等', withdrawNotesPlaceholder: '例如:服务问题退款、错误充值退回、账户注销退款等', notesOptional: '备注为可选项,有助于未来查账', amountHint: '请输入正数金额', newBalance: '操作后余额', + addPoints: '添加积分', + deductPoints: '扣减积分', + addPointsAmount: '添加积分数量', + deductPointsAmount: '扣减积分数量', + deductAllPoints: '全部', + newPoints: '操作后积分', depositing: '充值中...', withdrawing: '退款中...', confirmDeposit: '确认充值', @@ -2405,6 +2467,8 @@ export default { allTypes: '全部类型', typeBalance: '余额(兑换码)', typeAdminBalance: '余额(管理员调整)', + typePoints: '积分(兑换码)', + typeAdminPoints: '积分(管理员调整)', typeConcurrency: '并发(兑换码)', typeAdminConcurrency: '并发(管理员调整)', typeSubscription: '订阅', @@ -2613,6 +2677,23 @@ export default { rateMultipliers: '专属倍率', rateMultipliersTitle: '分组专属倍率管理', addUserRate: '添加用户专属倍率', + rateSchedules: '倍率策略', + rateSchedulesTitle: '分组时间区间倍率策略', + addRateSchedule: '添加时间段', + noRateSchedules: '暂无时间区间倍率策略', + startTime: '开始时间', + endTime: '结束时间', + targetRate: '目标倍率', + enabled: '启用', + disabled: '停用', + rowLabel: '第 {index} 行', + invalidScheduleTime: '请输入 HH:mm 格式时间', + invalidTimeRange: '结束时间必须晚于开始时间,跨天请拆成两个时间段', + invalidScheduleRate: '目标倍率必须大于 0', + overlapTimeRange: '时间段不能重叠', + rateSchedulesSaved: '倍率策略已保存', + failedToLoadSchedules: '加载倍率策略失败', + failedToSaveSchedules: '保存倍率策略失败', rpmOverrides: '专属 RPM', rpmOverridesTitle: '分组专属 RPM 管理', addUserRpm: '添加用户专属 RPM', @@ -3275,7 +3356,7 @@ export default { allStatus: '全部状态', allGroups: '全部分组', allProxies: '全部代理', - ungroupedGroup: '未分配分组', + ungroupedGroup: '仅私有默认分组', oauthType: 'OAuth', // Schedulable toggle schedulable: '参与调度', @@ -4501,15 +4582,18 @@ export default { }, types: { balance: '余额', + points: '积分', concurrency: '并发数', subscription: '订阅', invitation: '邀请码', // 管理员在用户管理页面调整余额/并发时产生的记录 admin_balance: '余额(管理员)', + admin_points: '积分(管理员)', admin_concurrency: '并发数(管理员)' }, // 用于选择器和筛选器的直接键 balance: '余额', + points: '积分', concurrency: '并发数', subscription: '订阅', invitation: '邀请码', @@ -4529,6 +4613,7 @@ export default { codesCreated: '已创建 {count} 个兑换码', codeType: '类型', amount: '金额 ($)', + pointsValue: '积分数量', value: '面值', count: '数量', generate: '生成', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b262cc17b..1cbc8c0db 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -86,6 +86,8 @@ export interface User { wechat_bound?: boolean role: 'admin' | 'user' // User role for authorization balance: number // User balance for API usage + points_balance?: number // User points balance for API usage and store purchases + prefer_points_billing?: boolean // Whether model calls should use points before balance concurrency: number // Allowed concurrent requests rpm_limit?: number // User-level RPM cap (0 = unlimited); effective as fallback when group has no rpm_limit status: 'active' | 'disabled' // Account status @@ -1252,7 +1254,7 @@ export interface AdminDataImportResult { // ==================== Usage & Redeem Types ==================== -export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' | 'invitation' +export type RedeemCodeType = 'balance' | 'points' | 'concurrency' | 'subscription' | 'invitation' export type UsageRequestType = 'unknown' | 'sync' | 'stream' | 'ws_v2' export interface UsageLog { @@ -1284,6 +1286,9 @@ export interface UsageLog { total_cost: number actual_cost: number rate_multiplier: number + points_deducted?: number + balance_deducted?: number + billing_wallet_type?: 'subscription' | 'balance' | 'points' | 'mixed' | 'none' | string billing_type: number request_type?: UsageRequestType diff --git a/frontend/src/types/store.ts b/frontend/src/types/store.ts index ce5ccd335..16a8f5440 100644 --- a/frontend/src/types/store.ts +++ b/frontend/src/types/store.ts @@ -5,10 +5,10 @@ export type StoreCardStatus = 'available' | 'locked' | 'sold' | 'disabled' export type StoreCardViewStatus = StoreCardStatus | 'unused' export type StoreCardType = 'text' | 'file' export type StoreOrderStatus = 'pending' | 'paid' | 'completed' | 'cancelled' | 'failed' -export type StorePayMethod = 'balance' | 'payment' +export type StorePayMethod = 'balance' | 'points' | 'payment' export type StoreCategoryStatus = 'active' | 'inactive' export type StoreProductStatus = 'active' | 'inactive' -export type StoreProductType = 'card_key' | 'balance_draw' +export type StoreProductType = 'card_key' | 'balance_draw' | 'points_draw' export interface StoreDrawConfig { enabled: boolean @@ -56,6 +56,9 @@ export interface StoreProduct { auto_delivery: boolean product_type: StoreProductType balance_only: boolean + allow_balance_payment: boolean + allow_points_payment: boolean + allow_platform_payment: boolean draw_config?: StoreDrawConfig | null draw_progress?: StoreDrawProgress | null stock_unlimited?: boolean @@ -98,15 +101,18 @@ export interface StoreOrder { product_name: string product_cover_url?: string | null product_description?: string | null + product_type: StoreProductType unit_price: number quantity: number total_amount: number + points_amount: number payment_method: string payment_order_id?: number | null status: StoreOrderStatus delivered_cards: string[] delivered_files: StoreDeliveredFile[] draw_reward_amount?: number | null + draw_reward_type?: 'balance' | 'points' | null draw_cycle_id?: number | null draw_cycle_index?: number | null paid_at?: string | null @@ -168,6 +174,9 @@ export interface UpsertStoreProductRequest { auto_delivery?: boolean product_type?: StoreProductType balance_only?: boolean + allow_balance_payment?: boolean + allow_points_payment?: boolean + allow_platform_payment?: boolean draw_config?: StoreDrawConfig | null } diff --git a/frontend/src/utils/ccswitchImport.ts b/frontend/src/utils/ccswitchImport.ts index 79411d2ff..0e26c5340 100644 --- a/frontend/src/utils/ccswitchImport.ts +++ b/frontend/src/utils/ccswitchImport.ts @@ -1,6 +1,7 @@ import type { GroupPlatform } from '@/types' -export const OPENAI_CC_SWITCH_CODEX_MODEL = 'gpt-5.4' +export const OPENAI_CC_SWITCH_CODEX_MODEL = 'gpt-5.5' +export const OPENAI_CC_SWITCH_REASONING_EFFORT = 'xhigh' export type CcSwitchClientType = 'claude' | 'gemini' @@ -19,6 +20,44 @@ export interface CcSwitchImportDeeplinkInput { usageScript: string } +function encodeBase64Utf8(value: string): string { + const bytes = new TextEncoder().encode(value) + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return btoa(binary) +} + +function toCodexProviderId(providerName: string): string { + const normalized = providerName + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '_') + .replace(/^_+|_+$/g, '') + + return normalized || 'pixel_api' +} + +function tomlString(value: string): string { + return JSON.stringify(value) +} + +function buildOpenAICodexConfig(baseUrl: string, providerName: string): string { + const providerId = toCodexProviderId(providerName) + + return `model_provider = ${tomlString(providerId)} +model = ${tomlString(OPENAI_CC_SWITCH_CODEX_MODEL)} +model_reasoning_effort = ${tomlString(OPENAI_CC_SWITCH_REASONING_EFFORT)} +disable_response_storage = true + +[model_providers.${providerId}] +name = ${tomlString(providerId)} +base_url = ${tomlString(baseUrl)} +wire_api = "responses" +requires_openai_auth = true` +} + export function resolveCcSwitchImportConfig( platform: GroupPlatform | undefined | null, clientType: CcSwitchClientType, @@ -60,7 +99,7 @@ export function buildCcSwitchImportDeeplink(input: CcSwitchImportDeeplinkInput): ['apiKey', input.apiKey], ['configFormat', 'json'], ['usageEnabled', 'true'], - ['usageScript', btoa(input.usageScript)], + ['usageScript', encodeBase64Utf8(input.usageScript)], ['usageAutoInterval', '30'] ] @@ -68,5 +107,17 @@ export function buildCcSwitchImportDeeplink(input: CcSwitchImportDeeplinkInput): entries.splice(2, 0, ['model', config.model]) } + if ((input.platform || 'anthropic') === 'openai') { + entries.push([ + 'config', + encodeBase64Utf8(JSON.stringify({ + auth: { + OPENAI_API_KEY: input.apiKey + }, + config: buildOpenAICodexConfig(config.endpoint, input.providerName) + })) + ]) + } + return `ccswitch://v1/import?${new URLSearchParams(entries).toString()}` } diff --git a/frontend/src/utils/storeRewards.ts b/frontend/src/utils/storeRewards.ts new file mode 100644 index 000000000..c7be88a0b --- /dev/null +++ b/frontend/src/utils/storeRewards.ts @@ -0,0 +1,11 @@ +import type { StoreOrder } from '@/types/store' + +type StoreDrawRewardOrder = Pick + +export function formatStoreDrawReward(order: StoreDrawRewardOrder): string { + const amount = Number(order.draw_reward_amount || 0) + if (order.draw_reward_type === 'points' || order.product_type === 'points_draw') { + return amount.toFixed(10).replace(/\.?0+$/, '') || '0' + } + return `$${amount.toFixed(2)}` +} diff --git a/frontend/src/views/StoreView.vue b/frontend/src/views/StoreView.vue index 8bba4e35d..9cb34844b 100644 --- a/frontend/src/views/StoreView.vue +++ b/frontend/src/views/StoreView.vue @@ -92,7 +92,7 @@

{{ product.description || t('store.noDescription') }}

-
+
{{ t('store.drawProgress') }} {{ drawProgressText(product) }} @@ -162,18 +162,23 @@ {{ drawProgressText(checkoutProduct) }}
-
+
{{ t('payment.currentBalance') }} ${{ currentBalance.toFixed(2) }}
+
+ {{ t('store.currentPoints') }} + {{ currentPoints.toFixed(10).replace(/\.?0+$/, '') || '0' }} +
-
+
+
@@ -314,7 +331,8 @@ import { METHOD_ORDER, getPaymentPopupFeatures } from '@/components/payment/prov import { decidePaymentLaunch, getVisibleMethods, normalizeVisibleMethod, type PaymentRecoverySnapshot } from '@/components/payment/paymentFlow' import type { CheckoutInfoResponse, CreateOrderResult } from '@/types/payment' import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue' -import type { StoreCategory, StoreOrder, StorePayMethod, StoreProduct } from '@/types/store' +import { formatStoreDrawReward } from '@/utils/storeRewards' +import type { StoreCategory, StoreDrawConfig, StoreOrder, StorePayMethod, StoreProduct } from '@/types/store' const { t } = useI18n() const router = useRouter() @@ -344,11 +362,12 @@ const filteredProducts = computed(() => products.value.filter((product) => )) const totalStock = computed(() => products.value.reduce((sum, product) => sum + (product.stock_unlimited ? 0 : Math.max(0, product.stock)), 0)) const currentBalance = computed(() => Number(authStore.user?.balance || 0)) +const currentPoints = computed(() => Number(authStore.user?.points_balance || 0)) const checkoutMinQuantity = computed(() => Math.max(1, checkoutProduct.value?.min_purchase || 1)) const checkoutMaxQuantity = computed(() => { const product = checkoutProduct.value if (!product) return 1 - if (product.product_type === 'balance_draw') return 1 + if (isDrawProduct(product)) return 1 const maxPurchase = product.max_purchase > 0 ? product.max_purchase : product.stock return Math.max(checkoutMinQuantity.value, Math.min(product.stock, maxPurchase)) }) @@ -358,11 +377,11 @@ const checkoutAmount = computed(() => { return Math.round(product.price * Math.max(checkoutMinQuantity.value, quantity.value || checkoutMinQuantity.value) * 100) / 100 }) const visibleMethods = computed(() => getVisibleMethods(checkout.value?.methods || {})) -const isCheckoutDrawProduct = computed(() => checkoutProduct.value?.product_type === 'balance_draw') +const isCheckoutDrawProduct = computed(() => isDrawProduct(checkoutProduct.value || null)) const drawRewardRangeText = computed(() => { const config = checkoutProduct.value?.draw_config if (!config) return '' - return `$${config.min_amount.toFixed(2)} - $${config.max_amount.toFixed(2)}` + return formatDrawRewardRange(checkoutProduct.value, config) }) const enabledMethods = computed(() => Object.keys(visibleMethods.value).sort((a, b) => { const ai = METHOD_ORDER.indexOf(a as typeof METHOD_ORDER[number]) @@ -374,13 +393,21 @@ const methodOptions = computed(() => enabledMethods.value fee_rate: visibleMethods.value[type]?.fee_rate ?? 0, available: visibleMethods.value[type]?.available !== false && amountFitsMethod(checkoutAmount.value, type), }))) -const canPayByBalance = computed(() => currentBalance.value >= checkoutAmount.value && checkoutAmount.value > 0) -const balancePayHint = computed(() => canPayByBalance.value ? t('store.balanceEnough') : t('store.balanceNotEnough')) +const isBalancePaymentAllowed = computed(() => checkoutProduct.value?.allow_balance_payment !== false) +const isPointsPaymentAllowed = computed(() => checkoutProduct.value?.allow_points_payment === true) +const isPlatformPaymentAllowed = computed(() => checkoutProduct.value?.allow_platform_payment !== false) +const canPayByBalance = computed(() => isBalancePaymentAllowed.value && currentBalance.value >= checkoutAmount.value && checkoutAmount.value > 0) +const canPayByPoints = computed(() => isPointsPaymentAllowed.value && currentPoints.value >= checkoutAmount.value && checkoutAmount.value > 0) +const balancePayHint = computed(() => { + if (!isBalancePaymentAllowed.value) return t('store.paymentMethodUnavailable') + return canPayByBalance.value ? t('store.balanceEnough') : t('store.balanceNotEnough') +}) +const pointsPayHint = computed(() => canPayByPoints.value ? t('store.pointsEnough') : t('store.pointsNotEnough')) const canSubmitCheckout = computed(() => { if (!checkoutProduct.value || quantity.value < checkoutMinQuantity.value || quantity.value > checkoutMaxQuantity.value) return false if (payMethod.value === 'balance') return canPayByBalance.value - if (isCheckoutDrawProduct.value) return false - return !!selectedMethod.value && amountFitsMethod(checkoutAmount.value, selectedMethod.value) + if (payMethod.value === 'points') return canPayByPoints.value + return isPlatformPaymentAllowed.value && !!selectedMethod.value && amountFitsMethod(checkoutAmount.value, selectedMethod.value) }) function emptyPaymentState(): PaymentRecoverySnapshot { @@ -419,7 +446,28 @@ function amountFitsMethod(amount: number, method: string): boolean { } function isProductPurchasable(product: StoreProduct): boolean { - return product.product_type === 'balance_draw' || product.stock > 0 + return isDrawProduct(product) || product.stock > 0 +} + +function isDrawProduct(product: StoreProduct | null): boolean { + return product?.product_type === 'balance_draw' || product?.product_type === 'points_draw' +} + +function drawRewardUnit(productType?: string | null): string { + return productType === 'points_draw' ? '' : '$' +} + +function formatDrawReward(order: StoreOrder): string { + return formatStoreDrawReward(order) +} + +function formatDrawRewardRange(product: StoreProduct | null, config: StoreDrawConfig): string { + if (product?.product_type === 'points_draw') { + const min = config.min_amount.toFixed(10).replace(/\.?0+$/, '') || '0' + const max = config.max_amount.toFixed(10).replace(/\.?0+$/, '') || '0' + return `${min} - ${max}` + } + return `${drawRewardUnit(product?.product_type)}${config.min_amount.toFixed(2)} - ${drawRewardUnit(product?.product_type)}${config.max_amount.toFixed(2)}` } function drawProgressText(product: StoreProduct | null): string { @@ -431,10 +479,26 @@ function drawProgressText(product: StoreProduct | null): string { } function createCheckoutIdempotencyKey(): string { - if (!window.crypto?.randomUUID) { - throw new Error('crypto.randomUUID is required to create store orders') + const crypto = globalThis.crypto + if (crypto?.randomUUID) { + return crypto.randomUUID() + } + if (!crypto?.getRandomValues) { + throw new Error('crypto.getRandomValues is required to create store orders') } - return window.crypto.randomUUID() + + const bytes = new Uint8Array(16) + crypto.getRandomValues(bytes) + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')) + return [ + hex.slice(0, 4).join(''), + hex.slice(4, 6).join(''), + hex.slice(6, 8).join(''), + hex.slice(8, 10).join(''), + hex.slice(10, 16).join(''), + ].join('-') } async function startCheckout(product: StoreProduct) { @@ -453,7 +517,17 @@ async function startCheckout(product: StoreProduct) { } checkoutProduct.value = product quantity.value = Math.max(1, product.min_purchase || 1) - payMethod.value = product.product_type === 'balance_draw' || canPayByBalance.value ? 'balance' : 'payment' + if (canPayByBalance.value) { + payMethod.value = 'balance' + } else if (product.allow_points_payment && canPayByPoints.value) { + payMethod.value = 'points' + } else if (product.allow_platform_payment !== false) { + payMethod.value = 'payment' + } else if (product.allow_balance_payment !== false) { + payMethod.value = 'balance' + } else { + payMethod.value = 'points' + } if (!selectedMethod.value && enabledMethods.value.length > 0) { selectedMethod.value = enabledMethods.value[0] } @@ -486,7 +560,7 @@ async function submitCheckout() { const orderResponse = await storeAPI.createOrder({ product_id: checkoutProduct.value.id, quantity: quantity.value, - payment_method: payMethod.value === 'balance' ? 'balance' : selectedMethod.value, + payment_method: payMethod.value === 'payment' ? selectedMethod.value : payMethod.value, return_url: `${window.location.origin}/payment/result`, payment_source: selectedMethod.value === 'wxpay' && /MicroMessenger/i.test(window.navigator.userAgent) ? 'wechat_in_app_resume' @@ -494,9 +568,21 @@ async function submitCheckout() { is_mobile: isMobileDevice(), }, checkoutIdempotencyKey.value) const storeOrder = orderResponse.data - if (payMethod.value === 'balance') { + if (payMethod.value === 'balance' || payMethod.value === 'points') { if (authStore.user) { - authStore.user.balance = Math.max(0, currentBalance.value - storeOrder.total_amount + Number(storeOrder.draw_reward_amount || 0)) + const drawReward = Number(storeOrder.draw_reward_amount || 0) + const rewardIsPoints = storeOrder.draw_reward_type === 'points' || storeOrder.product_type === 'points_draw' + if (payMethod.value === 'balance') { + authStore.user.balance = Math.max(0, currentBalance.value - storeOrder.total_amount + (rewardIsPoints ? 0 : drawReward)) + if (rewardIsPoints && drawReward > 0) { + authStore.user.points_balance = currentPoints.value + drawReward + } + } else { + authStore.user.points_balance = Math.max(0, currentPoints.value - storeOrder.total_amount + (rewardIsPoints ? drawReward : 0)) + if (!rewardIsPoints && drawReward > 0) { + authStore.user.balance = currentBalance.value + drawReward + } + } } appStore.showSuccess(t('store.purchaseSuccess')) checkoutProduct.value = null diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index bfdbd854a..1b90b4289 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -1535,7 +1535,7 @@ const accountMatchesCurrentFilters = (account: Account) => { if (filters.group) { const groupIds = account.group_ids ?? account.groups?.map((group) => group.id) ?? [] if (filters.group === ACCOUNT_UNGROUPED_GROUP_QUERY_VALUE) { - if (groupIds.length > 0) return false + if (accountHasNonPrivateGroup(account, groupIds)) return false } else if (!groupIds.includes(Number(filters.group))) { return false } @@ -1557,6 +1557,16 @@ const accountMatchesCurrentFilters = (account: Account) => { if (search && !account.name.toLowerCase().includes(search)) return false return true } +const accountHasNonPrivateGroup = (account: Account, groupIds: number[]): boolean => { + if (account.groups) { + return account.groups.some((group) => group.scope !== 'user_private') + } + const groupScopeByID = new Map(groups.value.map(group => [group.id, group.scope])) + return groupIds.some((groupID) => { + const scope = groupScopeByID.get(groupID) + return scope !== undefined && scope !== 'user_private' + }) +} const mergeRuntimeFields = (oldAccount: Account, updatedAccount: Account): Account => ({ ...updatedAccount, current_concurrency: updatedAccount.current_concurrency ?? oldAccount.current_concurrency, diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index d14414605..76415fe8c 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -331,6 +331,15 @@ t("admin.groups.rateMultipliers") }} +