feat(notify): add balance low & account quota notification system
- User balance low notification: email alert when balance drops below configurable threshold (user email + verified extra emails) - Account quota notification: broadcast email to admin-configured recipients when daily/weekly/total quota usage exceeds alert threshold - Admin settings: global enable/disable, default threshold, quota notification email list (Email Settings tab) - User profile: enable/disable, custom threshold, add/remove extra notification emails with verification code flow - Account quota: per-dimension alert toggle and threshold in quota control card - Trigger logic: first-crossing only (old >= threshold && new < threshold for balance; old < threshold && new >= threshold for quota), naturally prevents duplicate notifications without Redis dedup
This commit is contained in:
@ -68,7 +68,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator)
|
||||
subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig)
|
||||
authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService)
|
||||
userService := service.NewUserService(userRepository, apiKeyAuthCacheInvalidator, billingCache)
|
||||
userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache)
|
||||
redeemCache := repository.NewRedeemCache(redisClient)
|
||||
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator)
|
||||
secretEncryptor, err := repository.NewAESEncryptor(configConfig)
|
||||
@ -78,7 +78,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
totpCache := repository.NewTotpCache(redisClient)
|
||||
totpService := service.NewTotpService(userRepository, secretEncryptor, totpCache, settingService, emailService, emailQueueService)
|
||||
authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, redeemService, totpService)
|
||||
userHandler := handler.NewUserHandler(userService)
|
||||
userHandler := handler.NewUserHandler(userService, emailService, emailCache)
|
||||
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
||||
usageLogRepository := repository.NewUsageLogRepository(client, db)
|
||||
usageService := service.NewUsageService(usageLogRepository, userRepository, client, apiKeyAuthCacheInvalidator)
|
||||
@ -176,9 +176,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
channelRepository := repository.NewChannelRepository(db)
|
||||
channelService := service.NewChannelService(channelRepository, apiKeyAuthCacheInvalidator)
|
||||
modelPricingResolver := service.NewModelPricingResolver(channelService, billingService)
|
||||
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver)
|
||||
balanceNotifyService := service.ProvideBalanceNotifyService(emailService, settingRepository)
|
||||
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver, balanceNotifyService)
|
||||
openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI)
|
||||
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, modelPricingResolver, channelService)
|
||||
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, modelPricingResolver, channelService, balanceNotifyService)
|
||||
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig)
|
||||
opsSystemLogSink := service.ProvideOpsSystemLogSink(opsRepository)
|
||||
opsService := service.NewOpsService(opsRepository, settingRepository, configConfig, accountRepository, userRepository, concurrencyService, gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, opsSystemLogSink)
|
||||
|
||||
@ -1078,6 +1078,9 @@ var (
|
||||
{Name: "totp_secret_encrypted", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}},
|
||||
{Name: "totp_enabled", Type: field.TypeBool, Default: false},
|
||||
{Name: "totp_enabled_at", Type: field.TypeTime, Nullable: true},
|
||||
{Name: "balance_notify_enabled", Type: field.TypeBool, Default: true},
|
||||
{Name: "balance_notify_threshold", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
|
||||
{Name: "balance_notify_extra_emails", Type: field.TypeString, Default: "[]", SchemaType: map[string]string{"postgres": "text"}},
|
||||
}
|
||||
// UsersTable holds the schema information for the "users" table.
|
||||
UsersTable = &schema.Table{
|
||||
|
||||
@ -28210,6 +28210,10 @@ type UserMutation struct {
|
||||
totp_secret_encrypted *string
|
||||
totp_enabled *bool
|
||||
totp_enabled_at *time.Time
|
||||
balance_notify_enabled *bool
|
||||
balance_notify_threshold *float64
|
||||
addbalance_notify_threshold *float64
|
||||
balance_notify_extra_emails *string
|
||||
clearedFields map[string]struct{}
|
||||
api_keys map[int64]struct{}
|
||||
removedapi_keys map[int64]struct{}
|
||||
@ -28927,6 +28931,148 @@ func (m *UserMutation) ResetTotpEnabledAt() {
|
||||
delete(m.clearedFields, user.FieldTotpEnabledAt)
|
||||
}
|
||||
|
||||
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
|
||||
func (m *UserMutation) SetBalanceNotifyEnabled(b bool) {
|
||||
m.balance_notify_enabled = &b
|
||||
}
|
||||
|
||||
// BalanceNotifyEnabled returns the value of the "balance_notify_enabled" field in the mutation.
|
||||
func (m *UserMutation) BalanceNotifyEnabled() (r bool, exists bool) {
|
||||
v := m.balance_notify_enabled
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldBalanceNotifyEnabled returns the old "balance_notify_enabled" 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) OldBalanceNotifyEnabled(ctx context.Context) (v bool, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldBalanceNotifyEnabled is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldBalanceNotifyEnabled requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldBalanceNotifyEnabled: %w", err)
|
||||
}
|
||||
return oldValue.BalanceNotifyEnabled, nil
|
||||
}
|
||||
|
||||
// ResetBalanceNotifyEnabled resets all changes to the "balance_notify_enabled" field.
|
||||
func (m *UserMutation) ResetBalanceNotifyEnabled() {
|
||||
m.balance_notify_enabled = nil
|
||||
}
|
||||
|
||||
// SetBalanceNotifyThreshold sets the "balance_notify_threshold" field.
|
||||
func (m *UserMutation) SetBalanceNotifyThreshold(f float64) {
|
||||
m.balance_notify_threshold = &f
|
||||
m.addbalance_notify_threshold = nil
|
||||
}
|
||||
|
||||
// BalanceNotifyThreshold returns the value of the "balance_notify_threshold" field in the mutation.
|
||||
func (m *UserMutation) BalanceNotifyThreshold() (r float64, exists bool) {
|
||||
v := m.balance_notify_threshold
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldBalanceNotifyThreshold returns the old "balance_notify_threshold" 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) OldBalanceNotifyThreshold(ctx context.Context) (v *float64, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldBalanceNotifyThreshold is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldBalanceNotifyThreshold requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldBalanceNotifyThreshold: %w", err)
|
||||
}
|
||||
return oldValue.BalanceNotifyThreshold, nil
|
||||
}
|
||||
|
||||
// AddBalanceNotifyThreshold adds f to the "balance_notify_threshold" field.
|
||||
func (m *UserMutation) AddBalanceNotifyThreshold(f float64) {
|
||||
if m.addbalance_notify_threshold != nil {
|
||||
*m.addbalance_notify_threshold += f
|
||||
} else {
|
||||
m.addbalance_notify_threshold = &f
|
||||
}
|
||||
}
|
||||
|
||||
// AddedBalanceNotifyThreshold returns the value that was added to the "balance_notify_threshold" field in this mutation.
|
||||
func (m *UserMutation) AddedBalanceNotifyThreshold() (r float64, exists bool) {
|
||||
v := m.addbalance_notify_threshold
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// ClearBalanceNotifyThreshold clears the value of the "balance_notify_threshold" field.
|
||||
func (m *UserMutation) ClearBalanceNotifyThreshold() {
|
||||
m.balance_notify_threshold = nil
|
||||
m.addbalance_notify_threshold = nil
|
||||
m.clearedFields[user.FieldBalanceNotifyThreshold] = struct{}{}
|
||||
}
|
||||
|
||||
// BalanceNotifyThresholdCleared returns if the "balance_notify_threshold" field was cleared in this mutation.
|
||||
func (m *UserMutation) BalanceNotifyThresholdCleared() bool {
|
||||
_, ok := m.clearedFields[user.FieldBalanceNotifyThreshold]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ResetBalanceNotifyThreshold resets all changes to the "balance_notify_threshold" field.
|
||||
func (m *UserMutation) ResetBalanceNotifyThreshold() {
|
||||
m.balance_notify_threshold = nil
|
||||
m.addbalance_notify_threshold = nil
|
||||
delete(m.clearedFields, user.FieldBalanceNotifyThreshold)
|
||||
}
|
||||
|
||||
// SetBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field.
|
||||
func (m *UserMutation) SetBalanceNotifyExtraEmails(s string) {
|
||||
m.balance_notify_extra_emails = &s
|
||||
}
|
||||
|
||||
// BalanceNotifyExtraEmails returns the value of the "balance_notify_extra_emails" field in the mutation.
|
||||
func (m *UserMutation) BalanceNotifyExtraEmails() (r string, exists bool) {
|
||||
v := m.balance_notify_extra_emails
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldBalanceNotifyExtraEmails returns the old "balance_notify_extra_emails" 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) OldBalanceNotifyExtraEmails(ctx context.Context) (v string, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldBalanceNotifyExtraEmails is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldBalanceNotifyExtraEmails requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldBalanceNotifyExtraEmails: %w", err)
|
||||
}
|
||||
return oldValue.BalanceNotifyExtraEmails, nil
|
||||
}
|
||||
|
||||
// ResetBalanceNotifyExtraEmails resets all changes to the "balance_notify_extra_emails" field.
|
||||
func (m *UserMutation) ResetBalanceNotifyExtraEmails() {
|
||||
m.balance_notify_extra_emails = nil
|
||||
}
|
||||
|
||||
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by ids.
|
||||
func (m *UserMutation) AddAPIKeyIDs(ids ...int64) {
|
||||
if m.api_keys == nil {
|
||||
@ -29501,7 +29647,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, 14)
|
||||
fields := make([]string, 0, 17)
|
||||
if m.created_at != nil {
|
||||
fields = append(fields, user.FieldCreatedAt)
|
||||
}
|
||||
@ -29544,6 +29690,15 @@ func (m *UserMutation) Fields() []string {
|
||||
if m.totp_enabled_at != nil {
|
||||
fields = append(fields, user.FieldTotpEnabledAt)
|
||||
}
|
||||
if m.balance_notify_enabled != nil {
|
||||
fields = append(fields, user.FieldBalanceNotifyEnabled)
|
||||
}
|
||||
if m.balance_notify_threshold != nil {
|
||||
fields = append(fields, user.FieldBalanceNotifyThreshold)
|
||||
}
|
||||
if m.balance_notify_extra_emails != nil {
|
||||
fields = append(fields, user.FieldBalanceNotifyExtraEmails)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
@ -29580,6 +29735,12 @@ func (m *UserMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.TotpEnabled()
|
||||
case user.FieldTotpEnabledAt:
|
||||
return m.TotpEnabledAt()
|
||||
case user.FieldBalanceNotifyEnabled:
|
||||
return m.BalanceNotifyEnabled()
|
||||
case user.FieldBalanceNotifyThreshold:
|
||||
return m.BalanceNotifyThreshold()
|
||||
case user.FieldBalanceNotifyExtraEmails:
|
||||
return m.BalanceNotifyExtraEmails()
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
@ -29617,6 +29778,12 @@ func (m *UserMutation) OldField(ctx context.Context, name string) (ent.Value, er
|
||||
return m.OldTotpEnabled(ctx)
|
||||
case user.FieldTotpEnabledAt:
|
||||
return m.OldTotpEnabledAt(ctx)
|
||||
case user.FieldBalanceNotifyEnabled:
|
||||
return m.OldBalanceNotifyEnabled(ctx)
|
||||
case user.FieldBalanceNotifyThreshold:
|
||||
return m.OldBalanceNotifyThreshold(ctx)
|
||||
case user.FieldBalanceNotifyExtraEmails:
|
||||
return m.OldBalanceNotifyExtraEmails(ctx)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown User field %s", name)
|
||||
}
|
||||
@ -29724,6 +29891,27 @@ func (m *UserMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetTotpEnabledAt(v)
|
||||
return nil
|
||||
case user.FieldBalanceNotifyEnabled:
|
||||
v, ok := value.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetBalanceNotifyEnabled(v)
|
||||
return nil
|
||||
case user.FieldBalanceNotifyThreshold:
|
||||
v, ok := value.(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetBalanceNotifyThreshold(v)
|
||||
return nil
|
||||
case user.FieldBalanceNotifyExtraEmails:
|
||||
v, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetBalanceNotifyExtraEmails(v)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unknown User field %s", name)
|
||||
}
|
||||
@ -29738,6 +29926,9 @@ func (m *UserMutation) AddedFields() []string {
|
||||
if m.addconcurrency != nil {
|
||||
fields = append(fields, user.FieldConcurrency)
|
||||
}
|
||||
if m.addbalance_notify_threshold != nil {
|
||||
fields = append(fields, user.FieldBalanceNotifyThreshold)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
@ -29750,6 +29941,8 @@ func (m *UserMutation) AddedField(name string) (ent.Value, bool) {
|
||||
return m.AddedBalance()
|
||||
case user.FieldConcurrency:
|
||||
return m.AddedConcurrency()
|
||||
case user.FieldBalanceNotifyThreshold:
|
||||
return m.AddedBalanceNotifyThreshold()
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
@ -29773,6 +29966,13 @@ func (m *UserMutation) AddField(name string, value ent.Value) error {
|
||||
}
|
||||
m.AddConcurrency(v)
|
||||
return nil
|
||||
case user.FieldBalanceNotifyThreshold:
|
||||
v, ok := value.(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.AddBalanceNotifyThreshold(v)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unknown User numeric field %s", name)
|
||||
}
|
||||
@ -29790,6 +29990,9 @@ func (m *UserMutation) ClearedFields() []string {
|
||||
if m.FieldCleared(user.FieldTotpEnabledAt) {
|
||||
fields = append(fields, user.FieldTotpEnabledAt)
|
||||
}
|
||||
if m.FieldCleared(user.FieldBalanceNotifyThreshold) {
|
||||
fields = append(fields, user.FieldBalanceNotifyThreshold)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
@ -29813,6 +30016,9 @@ func (m *UserMutation) ClearField(name string) error {
|
||||
case user.FieldTotpEnabledAt:
|
||||
m.ClearTotpEnabledAt()
|
||||
return nil
|
||||
case user.FieldBalanceNotifyThreshold:
|
||||
m.ClearBalanceNotifyThreshold()
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unknown User nullable field %s", name)
|
||||
}
|
||||
@ -29863,6 +30069,15 @@ func (m *UserMutation) ResetField(name string) error {
|
||||
case user.FieldTotpEnabledAt:
|
||||
m.ResetTotpEnabledAt()
|
||||
return nil
|
||||
case user.FieldBalanceNotifyEnabled:
|
||||
m.ResetBalanceNotifyEnabled()
|
||||
return nil
|
||||
case user.FieldBalanceNotifyThreshold:
|
||||
m.ResetBalanceNotifyThreshold()
|
||||
return nil
|
||||
case user.FieldBalanceNotifyExtraEmails:
|
||||
m.ResetBalanceNotifyExtraEmails()
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unknown User field %s", name)
|
||||
}
|
||||
|
||||
@ -1293,6 +1293,14 @@ func init() {
|
||||
userDescTotpEnabled := userFields[9].Descriptor()
|
||||
// user.DefaultTotpEnabled holds the default value on creation for the totp_enabled field.
|
||||
user.DefaultTotpEnabled = userDescTotpEnabled.Default.(bool)
|
||||
// userDescBalanceNotifyEnabled is the schema descriptor for balance_notify_enabled field.
|
||||
userDescBalanceNotifyEnabled := userFields[11].Descriptor()
|
||||
// user.DefaultBalanceNotifyEnabled holds the default value on creation for the balance_notify_enabled field.
|
||||
user.DefaultBalanceNotifyEnabled = userDescBalanceNotifyEnabled.Default.(bool)
|
||||
// userDescBalanceNotifyExtraEmails is the schema descriptor for balance_notify_extra_emails field.
|
||||
userDescBalanceNotifyExtraEmails := userFields[13].Descriptor()
|
||||
// user.DefaultBalanceNotifyExtraEmails holds the default value on creation for the balance_notify_extra_emails field.
|
||||
user.DefaultBalanceNotifyExtraEmails = userDescBalanceNotifyExtraEmails.Default.(string)
|
||||
userallowedgroupFields := schema.UserAllowedGroup{}.Fields()
|
||||
_ = userallowedgroupFields
|
||||
// userallowedgroupDescCreatedAt is the schema descriptor for created_at field.
|
||||
|
||||
@ -72,6 +72,17 @@ func (User) Fields() []ent.Field {
|
||||
field.Time("totp_enabled_at").
|
||||
Optional().
|
||||
Nillable(),
|
||||
|
||||
// 余额不足通知
|
||||
field.Bool("balance_notify_enabled").
|
||||
Default(true),
|
||||
field.Float("balance_notify_threshold").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.String("balance_notify_extra_emails").
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}).
|
||||
Default("[]"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -45,6 +45,12 @@ type User struct {
|
||||
TotpEnabled bool `json:"totp_enabled,omitempty"`
|
||||
// TotpEnabledAt holds the value of the "totp_enabled_at" field.
|
||||
TotpEnabledAt *time.Time `json:"totp_enabled_at,omitempty"`
|
||||
// BalanceNotifyEnabled holds the value of the "balance_notify_enabled" field.
|
||||
BalanceNotifyEnabled bool `json:"balance_notify_enabled,omitempty"`
|
||||
// BalanceNotifyThreshold holds the value of the "balance_notify_threshold" field.
|
||||
BalanceNotifyThreshold *float64 `json:"balance_notify_threshold,omitempty"`
|
||||
// BalanceNotifyExtraEmails holds the value of the "balance_notify_extra_emails" field.
|
||||
BalanceNotifyExtraEmails string `json:"balance_notify_extra_emails,omitempty"`
|
||||
// Edges holds the relations/edges for other nodes in the graph.
|
||||
// The values are being populated by the UserQuery when eager-loading is set.
|
||||
Edges UserEdges `json:"edges"`
|
||||
@ -184,13 +190,13 @@ func (*User) scanValues(columns []string) ([]any, error) {
|
||||
values := make([]any, len(columns))
|
||||
for i := range columns {
|
||||
switch columns[i] {
|
||||
case user.FieldTotpEnabled:
|
||||
case user.FieldTotpEnabled, user.FieldBalanceNotifyEnabled:
|
||||
values[i] = new(sql.NullBool)
|
||||
case user.FieldBalance:
|
||||
case user.FieldBalance, user.FieldBalanceNotifyThreshold:
|
||||
values[i] = new(sql.NullFloat64)
|
||||
case user.FieldID, user.FieldConcurrency:
|
||||
values[i] = new(sql.NullInt64)
|
||||
case user.FieldEmail, user.FieldPasswordHash, user.FieldRole, user.FieldStatus, user.FieldUsername, user.FieldNotes, user.FieldTotpSecretEncrypted:
|
||||
case user.FieldEmail, user.FieldPasswordHash, user.FieldRole, user.FieldStatus, user.FieldUsername, user.FieldNotes, user.FieldTotpSecretEncrypted, user.FieldBalanceNotifyExtraEmails:
|
||||
values[i] = new(sql.NullString)
|
||||
case user.FieldCreatedAt, user.FieldUpdatedAt, user.FieldDeletedAt, user.FieldTotpEnabledAt:
|
||||
values[i] = new(sql.NullTime)
|
||||
@ -302,6 +308,25 @@ func (_m *User) assignValues(columns []string, values []any) error {
|
||||
_m.TotpEnabledAt = new(time.Time)
|
||||
*_m.TotpEnabledAt = value.Time
|
||||
}
|
||||
case user.FieldBalanceNotifyEnabled:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field balance_notify_enabled", values[i])
|
||||
} else if value.Valid {
|
||||
_m.BalanceNotifyEnabled = value.Bool
|
||||
}
|
||||
case user.FieldBalanceNotifyThreshold:
|
||||
if value, ok := values[i].(*sql.NullFloat64); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field balance_notify_threshold", values[i])
|
||||
} else if value.Valid {
|
||||
_m.BalanceNotifyThreshold = new(float64)
|
||||
*_m.BalanceNotifyThreshold = value.Float64
|
||||
}
|
||||
case user.FieldBalanceNotifyExtraEmails:
|
||||
if value, ok := values[i].(*sql.NullString); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field balance_notify_extra_emails", values[i])
|
||||
} else if value.Valid {
|
||||
_m.BalanceNotifyExtraEmails = value.String
|
||||
}
|
||||
default:
|
||||
_m.selectValues.Set(columns[i], values[i])
|
||||
}
|
||||
@ -440,6 +465,17 @@ func (_m *User) String() string {
|
||||
builder.WriteString("totp_enabled_at=")
|
||||
builder.WriteString(v.Format(time.ANSIC))
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("balance_notify_enabled=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.BalanceNotifyEnabled))
|
||||
builder.WriteString(", ")
|
||||
if v := _m.BalanceNotifyThreshold; v != nil {
|
||||
builder.WriteString("balance_notify_threshold=")
|
||||
builder.WriteString(fmt.Sprintf("%v", *v))
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("balance_notify_extra_emails=")
|
||||
builder.WriteString(_m.BalanceNotifyExtraEmails)
|
||||
builder.WriteByte(')')
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
@ -43,6 +43,12 @@ const (
|
||||
FieldTotpEnabled = "totp_enabled"
|
||||
// FieldTotpEnabledAt holds the string denoting the totp_enabled_at field in the database.
|
||||
FieldTotpEnabledAt = "totp_enabled_at"
|
||||
// FieldBalanceNotifyEnabled holds the string denoting the balance_notify_enabled field in the database.
|
||||
FieldBalanceNotifyEnabled = "balance_notify_enabled"
|
||||
// FieldBalanceNotifyThreshold holds the string denoting the balance_notify_threshold field in the database.
|
||||
FieldBalanceNotifyThreshold = "balance_notify_threshold"
|
||||
// FieldBalanceNotifyExtraEmails holds the string denoting the balance_notify_extra_emails field in the database.
|
||||
FieldBalanceNotifyExtraEmails = "balance_notify_extra_emails"
|
||||
// EdgeAPIKeys holds the string denoting the api_keys edge name in mutations.
|
||||
EdgeAPIKeys = "api_keys"
|
||||
// EdgeRedeemCodes holds the string denoting the redeem_codes edge name in mutations.
|
||||
@ -161,6 +167,9 @@ var Columns = []string{
|
||||
FieldTotpSecretEncrypted,
|
||||
FieldTotpEnabled,
|
||||
FieldTotpEnabledAt,
|
||||
FieldBalanceNotifyEnabled,
|
||||
FieldBalanceNotifyThreshold,
|
||||
FieldBalanceNotifyExtraEmails,
|
||||
}
|
||||
|
||||
var (
|
||||
@ -217,6 +226,10 @@ var (
|
||||
DefaultNotes string
|
||||
// DefaultTotpEnabled holds the default value on creation for the "totp_enabled" field.
|
||||
DefaultTotpEnabled bool
|
||||
// DefaultBalanceNotifyEnabled holds the default value on creation for the "balance_notify_enabled" field.
|
||||
DefaultBalanceNotifyEnabled bool
|
||||
// DefaultBalanceNotifyExtraEmails holds the default value on creation for the "balance_notify_extra_emails" field.
|
||||
DefaultBalanceNotifyExtraEmails string
|
||||
)
|
||||
|
||||
// OrderOption defines the ordering options for the User queries.
|
||||
@ -297,6 +310,21 @@ func ByTotpEnabledAt(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldTotpEnabledAt, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByBalanceNotifyEnabled orders the results by the balance_notify_enabled field.
|
||||
func ByBalanceNotifyEnabled(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldBalanceNotifyEnabled, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByBalanceNotifyThreshold orders the results by the balance_notify_threshold field.
|
||||
func ByBalanceNotifyThreshold(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldBalanceNotifyThreshold, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByBalanceNotifyExtraEmails orders the results by the balance_notify_extra_emails field.
|
||||
func ByBalanceNotifyExtraEmails(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldBalanceNotifyExtraEmails, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByAPIKeysCount orders the results by api_keys count.
|
||||
func ByAPIKeysCount(opts ...sql.OrderTermOption) OrderOption {
|
||||
return func(s *sql.Selector) {
|
||||
|
||||
@ -125,6 +125,21 @@ func TotpEnabledAt(v time.Time) predicate.User {
|
||||
return predicate.User(sql.FieldEQ(FieldTotpEnabledAt, v))
|
||||
}
|
||||
|
||||
// BalanceNotifyEnabled applies equality check predicate on the "balance_notify_enabled" field. It's identical to BalanceNotifyEnabledEQ.
|
||||
func BalanceNotifyEnabled(v bool) predicate.User {
|
||||
return predicate.User(sql.FieldEQ(FieldBalanceNotifyEnabled, v))
|
||||
}
|
||||
|
||||
// BalanceNotifyThreshold applies equality check predicate on the "balance_notify_threshold" field. It's identical to BalanceNotifyThresholdEQ.
|
||||
func BalanceNotifyThreshold(v float64) predicate.User {
|
||||
return predicate.User(sql.FieldEQ(FieldBalanceNotifyThreshold, v))
|
||||
}
|
||||
|
||||
// BalanceNotifyExtraEmails applies equality check predicate on the "balance_notify_extra_emails" field. It's identical to BalanceNotifyExtraEmailsEQ.
|
||||
func BalanceNotifyExtraEmails(v string) predicate.User {
|
||||
return predicate.User(sql.FieldEQ(FieldBalanceNotifyExtraEmails, v))
|
||||
}
|
||||
|
||||
// CreatedAtEQ applies the EQ predicate on the "created_at" field.
|
||||
func CreatedAtEQ(v time.Time) predicate.User {
|
||||
return predicate.User(sql.FieldEQ(FieldCreatedAt, v))
|
||||
@ -860,6 +875,131 @@ func TotpEnabledAtNotNil() predicate.User {
|
||||
return predicate.User(sql.FieldNotNull(FieldTotpEnabledAt))
|
||||
}
|
||||
|
||||
// BalanceNotifyEnabledEQ applies the EQ predicate on the "balance_notify_enabled" field.
|
||||
func BalanceNotifyEnabledEQ(v bool) predicate.User {
|
||||
return predicate.User(sql.FieldEQ(FieldBalanceNotifyEnabled, v))
|
||||
}
|
||||
|
||||
// BalanceNotifyEnabledNEQ applies the NEQ predicate on the "balance_notify_enabled" field.
|
||||
func BalanceNotifyEnabledNEQ(v bool) predicate.User {
|
||||
return predicate.User(sql.FieldNEQ(FieldBalanceNotifyEnabled, v))
|
||||
}
|
||||
|
||||
// BalanceNotifyThresholdEQ applies the EQ predicate on the "balance_notify_threshold" field.
|
||||
func BalanceNotifyThresholdEQ(v float64) predicate.User {
|
||||
return predicate.User(sql.FieldEQ(FieldBalanceNotifyThreshold, v))
|
||||
}
|
||||
|
||||
// BalanceNotifyThresholdNEQ applies the NEQ predicate on the "balance_notify_threshold" field.
|
||||
func BalanceNotifyThresholdNEQ(v float64) predicate.User {
|
||||
return predicate.User(sql.FieldNEQ(FieldBalanceNotifyThreshold, v))
|
||||
}
|
||||
|
||||
// BalanceNotifyThresholdIn applies the In predicate on the "balance_notify_threshold" field.
|
||||
func BalanceNotifyThresholdIn(vs ...float64) predicate.User {
|
||||
return predicate.User(sql.FieldIn(FieldBalanceNotifyThreshold, vs...))
|
||||
}
|
||||
|
||||
// BalanceNotifyThresholdNotIn applies the NotIn predicate on the "balance_notify_threshold" field.
|
||||
func BalanceNotifyThresholdNotIn(vs ...float64) predicate.User {
|
||||
return predicate.User(sql.FieldNotIn(FieldBalanceNotifyThreshold, vs...))
|
||||
}
|
||||
|
||||
// BalanceNotifyThresholdGT applies the GT predicate on the "balance_notify_threshold" field.
|
||||
func BalanceNotifyThresholdGT(v float64) predicate.User {
|
||||
return predicate.User(sql.FieldGT(FieldBalanceNotifyThreshold, v))
|
||||
}
|
||||
|
||||
// BalanceNotifyThresholdGTE applies the GTE predicate on the "balance_notify_threshold" field.
|
||||
func BalanceNotifyThresholdGTE(v float64) predicate.User {
|
||||
return predicate.User(sql.FieldGTE(FieldBalanceNotifyThreshold, v))
|
||||
}
|
||||
|
||||
// BalanceNotifyThresholdLT applies the LT predicate on the "balance_notify_threshold" field.
|
||||
func BalanceNotifyThresholdLT(v float64) predicate.User {
|
||||
return predicate.User(sql.FieldLT(FieldBalanceNotifyThreshold, v))
|
||||
}
|
||||
|
||||
// BalanceNotifyThresholdLTE applies the LTE predicate on the "balance_notify_threshold" field.
|
||||
func BalanceNotifyThresholdLTE(v float64) predicate.User {
|
||||
return predicate.User(sql.FieldLTE(FieldBalanceNotifyThreshold, v))
|
||||
}
|
||||
|
||||
// BalanceNotifyThresholdIsNil applies the IsNil predicate on the "balance_notify_threshold" field.
|
||||
func BalanceNotifyThresholdIsNil() predicate.User {
|
||||
return predicate.User(sql.FieldIsNull(FieldBalanceNotifyThreshold))
|
||||
}
|
||||
|
||||
// BalanceNotifyThresholdNotNil applies the NotNil predicate on the "balance_notify_threshold" field.
|
||||
func BalanceNotifyThresholdNotNil() predicate.User {
|
||||
return predicate.User(sql.FieldNotNull(FieldBalanceNotifyThreshold))
|
||||
}
|
||||
|
||||
// BalanceNotifyExtraEmailsEQ applies the EQ predicate on the "balance_notify_extra_emails" field.
|
||||
func BalanceNotifyExtraEmailsEQ(v string) predicate.User {
|
||||
return predicate.User(sql.FieldEQ(FieldBalanceNotifyExtraEmails, v))
|
||||
}
|
||||
|
||||
// BalanceNotifyExtraEmailsNEQ applies the NEQ predicate on the "balance_notify_extra_emails" field.
|
||||
func BalanceNotifyExtraEmailsNEQ(v string) predicate.User {
|
||||
return predicate.User(sql.FieldNEQ(FieldBalanceNotifyExtraEmails, v))
|
||||
}
|
||||
|
||||
// BalanceNotifyExtraEmailsIn applies the In predicate on the "balance_notify_extra_emails" field.
|
||||
func BalanceNotifyExtraEmailsIn(vs ...string) predicate.User {
|
||||
return predicate.User(sql.FieldIn(FieldBalanceNotifyExtraEmails, vs...))
|
||||
}
|
||||
|
||||
// BalanceNotifyExtraEmailsNotIn applies the NotIn predicate on the "balance_notify_extra_emails" field.
|
||||
func BalanceNotifyExtraEmailsNotIn(vs ...string) predicate.User {
|
||||
return predicate.User(sql.FieldNotIn(FieldBalanceNotifyExtraEmails, vs...))
|
||||
}
|
||||
|
||||
// BalanceNotifyExtraEmailsGT applies the GT predicate on the "balance_notify_extra_emails" field.
|
||||
func BalanceNotifyExtraEmailsGT(v string) predicate.User {
|
||||
return predicate.User(sql.FieldGT(FieldBalanceNotifyExtraEmails, v))
|
||||
}
|
||||
|
||||
// BalanceNotifyExtraEmailsGTE applies the GTE predicate on the "balance_notify_extra_emails" field.
|
||||
func BalanceNotifyExtraEmailsGTE(v string) predicate.User {
|
||||
return predicate.User(sql.FieldGTE(FieldBalanceNotifyExtraEmails, v))
|
||||
}
|
||||
|
||||
// BalanceNotifyExtraEmailsLT applies the LT predicate on the "balance_notify_extra_emails" field.
|
||||
func BalanceNotifyExtraEmailsLT(v string) predicate.User {
|
||||
return predicate.User(sql.FieldLT(FieldBalanceNotifyExtraEmails, v))
|
||||
}
|
||||
|
||||
// BalanceNotifyExtraEmailsLTE applies the LTE predicate on the "balance_notify_extra_emails" field.
|
||||
func BalanceNotifyExtraEmailsLTE(v string) predicate.User {
|
||||
return predicate.User(sql.FieldLTE(FieldBalanceNotifyExtraEmails, v))
|
||||
}
|
||||
|
||||
// BalanceNotifyExtraEmailsContains applies the Contains predicate on the "balance_notify_extra_emails" field.
|
||||
func BalanceNotifyExtraEmailsContains(v string) predicate.User {
|
||||
return predicate.User(sql.FieldContains(FieldBalanceNotifyExtraEmails, v))
|
||||
}
|
||||
|
||||
// BalanceNotifyExtraEmailsHasPrefix applies the HasPrefix predicate on the "balance_notify_extra_emails" field.
|
||||
func BalanceNotifyExtraEmailsHasPrefix(v string) predicate.User {
|
||||
return predicate.User(sql.FieldHasPrefix(FieldBalanceNotifyExtraEmails, v))
|
||||
}
|
||||
|
||||
// BalanceNotifyExtraEmailsHasSuffix applies the HasSuffix predicate on the "balance_notify_extra_emails" field.
|
||||
func BalanceNotifyExtraEmailsHasSuffix(v string) predicate.User {
|
||||
return predicate.User(sql.FieldHasSuffix(FieldBalanceNotifyExtraEmails, v))
|
||||
}
|
||||
|
||||
// BalanceNotifyExtraEmailsEqualFold applies the EqualFold predicate on the "balance_notify_extra_emails" field.
|
||||
func BalanceNotifyExtraEmailsEqualFold(v string) predicate.User {
|
||||
return predicate.User(sql.FieldEqualFold(FieldBalanceNotifyExtraEmails, v))
|
||||
}
|
||||
|
||||
// BalanceNotifyExtraEmailsContainsFold applies the ContainsFold predicate on the "balance_notify_extra_emails" field.
|
||||
func BalanceNotifyExtraEmailsContainsFold(v string) predicate.User {
|
||||
return predicate.User(sql.FieldContainsFold(FieldBalanceNotifyExtraEmails, v))
|
||||
}
|
||||
|
||||
// HasAPIKeys applies the HasEdge predicate on the "api_keys" edge.
|
||||
func HasAPIKeys() predicate.User {
|
||||
return predicate.User(func(s *sql.Selector) {
|
||||
|
||||
@ -211,6 +211,48 @@ func (_c *UserCreate) SetNillableTotpEnabledAt(v *time.Time) *UserCreate {
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
|
||||
func (_c *UserCreate) SetBalanceNotifyEnabled(v bool) *UserCreate {
|
||||
_c.mutation.SetBalanceNotifyEnabled(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableBalanceNotifyEnabled sets the "balance_notify_enabled" field if the given value is not nil.
|
||||
func (_c *UserCreate) SetNillableBalanceNotifyEnabled(v *bool) *UserCreate {
|
||||
if v != nil {
|
||||
_c.SetBalanceNotifyEnabled(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetBalanceNotifyThreshold sets the "balance_notify_threshold" field.
|
||||
func (_c *UserCreate) SetBalanceNotifyThreshold(v float64) *UserCreate {
|
||||
_c.mutation.SetBalanceNotifyThreshold(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableBalanceNotifyThreshold sets the "balance_notify_threshold" field if the given value is not nil.
|
||||
func (_c *UserCreate) SetNillableBalanceNotifyThreshold(v *float64) *UserCreate {
|
||||
if v != nil {
|
||||
_c.SetBalanceNotifyThreshold(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field.
|
||||
func (_c *UserCreate) SetBalanceNotifyExtraEmails(v string) *UserCreate {
|
||||
_c.mutation.SetBalanceNotifyExtraEmails(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field if the given value is not nil.
|
||||
func (_c *UserCreate) SetNillableBalanceNotifyExtraEmails(v *string) *UserCreate {
|
||||
if v != nil {
|
||||
_c.SetBalanceNotifyExtraEmails(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
|
||||
func (_c *UserCreate) AddAPIKeyIDs(ids ...int64) *UserCreate {
|
||||
_c.mutation.AddAPIKeyIDs(ids...)
|
||||
@ -440,6 +482,14 @@ func (_c *UserCreate) defaults() error {
|
||||
v := user.DefaultTotpEnabled
|
||||
_c.mutation.SetTotpEnabled(v)
|
||||
}
|
||||
if _, ok := _c.mutation.BalanceNotifyEnabled(); !ok {
|
||||
v := user.DefaultBalanceNotifyEnabled
|
||||
_c.mutation.SetBalanceNotifyEnabled(v)
|
||||
}
|
||||
if _, ok := _c.mutation.BalanceNotifyExtraEmails(); !ok {
|
||||
v := user.DefaultBalanceNotifyExtraEmails
|
||||
_c.mutation.SetBalanceNotifyExtraEmails(v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -503,6 +553,12 @@ func (_c *UserCreate) check() error {
|
||||
if _, ok := _c.mutation.TotpEnabled(); !ok {
|
||||
return &ValidationError{Name: "totp_enabled", err: errors.New(`ent: missing required field "User.totp_enabled"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.BalanceNotifyEnabled(); !ok {
|
||||
return &ValidationError{Name: "balance_notify_enabled", err: errors.New(`ent: missing required field "User.balance_notify_enabled"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.BalanceNotifyExtraEmails(); !ok {
|
||||
return &ValidationError{Name: "balance_notify_extra_emails", err: errors.New(`ent: missing required field "User.balance_notify_extra_emails"`)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -586,6 +642,18 @@ func (_c *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(user.FieldTotpEnabledAt, field.TypeTime, value)
|
||||
_node.TotpEnabledAt = &value
|
||||
}
|
||||
if value, ok := _c.mutation.BalanceNotifyEnabled(); ok {
|
||||
_spec.SetField(user.FieldBalanceNotifyEnabled, field.TypeBool, value)
|
||||
_node.BalanceNotifyEnabled = value
|
||||
}
|
||||
if value, ok := _c.mutation.BalanceNotifyThreshold(); ok {
|
||||
_spec.SetField(user.FieldBalanceNotifyThreshold, field.TypeFloat64, value)
|
||||
_node.BalanceNotifyThreshold = &value
|
||||
}
|
||||
if value, ok := _c.mutation.BalanceNotifyExtraEmails(); ok {
|
||||
_spec.SetField(user.FieldBalanceNotifyExtraEmails, field.TypeString, value)
|
||||
_node.BalanceNotifyExtraEmails = value
|
||||
}
|
||||
if nodes := _c.mutation.APIKeysIDs(); len(nodes) > 0 {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
@ -988,6 +1056,54 @@ func (u *UserUpsert) ClearTotpEnabledAt() *UserUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
|
||||
func (u *UserUpsert) SetBalanceNotifyEnabled(v bool) *UserUpsert {
|
||||
u.Set(user.FieldBalanceNotifyEnabled, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateBalanceNotifyEnabled sets the "balance_notify_enabled" field to the value that was provided on create.
|
||||
func (u *UserUpsert) UpdateBalanceNotifyEnabled() *UserUpsert {
|
||||
u.SetExcluded(user.FieldBalanceNotifyEnabled)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetBalanceNotifyThreshold sets the "balance_notify_threshold" field.
|
||||
func (u *UserUpsert) SetBalanceNotifyThreshold(v float64) *UserUpsert {
|
||||
u.Set(user.FieldBalanceNotifyThreshold, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateBalanceNotifyThreshold sets the "balance_notify_threshold" field to the value that was provided on create.
|
||||
func (u *UserUpsert) UpdateBalanceNotifyThreshold() *UserUpsert {
|
||||
u.SetExcluded(user.FieldBalanceNotifyThreshold)
|
||||
return u
|
||||
}
|
||||
|
||||
// AddBalanceNotifyThreshold adds v to the "balance_notify_threshold" field.
|
||||
func (u *UserUpsert) AddBalanceNotifyThreshold(v float64) *UserUpsert {
|
||||
u.Add(user.FieldBalanceNotifyThreshold, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// ClearBalanceNotifyThreshold clears the value of the "balance_notify_threshold" field.
|
||||
func (u *UserUpsert) ClearBalanceNotifyThreshold() *UserUpsert {
|
||||
u.SetNull(user.FieldBalanceNotifyThreshold)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field.
|
||||
func (u *UserUpsert) SetBalanceNotifyExtraEmails(v string) *UserUpsert {
|
||||
u.Set(user.FieldBalanceNotifyExtraEmails, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field to the value that was provided on create.
|
||||
func (u *UserUpsert) UpdateBalanceNotifyExtraEmails() *UserUpsert {
|
||||
u.SetExcluded(user.FieldBalanceNotifyExtraEmails)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateNewValues updates the mutable fields using the new values that were set on create.
|
||||
// Using this option is equivalent to using:
|
||||
//
|
||||
@ -1250,6 +1366,62 @@ func (u *UserUpsertOne) ClearTotpEnabledAt() *UserUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
|
||||
func (u *UserUpsertOne) SetBalanceNotifyEnabled(v bool) *UserUpsertOne {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
s.SetBalanceNotifyEnabled(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateBalanceNotifyEnabled sets the "balance_notify_enabled" field to the value that was provided on create.
|
||||
func (u *UserUpsertOne) UpdateBalanceNotifyEnabled() *UserUpsertOne {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
s.UpdateBalanceNotifyEnabled()
|
||||
})
|
||||
}
|
||||
|
||||
// SetBalanceNotifyThreshold sets the "balance_notify_threshold" field.
|
||||
func (u *UserUpsertOne) SetBalanceNotifyThreshold(v float64) *UserUpsertOne {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
s.SetBalanceNotifyThreshold(v)
|
||||
})
|
||||
}
|
||||
|
||||
// AddBalanceNotifyThreshold adds v to the "balance_notify_threshold" field.
|
||||
func (u *UserUpsertOne) AddBalanceNotifyThreshold(v float64) *UserUpsertOne {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
s.AddBalanceNotifyThreshold(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateBalanceNotifyThreshold sets the "balance_notify_threshold" field to the value that was provided on create.
|
||||
func (u *UserUpsertOne) UpdateBalanceNotifyThreshold() *UserUpsertOne {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
s.UpdateBalanceNotifyThreshold()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearBalanceNotifyThreshold clears the value of the "balance_notify_threshold" field.
|
||||
func (u *UserUpsertOne) ClearBalanceNotifyThreshold() *UserUpsertOne {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
s.ClearBalanceNotifyThreshold()
|
||||
})
|
||||
}
|
||||
|
||||
// SetBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field.
|
||||
func (u *UserUpsertOne) SetBalanceNotifyExtraEmails(v string) *UserUpsertOne {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
s.SetBalanceNotifyExtraEmails(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field to the value that was provided on create.
|
||||
func (u *UserUpsertOne) UpdateBalanceNotifyExtraEmails() *UserUpsertOne {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
s.UpdateBalanceNotifyExtraEmails()
|
||||
})
|
||||
}
|
||||
|
||||
// Exec executes the query.
|
||||
func (u *UserUpsertOne) Exec(ctx context.Context) error {
|
||||
if len(u.create.conflict) == 0 {
|
||||
@ -1678,6 +1850,62 @@ func (u *UserUpsertBulk) ClearTotpEnabledAt() *UserUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
|
||||
func (u *UserUpsertBulk) SetBalanceNotifyEnabled(v bool) *UserUpsertBulk {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
s.SetBalanceNotifyEnabled(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateBalanceNotifyEnabled sets the "balance_notify_enabled" field to the value that was provided on create.
|
||||
func (u *UserUpsertBulk) UpdateBalanceNotifyEnabled() *UserUpsertBulk {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
s.UpdateBalanceNotifyEnabled()
|
||||
})
|
||||
}
|
||||
|
||||
// SetBalanceNotifyThreshold sets the "balance_notify_threshold" field.
|
||||
func (u *UserUpsertBulk) SetBalanceNotifyThreshold(v float64) *UserUpsertBulk {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
s.SetBalanceNotifyThreshold(v)
|
||||
})
|
||||
}
|
||||
|
||||
// AddBalanceNotifyThreshold adds v to the "balance_notify_threshold" field.
|
||||
func (u *UserUpsertBulk) AddBalanceNotifyThreshold(v float64) *UserUpsertBulk {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
s.AddBalanceNotifyThreshold(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateBalanceNotifyThreshold sets the "balance_notify_threshold" field to the value that was provided on create.
|
||||
func (u *UserUpsertBulk) UpdateBalanceNotifyThreshold() *UserUpsertBulk {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
s.UpdateBalanceNotifyThreshold()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearBalanceNotifyThreshold clears the value of the "balance_notify_threshold" field.
|
||||
func (u *UserUpsertBulk) ClearBalanceNotifyThreshold() *UserUpsertBulk {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
s.ClearBalanceNotifyThreshold()
|
||||
})
|
||||
}
|
||||
|
||||
// SetBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field.
|
||||
func (u *UserUpsertBulk) SetBalanceNotifyExtraEmails(v string) *UserUpsertBulk {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
s.SetBalanceNotifyExtraEmails(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field to the value that was provided on create.
|
||||
func (u *UserUpsertBulk) UpdateBalanceNotifyExtraEmails() *UserUpsertBulk {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
s.UpdateBalanceNotifyExtraEmails()
|
||||
})
|
||||
}
|
||||
|
||||
// Exec executes the query.
|
||||
func (u *UserUpsertBulk) Exec(ctx context.Context) error {
|
||||
if u.create.err != nil {
|
||||
|
||||
@ -243,6 +243,61 @@ func (_u *UserUpdate) ClearTotpEnabledAt() *UserUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
|
||||
func (_u *UserUpdate) SetBalanceNotifyEnabled(v bool) *UserUpdate {
|
||||
_u.mutation.SetBalanceNotifyEnabled(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableBalanceNotifyEnabled sets the "balance_notify_enabled" field if the given value is not nil.
|
||||
func (_u *UserUpdate) SetNillableBalanceNotifyEnabled(v *bool) *UserUpdate {
|
||||
if v != nil {
|
||||
_u.SetBalanceNotifyEnabled(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetBalanceNotifyThreshold sets the "balance_notify_threshold" field.
|
||||
func (_u *UserUpdate) SetBalanceNotifyThreshold(v float64) *UserUpdate {
|
||||
_u.mutation.ResetBalanceNotifyThreshold()
|
||||
_u.mutation.SetBalanceNotifyThreshold(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableBalanceNotifyThreshold sets the "balance_notify_threshold" field if the given value is not nil.
|
||||
func (_u *UserUpdate) SetNillableBalanceNotifyThreshold(v *float64) *UserUpdate {
|
||||
if v != nil {
|
||||
_u.SetBalanceNotifyThreshold(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddBalanceNotifyThreshold adds value to the "balance_notify_threshold" field.
|
||||
func (_u *UserUpdate) AddBalanceNotifyThreshold(v float64) *UserUpdate {
|
||||
_u.mutation.AddBalanceNotifyThreshold(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearBalanceNotifyThreshold clears the value of the "balance_notify_threshold" field.
|
||||
func (_u *UserUpdate) ClearBalanceNotifyThreshold() *UserUpdate {
|
||||
_u.mutation.ClearBalanceNotifyThreshold()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field.
|
||||
func (_u *UserUpdate) SetBalanceNotifyExtraEmails(v string) *UserUpdate {
|
||||
_u.mutation.SetBalanceNotifyExtraEmails(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field if the given value is not nil.
|
||||
func (_u *UserUpdate) SetNillableBalanceNotifyExtraEmails(v *string) *UserUpdate {
|
||||
if v != nil {
|
||||
_u.SetBalanceNotifyExtraEmails(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
|
||||
func (_u *UserUpdate) AddAPIKeyIDs(ids ...int64) *UserUpdate {
|
||||
_u.mutation.AddAPIKeyIDs(ids...)
|
||||
@ -746,6 +801,21 @@ func (_u *UserUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if _u.mutation.TotpEnabledAtCleared() {
|
||||
_spec.ClearField(user.FieldTotpEnabledAt, field.TypeTime)
|
||||
}
|
||||
if value, ok := _u.mutation.BalanceNotifyEnabled(); ok {
|
||||
_spec.SetField(user.FieldBalanceNotifyEnabled, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.BalanceNotifyThreshold(); ok {
|
||||
_spec.SetField(user.FieldBalanceNotifyThreshold, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AddedBalanceNotifyThreshold(); ok {
|
||||
_spec.AddField(user.FieldBalanceNotifyThreshold, field.TypeFloat64, value)
|
||||
}
|
||||
if _u.mutation.BalanceNotifyThresholdCleared() {
|
||||
_spec.ClearField(user.FieldBalanceNotifyThreshold, field.TypeFloat64)
|
||||
}
|
||||
if value, ok := _u.mutation.BalanceNotifyExtraEmails(); ok {
|
||||
_spec.SetField(user.FieldBalanceNotifyExtraEmails, field.TypeString, value)
|
||||
}
|
||||
if _u.mutation.APIKeysCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
@ -1434,6 +1504,61 @@ func (_u *UserUpdateOne) ClearTotpEnabledAt() *UserUpdateOne {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
|
||||
func (_u *UserUpdateOne) SetBalanceNotifyEnabled(v bool) *UserUpdateOne {
|
||||
_u.mutation.SetBalanceNotifyEnabled(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableBalanceNotifyEnabled sets the "balance_notify_enabled" field if the given value is not nil.
|
||||
func (_u *UserUpdateOne) SetNillableBalanceNotifyEnabled(v *bool) *UserUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetBalanceNotifyEnabled(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetBalanceNotifyThreshold sets the "balance_notify_threshold" field.
|
||||
func (_u *UserUpdateOne) SetBalanceNotifyThreshold(v float64) *UserUpdateOne {
|
||||
_u.mutation.ResetBalanceNotifyThreshold()
|
||||
_u.mutation.SetBalanceNotifyThreshold(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableBalanceNotifyThreshold sets the "balance_notify_threshold" field if the given value is not nil.
|
||||
func (_u *UserUpdateOne) SetNillableBalanceNotifyThreshold(v *float64) *UserUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetBalanceNotifyThreshold(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddBalanceNotifyThreshold adds value to the "balance_notify_threshold" field.
|
||||
func (_u *UserUpdateOne) AddBalanceNotifyThreshold(v float64) *UserUpdateOne {
|
||||
_u.mutation.AddBalanceNotifyThreshold(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearBalanceNotifyThreshold clears the value of the "balance_notify_threshold" field.
|
||||
func (_u *UserUpdateOne) ClearBalanceNotifyThreshold() *UserUpdateOne {
|
||||
_u.mutation.ClearBalanceNotifyThreshold()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field.
|
||||
func (_u *UserUpdateOne) SetBalanceNotifyExtraEmails(v string) *UserUpdateOne {
|
||||
_u.mutation.SetBalanceNotifyExtraEmails(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field if the given value is not nil.
|
||||
func (_u *UserUpdateOne) SetNillableBalanceNotifyExtraEmails(v *string) *UserUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetBalanceNotifyExtraEmails(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
|
||||
func (_u *UserUpdateOne) AddAPIKeyIDs(ids ...int64) *UserUpdateOne {
|
||||
_u.mutation.AddAPIKeyIDs(ids...)
|
||||
@ -1967,6 +2092,21 @@ func (_u *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) {
|
||||
if _u.mutation.TotpEnabledAtCleared() {
|
||||
_spec.ClearField(user.FieldTotpEnabledAt, field.TypeTime)
|
||||
}
|
||||
if value, ok := _u.mutation.BalanceNotifyEnabled(); ok {
|
||||
_spec.SetField(user.FieldBalanceNotifyEnabled, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.BalanceNotifyThreshold(); ok {
|
||||
_spec.SetField(user.FieldBalanceNotifyThreshold, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AddedBalanceNotifyThreshold(); ok {
|
||||
_spec.AddField(user.FieldBalanceNotifyThreshold, field.TypeFloat64, value)
|
||||
}
|
||||
if _u.mutation.BalanceNotifyThresholdCleared() {
|
||||
_spec.ClearField(user.FieldBalanceNotifyThreshold, field.TypeFloat64)
|
||||
}
|
||||
if value, ok := _u.mutation.BalanceNotifyExtraEmails(); ok {
|
||||
_spec.SetField(user.FieldBalanceNotifyExtraEmails, field.TypeString, value)
|
||||
}
|
||||
if _u.mutation.APIKeysCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
|
||||
@ -183,6 +183,8 @@ github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
|
||||
github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
|
||||
github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI=
|
||||
github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@ -218,6 +220,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||
@ -251,6 +255,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
@ -280,6 +286,8 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv
|
||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
@ -312,6 +320,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
|
||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
||||
|
||||
@ -175,7 +175,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
EnableFingerprintUnification: settings.EnableFingerprintUnification,
|
||||
EnableMetadataPassthrough: settings.EnableMetadataPassthrough,
|
||||
EnableCCHSigning: settings.EnableCCHSigning,
|
||||
WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled,
|
||||
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
|
||||
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
|
||||
AccountQuotaNotifyEmails: settings.AccountQuotaNotifyEmails,
|
||||
PaymentEnabled: paymentCfg.Enabled,
|
||||
PaymentMinAmount: paymentCfg.MinAmount,
|
||||
PaymentMaxAmount: paymentCfg.MaxAmount,
|
||||
@ -305,6 +307,11 @@ type UpdateSettingsRequest struct {
|
||||
EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"`
|
||||
EnableCCHSigning *bool `json:"enable_cch_signing"`
|
||||
|
||||
// Balance low notification
|
||||
BalanceLowNotifyEnabled *bool `json:"balance_low_notify_enabled"`
|
||||
BalanceLowNotifyThreshold *float64 `json:"balance_low_notify_threshold"`
|
||||
AccountQuotaNotifyEmails *[]string `json:"account_quota_notify_emails"`
|
||||
|
||||
// Payment configuration (integrated into settings, full replace)
|
||||
PaymentEnabled *bool `json:"payment_enabled"`
|
||||
PaymentMinAmount *float64 `json:"payment_min_amount"`
|
||||
@ -882,6 +889,24 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.EnableCCHSigning
|
||||
}(),
|
||||
BalanceLowNotifyEnabled: func() bool {
|
||||
if req.BalanceLowNotifyEnabled != nil {
|
||||
return *req.BalanceLowNotifyEnabled
|
||||
}
|
||||
return previousSettings.BalanceLowNotifyEnabled
|
||||
}(),
|
||||
BalanceLowNotifyThreshold: func() float64 {
|
||||
if req.BalanceLowNotifyThreshold != nil {
|
||||
return *req.BalanceLowNotifyThreshold
|
||||
}
|
||||
return previousSettings.BalanceLowNotifyThreshold
|
||||
}(),
|
||||
AccountQuotaNotifyEmails: func() []string {
|
||||
if req.AccountQuotaNotifyEmails != nil {
|
||||
return *req.AccountQuotaNotifyEmails
|
||||
}
|
||||
return previousSettings.AccountQuotaNotifyEmails
|
||||
}(),
|
||||
}
|
||||
|
||||
if err := h.settingService.UpdateSettings(c.Request.Context(), settings); err != nil {
|
||||
@ -1028,6 +1053,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification,
|
||||
EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough,
|
||||
EnableCCHSigning: updatedSettings.EnableCCHSigning,
|
||||
BalanceLowNotifyEnabled: updatedSettings.BalanceLowNotifyEnabled,
|
||||
BalanceLowNotifyThreshold: updatedSettings.BalanceLowNotifyThreshold,
|
||||
AccountQuotaNotifyEmails: updatedSettings.AccountQuotaNotifyEmails,
|
||||
PaymentEnabled: updatedPaymentCfg.Enabled,
|
||||
PaymentMinAmount: updatedPaymentCfg.MinAmount,
|
||||
PaymentMaxAmount: updatedPaymentCfg.MaxAmount,
|
||||
@ -1848,37 +1876,3 @@ func (h *SettingHandler) UpdateStreamTimeoutSettings(c *gin.Context) {
|
||||
ThresholdWindowMinutes: updatedSettings.ThresholdWindowMinutes,
|
||||
})
|
||||
}
|
||||
|
||||
// GetWebSearchEmulationConfig 获取 Web Search 模拟配置
|
||||
// GET /api/v1/admin/settings/web-search-emulation
|
||||
func (h *SettingHandler) GetWebSearchEmulationConfig(c *gin.Context) {
|
||||
cfg, err := h.settingService.GetWebSearchEmulationConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, service.SanitizeWebSearchConfig(cfg))
|
||||
}
|
||||
|
||||
// UpdateWebSearchEmulationConfig 更新 Web Search 模拟配置
|
||||
// PUT /api/v1/admin/settings/web-search-emulation
|
||||
func (h *SettingHandler) UpdateWebSearchEmulationConfig(c *gin.Context) {
|
||||
var cfg service.WebSearchEmulationConfig
|
||||
if err := c.ShouldBindJSON(&cfg); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.settingService.SaveWebSearchEmulationConfig(c.Request.Context(), &cfg); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Re-read (with sanitized api keys) to return current state
|
||||
updated, err := h.settingService.GetWebSearchEmulationConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, service.SanitizeWebSearchConfig(updated))
|
||||
}
|
||||
|
||||
@ -13,16 +13,19 @@ func UserFromServiceShallow(u *service.User) *User {
|
||||
return nil
|
||||
}
|
||||
return &User{
|
||||
ID: u.ID,
|
||||
Email: u.Email,
|
||||
Username: u.Username,
|
||||
Role: u.Role,
|
||||
Balance: u.Balance,
|
||||
Concurrency: u.Concurrency,
|
||||
Status: u.Status,
|
||||
AllowedGroups: u.AllowedGroups,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
ID: u.ID,
|
||||
Email: u.Email,
|
||||
Username: u.Username,
|
||||
Role: u.Role,
|
||||
Balance: u.Balance,
|
||||
Concurrency: u.Concurrency,
|
||||
Status: u.Status,
|
||||
AllowedGroups: u.AllowedGroups,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
BalanceNotifyEnabled: u.BalanceNotifyEnabled,
|
||||
BalanceNotifyThreshold: u.BalanceNotifyThreshold,
|
||||
BalanceNotifyExtraEmails: u.BalanceNotifyExtraEmails,
|
||||
}
|
||||
}
|
||||
|
||||
@ -322,6 +325,26 @@ func AccountFromServiceShallow(a *service.Account) *Account {
|
||||
out.QuotaWeeklyResetAt = &v
|
||||
}
|
||||
}
|
||||
|
||||
// 配额通知配置
|
||||
if enabled := a.GetQuotaNotifyDailyEnabled(); enabled {
|
||||
out.QuotaNotifyDailyEnabled = &enabled
|
||||
}
|
||||
if threshold := a.GetQuotaNotifyDailyThreshold(); threshold > 0 {
|
||||
out.QuotaNotifyDailyThreshold = &threshold
|
||||
}
|
||||
if enabled := a.GetQuotaNotifyWeeklyEnabled(); enabled {
|
||||
out.QuotaNotifyWeeklyEnabled = &enabled
|
||||
}
|
||||
if threshold := a.GetQuotaNotifyWeeklyThreshold(); threshold > 0 {
|
||||
out.QuotaNotifyWeeklyThreshold = &threshold
|
||||
}
|
||||
if enabled := a.GetQuotaNotifyTotalEnabled(); enabled {
|
||||
out.QuotaNotifyTotalEnabled = &enabled
|
||||
}
|
||||
if threshold := a.GetQuotaNotifyTotalThreshold(); threshold > 0 {
|
||||
out.QuotaNotifyTotalThreshold = &threshold
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
|
||||
@ -148,6 +148,11 @@ type SystemSettings struct {
|
||||
PaymentCancelRateLimitWindow int `json:"payment_cancel_rate_limit_window"`
|
||||
PaymentCancelRateLimitUnit string `json:"payment_cancel_rate_limit_unit"`
|
||||
PaymentCancelRateLimitMode string `json:"payment_cancel_rate_limit_window_mode"`
|
||||
|
||||
// Balance low notification
|
||||
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
|
||||
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
|
||||
AccountQuotaNotifyEmails []string `json:"account_quota_notify_emails"`
|
||||
}
|
||||
|
||||
type DefaultSubscriptionSetting struct {
|
||||
|
||||
@ -18,6 +18,11 @@ type User struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// 余额不足通知
|
||||
BalanceNotifyEnabled bool `json:"balance_notify_enabled"`
|
||||
BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"`
|
||||
BalanceNotifyExtraEmails []string `json:"balance_notify_extra_emails"`
|
||||
|
||||
APIKeys []APIKey `json:"api_keys,omitempty"`
|
||||
Subscriptions []UserSubscription `json:"subscriptions,omitempty"`
|
||||
}
|
||||
@ -218,6 +223,14 @@ type Account struct {
|
||||
QuotaDailyResetAt *string `json:"quota_daily_reset_at,omitempty"`
|
||||
QuotaWeeklyResetAt *string `json:"quota_weekly_reset_at,omitempty"`
|
||||
|
||||
// 配额通知配置
|
||||
QuotaNotifyDailyEnabled *bool `json:"quota_notify_daily_enabled,omitempty"`
|
||||
QuotaNotifyDailyThreshold *float64 `json:"quota_notify_daily_threshold,omitempty"`
|
||||
QuotaNotifyWeeklyEnabled *bool `json:"quota_notify_weekly_enabled,omitempty"`
|
||||
QuotaNotifyWeeklyThreshold *float64 `json:"quota_notify_weekly_threshold,omitempty"`
|
||||
QuotaNotifyTotalEnabled *bool `json:"quota_notify_total_enabled,omitempty"`
|
||||
QuotaNotifyTotalThreshold *float64 `json:"quota_notify_total_threshold,omitempty"`
|
||||
|
||||
Proxy *Proxy `json:"proxy,omitempty"`
|
||||
AccountGroups []AccountGroup `json:"account_groups,omitempty"`
|
||||
|
||||
|
||||
@ -11,13 +11,17 @@ import (
|
||||
|
||||
// UserHandler handles user-related requests
|
||||
type UserHandler struct {
|
||||
userService *service.UserService
|
||||
userService *service.UserService
|
||||
emailService *service.EmailService
|
||||
emailCache service.EmailCache
|
||||
}
|
||||
|
||||
// NewUserHandler creates a new UserHandler
|
||||
func NewUserHandler(userService *service.UserService) *UserHandler {
|
||||
func NewUserHandler(userService *service.UserService, emailService *service.EmailService, emailCache service.EmailCache) *UserHandler {
|
||||
return &UserHandler{
|
||||
userService: userService,
|
||||
userService: userService,
|
||||
emailService: emailService,
|
||||
emailCache: emailCache,
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,7 +33,9 @@ type ChangePasswordRequest struct {
|
||||
|
||||
// UpdateProfileRequest represents the update profile request payload
|
||||
type UpdateProfileRequest struct {
|
||||
Username *string `json:"username"`
|
||||
Username *string `json:"username"`
|
||||
BalanceNotifyEnabled *bool `json:"balance_notify_enabled"`
|
||||
BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"`
|
||||
}
|
||||
|
||||
// GetProfile handles getting user profile
|
||||
@ -94,7 +100,9 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
|
||||
}
|
||||
|
||||
svcReq := service.UpdateProfileRequest{
|
||||
Username: req.Username,
|
||||
Username: req.Username,
|
||||
BalanceNotifyEnabled: req.BalanceNotifyEnabled,
|
||||
BalanceNotifyThreshold: req.BalanceNotifyThreshold,
|
||||
}
|
||||
updatedUser, err := h.userService.UpdateProfile(c.Request.Context(), subject.UserID, svcReq)
|
||||
if err != nil {
|
||||
@ -104,3 +112,98 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
|
||||
|
||||
response.Success(c, dto.UserFromService(updatedUser))
|
||||
}
|
||||
|
||||
// SendNotifyEmailCodeRequest represents the request to send notify email verification code
|
||||
type SendNotifyEmailCodeRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
}
|
||||
|
||||
// SendNotifyEmailCode sends verification code to extra notification email
|
||||
// POST /api/v1/user/notify-email/send-code
|
||||
func (h *UserHandler) SendNotifyEmailCode(c *gin.Context) {
|
||||
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "User not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req SendNotifyEmailCodeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
err := h.userService.SendNotifyEmailCode(c.Request.Context(), subject.UserID, req.Email, h.emailService, h.emailCache)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"message": "Verification code sent successfully"})
|
||||
}
|
||||
|
||||
// VerifyNotifyEmailRequest represents the request to verify and add notify email
|
||||
type VerifyNotifyEmailRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Code string `json:"code" binding:"required,len=6"`
|
||||
}
|
||||
|
||||
// VerifyNotifyEmail verifies code and adds email to notification list
|
||||
// POST /api/v1/user/notify-email/verify
|
||||
func (h *UserHandler) VerifyNotifyEmail(c *gin.Context) {
|
||||
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "User not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req VerifyNotifyEmailRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
err := h.userService.VerifyAndAddNotifyEmail(c.Request.Context(), subject.UserID, req.Email, req.Code, h.emailCache)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Return updated user
|
||||
updatedUser, err := h.userService.GetByID(c.Request.Context(), subject.UserID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.UserFromService(updatedUser))
|
||||
}
|
||||
|
||||
// RemoveNotifyEmailRequest represents the request to remove a notify email
|
||||
type RemoveNotifyEmailRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
}
|
||||
|
||||
// RemoveNotifyEmail removes email from notification list
|
||||
// DELETE /api/v1/user/notify-email
|
||||
func (h *UserHandler) RemoveNotifyEmail(c *gin.Context) {
|
||||
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "User not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req RemoveNotifyEmailRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
err := h.userService.RemoveNotifyEmail(c.Request.Context(), subject.UserID, req.Email)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"message": "Email removed successfully"})
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@ -639,22 +640,32 @@ func userEntityToService(u *dbent.User) *service.User {
|
||||
if u == nil {
|
||||
return nil
|
||||
}
|
||||
return &service.User{
|
||||
ID: u.ID,
|
||||
Email: u.Email,
|
||||
Username: u.Username,
|
||||
Notes: u.Notes,
|
||||
PasswordHash: u.PasswordHash,
|
||||
Role: u.Role,
|
||||
Balance: u.Balance,
|
||||
Concurrency: u.Concurrency,
|
||||
Status: u.Status,
|
||||
TotpSecretEncrypted: u.TotpSecretEncrypted,
|
||||
TotpEnabled: u.TotpEnabled,
|
||||
TotpEnabledAt: u.TotpEnabledAt,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
out := &service.User{
|
||||
ID: u.ID,
|
||||
Email: u.Email,
|
||||
Username: u.Username,
|
||||
Notes: u.Notes,
|
||||
PasswordHash: u.PasswordHash,
|
||||
Role: u.Role,
|
||||
Balance: u.Balance,
|
||||
Concurrency: u.Concurrency,
|
||||
Status: u.Status,
|
||||
TotpSecretEncrypted: u.TotpSecretEncrypted,
|
||||
TotpEnabled: u.TotpEnabled,
|
||||
TotpEnabledAt: u.TotpEnabledAt,
|
||||
BalanceNotifyEnabled: u.BalanceNotifyEnabled,
|
||||
BalanceNotifyThreshold: u.BalanceNotifyThreshold,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
}
|
||||
// Parse extra emails JSON array
|
||||
if u.BalanceNotifyExtraEmails != "" && u.BalanceNotifyExtraEmails != "[]" {
|
||||
var emails []string
|
||||
if err := json.Unmarshal([]byte(u.BalanceNotifyExtraEmails), &emails); err == nil {
|
||||
out.BalanceNotifyExtraEmails = emails
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func groupEntityToService(g *dbent.Group) *service.Group {
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
|
||||
const (
|
||||
verifyCodeKeyPrefix = "verify_code:"
|
||||
notifyVerifyKeyPrefix = "notify_verify:"
|
||||
passwordResetKeyPrefix = "password_reset:"
|
||||
passwordResetSentAtKeyPrefix = "password_reset_sent:"
|
||||
)
|
||||
@ -20,6 +21,11 @@ func verifyCodeKey(email string) string {
|
||||
return verifyCodeKeyPrefix + email
|
||||
}
|
||||
|
||||
// notifyVerifyKey generates the Redis key for notify email verification code.
|
||||
func notifyVerifyKey(email string) string {
|
||||
return notifyVerifyKeyPrefix + email
|
||||
}
|
||||
|
||||
// passwordResetKey generates the Redis key for password reset token.
|
||||
func passwordResetKey(email string) string {
|
||||
return passwordResetKeyPrefix + email
|
||||
@ -106,3 +112,32 @@ func (c *emailCache) SetPasswordResetEmailCooldown(ctx context.Context, email st
|
||||
key := passwordResetSentAtKey(email)
|
||||
return c.rdb.Set(ctx, key, "1", ttl).Err()
|
||||
}
|
||||
|
||||
// Notify email verification code methods
|
||||
|
||||
func (c *emailCache) GetNotifyVerifyCode(ctx context.Context, email string) (*service.VerificationCodeData, error) {
|
||||
key := notifyVerifyKey(email)
|
||||
val, err := c.rdb.Get(ctx, key).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data service.VerificationCodeData
|
||||
if err := json.Unmarshal([]byte(val), &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
func (c *emailCache) SetNotifyVerifyCode(ctx context.Context, email string, data *service.VerificationCodeData, ttl time.Duration) error {
|
||||
key := notifyVerifyKey(email)
|
||||
val, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.rdb.Set(ctx, key, val, ttl).Err()
|
||||
}
|
||||
|
||||
func (c *emailCache) DeleteNotifyVerifyCode(ctx context.Context, email string) error {
|
||||
key := notifyVerifyKey(email)
|
||||
return c.rdb.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
@ -137,7 +138,7 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error
|
||||
txClient = r.client
|
||||
}
|
||||
|
||||
updated, err := txClient.User.UpdateOneID(userIn.ID).
|
||||
updateOp := txClient.User.UpdateOneID(userIn.ID).
|
||||
SetEmail(userIn.Email).
|
||||
SetUsername(userIn.Username).
|
||||
SetNotes(userIn.Notes).
|
||||
@ -146,7 +147,13 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error
|
||||
SetBalance(userIn.Balance).
|
||||
SetConcurrency(userIn.Concurrency).
|
||||
SetStatus(userIn.Status).
|
||||
Save(ctx)
|
||||
SetBalanceNotifyEnabled(userIn.BalanceNotifyEnabled).
|
||||
SetNillableBalanceNotifyThreshold(userIn.BalanceNotifyThreshold).
|
||||
SetBalanceNotifyExtraEmails(marshalExtraEmails(userIn.BalanceNotifyExtraEmails))
|
||||
if userIn.BalanceNotifyThreshold == nil {
|
||||
updateOp = updateOp.ClearBalanceNotifyThreshold()
|
||||
}
|
||||
updated, err := updateOp.Save(ctx)
|
||||
if err != nil {
|
||||
return translatePersistenceError(err, service.ErrUserNotFound, service.ErrEmailExists)
|
||||
}
|
||||
@ -549,6 +556,18 @@ func applyUserEntityToService(dst *service.User, src *dbent.User) {
|
||||
dst.UpdatedAt = src.UpdatedAt
|
||||
}
|
||||
|
||||
// marshalExtraEmails serializes a string slice to JSON for storage.
|
||||
func marshalExtraEmails(emails []string) string {
|
||||
if len(emails) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
data, err := json.Marshal(emails)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// UpdateTotpSecret 更新用户的 TOTP 加密密钥
|
||||
func (r *userRepository) UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error {
|
||||
client := clientFromContext(ctx, r.client)
|
||||
|
||||
@ -26,6 +26,14 @@ func RegisterUserRoutes(
|
||||
user.PUT("/password", h.User.ChangePassword)
|
||||
user.PUT("", h.User.UpdateProfile)
|
||||
|
||||
// 通知邮箱管理
|
||||
notifyEmail := user.Group("/notify-email")
|
||||
{
|
||||
notifyEmail.POST("/send-code", h.User.SendNotifyEmailCode)
|
||||
notifyEmail.POST("/verify", h.User.VerifyNotifyEmail)
|
||||
notifyEmail.DELETE("", h.User.RemoveNotifyEmail)
|
||||
}
|
||||
|
||||
// TOTP 双因素认证
|
||||
totp := user.Group("/totp")
|
||||
{
|
||||
|
||||
@ -1406,6 +1406,19 @@ func (a *Account) getExtraTime(key string) time.Time {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// getExtraBool 从 Extra 中读取指定 key 的 bool 值
|
||||
func (a *Account) getExtraBool(key string) bool {
|
||||
if a.Extra == nil {
|
||||
return false
|
||||
}
|
||||
if v, ok := a.Extra[key]; ok {
|
||||
if b, ok := v.(bool); ok {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getExtraString 从 Extra 中读取指定 key 的字符串值
|
||||
func (a *Account) getExtraString(key string) string {
|
||||
if a.Extra == nil {
|
||||
@ -1475,6 +1488,32 @@ func (a *Account) GetQuotaResetTimezone() string {
|
||||
return "UTC"
|
||||
}
|
||||
|
||||
// --- Quota Notification Getters ---
|
||||
|
||||
func (a *Account) GetQuotaNotifyDailyEnabled() bool {
|
||||
return a.getExtraBool("quota_notify_daily_enabled")
|
||||
}
|
||||
|
||||
func (a *Account) GetQuotaNotifyDailyThreshold() float64 {
|
||||
return a.getExtraFloat64("quota_notify_daily_threshold")
|
||||
}
|
||||
|
||||
func (a *Account) GetQuotaNotifyWeeklyEnabled() bool {
|
||||
return a.getExtraBool("quota_notify_weekly_enabled")
|
||||
}
|
||||
|
||||
func (a *Account) GetQuotaNotifyWeeklyThreshold() float64 {
|
||||
return a.getExtraFloat64("quota_notify_weekly_threshold")
|
||||
}
|
||||
|
||||
func (a *Account) GetQuotaNotifyTotalEnabled() bool {
|
||||
return a.getExtraBool("quota_notify_total_enabled")
|
||||
}
|
||||
|
||||
func (a *Account) GetQuotaNotifyTotalThreshold() float64 {
|
||||
return a.getExtraFloat64("quota_notify_total_threshold")
|
||||
}
|
||||
|
||||
// nextFixedDailyReset 计算在 after 之后的下一个每日固定重置时间点
|
||||
func nextFixedDailyReset(hour int, tz *time.Location, after time.Time) time.Time {
|
||||
t := after.In(tz)
|
||||
|
||||
@ -87,6 +87,18 @@ func (s *emailCacheStub) DeleteVerificationCode(ctx context.Context, email strin
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *emailCacheStub) GetNotifyVerifyCode(ctx context.Context, email string) (*VerificationCodeData, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *emailCacheStub) SetNotifyVerifyCode(ctx context.Context, email string, data *VerificationCodeData, ttl time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *emailCacheStub) DeleteNotifyVerifyCode(ctx context.Context, email string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *emailCacheStub) GetPasswordResetToken(ctx context.Context, email string) (*PasswordResetTokenData, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
328
backend/internal/service/balance_notify_service.go
Normal file
328
backend/internal/service/balance_notify_service.go
Normal file
@ -0,0 +1,328 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
emailSendTimeout = 30 * time.Second
|
||||
|
||||
// Quota dimension labels
|
||||
quotaDimDaily = "daily"
|
||||
quotaDimWeekly = "weekly"
|
||||
quotaDimTotal = "total"
|
||||
)
|
||||
|
||||
// quotaDimLabels maps dimension names to display labels.
|
||||
var quotaDimLabels = map[string]string{
|
||||
quotaDimDaily: "日限额 / Daily",
|
||||
quotaDimWeekly: "周限额 / Weekly",
|
||||
quotaDimTotal: "总限额 / Total",
|
||||
}
|
||||
|
||||
// BalanceNotifyService handles balance and quota threshold notifications.
|
||||
type BalanceNotifyService struct {
|
||||
emailService *EmailService
|
||||
settingRepo SettingRepository
|
||||
}
|
||||
|
||||
// NewBalanceNotifyService creates a new BalanceNotifyService.
|
||||
func NewBalanceNotifyService(emailService *EmailService, settingRepo SettingRepository) *BalanceNotifyService {
|
||||
return &BalanceNotifyService{
|
||||
emailService: emailService,
|
||||
settingRepo: settingRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// CheckBalanceAfterDeduction checks if balance crossed below threshold after deduction.
|
||||
// oldBalance is the balance before deduction, cost is the amount deducted.
|
||||
// Notification is sent only on first crossing: oldBalance >= threshold && newBalance < threshold.
|
||||
func (s *BalanceNotifyService) CheckBalanceAfterDeduction(ctx context.Context, user *User, oldBalance, cost float64) {
|
||||
if user == nil || s.emailService == nil || s.settingRepo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Check user-level switch
|
||||
if !user.BalanceNotifyEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
// Check global switch
|
||||
globalEnabled, threshold := s.getBalanceNotifyConfig(ctx)
|
||||
if !globalEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
// User custom threshold overrides system default
|
||||
if user.BalanceNotifyThreshold != nil {
|
||||
threshold = *user.BalanceNotifyThreshold
|
||||
}
|
||||
|
||||
if threshold <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
newBalance := oldBalance - cost
|
||||
|
||||
// Only notify on first crossing
|
||||
if oldBalance >= threshold && newBalance < threshold {
|
||||
siteName := s.getSiteName(ctx)
|
||||
recipients := s.collectBalanceNotifyRecipients(user)
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("panic in balance notification", "recover", r)
|
||||
}
|
||||
}()
|
||||
s.sendBalanceLowEmails(recipients, user.Username, user.Email, newBalance, threshold, siteName)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// CheckAccountQuotaAfterIncrement checks if any quota dimension crossed above its notify threshold.
|
||||
// The account's Extra fields contain pre-increment usage values.
|
||||
func (s *BalanceNotifyService) CheckAccountQuotaAfterIncrement(ctx context.Context, account *Account, cost float64) {
|
||||
if account == nil || s.emailService == nil || s.settingRepo == nil || cost <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
adminEmails := s.getAccountQuotaNotifyEmails(ctx)
|
||||
if len(adminEmails) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
siteName := s.getSiteName(ctx)
|
||||
|
||||
// Check each dimension
|
||||
type quotaDim struct {
|
||||
name string
|
||||
enabled bool
|
||||
threshold float64
|
||||
oldUsed float64
|
||||
limit float64
|
||||
}
|
||||
|
||||
dims := []quotaDim{
|
||||
{
|
||||
name: quotaDimDaily,
|
||||
enabled: account.GetQuotaNotifyDailyEnabled(),
|
||||
threshold: account.GetQuotaNotifyDailyThreshold(),
|
||||
oldUsed: account.GetQuotaDailyUsed(),
|
||||
limit: account.GetQuotaDailyLimit(),
|
||||
},
|
||||
{
|
||||
name: quotaDimWeekly,
|
||||
enabled: account.GetQuotaNotifyWeeklyEnabled(),
|
||||
threshold: account.GetQuotaNotifyWeeklyThreshold(),
|
||||
oldUsed: account.GetQuotaWeeklyUsed(),
|
||||
limit: account.GetQuotaWeeklyLimit(),
|
||||
},
|
||||
{
|
||||
name: quotaDimTotal,
|
||||
enabled: account.GetQuotaNotifyTotalEnabled(),
|
||||
threshold: account.GetQuotaNotifyTotalThreshold(),
|
||||
oldUsed: account.GetQuotaUsed(),
|
||||
limit: account.GetQuotaLimit(),
|
||||
},
|
||||
}
|
||||
|
||||
for _, dim := range dims {
|
||||
if !dim.enabled || dim.threshold <= 0 {
|
||||
continue
|
||||
}
|
||||
newUsed := dim.oldUsed + cost
|
||||
// Only notify on first crossing
|
||||
if dim.oldUsed < dim.threshold && newUsed >= dim.threshold {
|
||||
dimCopy := dim // capture loop variable
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("panic in quota notification", "recover", r)
|
||||
}
|
||||
}()
|
||||
s.sendQuotaAlertEmails(adminEmails, account.Name, dimCopy.name, newUsed, dimCopy.limit, dimCopy.threshold, siteName)
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getBalanceNotifyConfig reads global balance notification settings.
|
||||
func (s *BalanceNotifyService) getBalanceNotifyConfig(ctx context.Context) (enabled bool, threshold float64) {
|
||||
keys := []string{SettingKeyBalanceLowNotifyEnabled, SettingKeyBalanceLowNotifyThreshold}
|
||||
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
||||
if err != nil {
|
||||
return false, 0
|
||||
}
|
||||
enabled = settings[SettingKeyBalanceLowNotifyEnabled] == "true"
|
||||
if v := settings[SettingKeyBalanceLowNotifyThreshold]; v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
threshold = f
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// getAccountQuotaNotifyEmails reads admin notification emails from settings.
|
||||
func (s *BalanceNotifyService) getAccountQuotaNotifyEmails(ctx context.Context) []string {
|
||||
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAccountQuotaNotifyEmails)
|
||||
if err != nil || strings.TrimSpace(raw) == "" || raw == "[]" {
|
||||
return nil
|
||||
}
|
||||
return parseJSONStringArray(raw)
|
||||
}
|
||||
|
||||
// getSiteName reads site name from settings with fallback.
|
||||
func (s *BalanceNotifyService) getSiteName(ctx context.Context) string {
|
||||
name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName)
|
||||
if err != nil || name == "" {
|
||||
return "Sub2API"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// collectBalanceNotifyRecipients collects all email recipients for balance notifications.
|
||||
func (s *BalanceNotifyService) collectBalanceNotifyRecipients(user *User) []string {
|
||||
recipients := []string{user.Email}
|
||||
for _, extra := range user.BalanceNotifyExtraEmails {
|
||||
email := strings.TrimSpace(extra)
|
||||
if email != "" && email != user.Email {
|
||||
recipients = append(recipients, email)
|
||||
}
|
||||
}
|
||||
return recipients
|
||||
}
|
||||
|
||||
// sendEmails sends an email to all recipients with shared timeout and error logging.
|
||||
func (s *BalanceNotifyService) sendEmails(recipients []string, subject, body string, logAttrs ...any) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), emailSendTimeout)
|
||||
defer cancel()
|
||||
for _, to := range recipients {
|
||||
if err := s.emailService.SendEmail(ctx, to, subject, body); err != nil {
|
||||
attrs := append([]any{"to", to, "error", err}, logAttrs...)
|
||||
slog.Error("failed to send notification", attrs...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendBalanceLowEmails sends balance low notification to all recipients.
|
||||
func (s *BalanceNotifyService) sendBalanceLowEmails(recipients []string, userName, userEmail string, balance, threshold float64, siteName string) {
|
||||
displayName := userName
|
||||
if displayName == "" {
|
||||
displayName = userEmail
|
||||
}
|
||||
subject := fmt.Sprintf("[%s] 余额不足提醒 / Balance Low Alert", siteName)
|
||||
body := s.buildBalanceLowEmailBody(displayName, balance, threshold, siteName)
|
||||
s.sendEmails(recipients, subject, body, "user_email", userEmail, "balance", balance)
|
||||
}
|
||||
|
||||
// sendQuotaAlertEmails sends quota alert notification to admin emails.
|
||||
func (s *BalanceNotifyService) sendQuotaAlertEmails(adminEmails []string, accountName, dimension string, used, limit, threshold float64, siteName string) {
|
||||
dimLabel := quotaDimLabels[dimension]
|
||||
if dimLabel == "" {
|
||||
dimLabel = dimension
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("[%s] 账号限额告警 / Account Quota Alert - %s", siteName, accountName)
|
||||
body := s.buildQuotaAlertEmailBody(accountName, dimLabel, used, limit, threshold, siteName)
|
||||
s.sendEmails(adminEmails, subject, body, "account", accountName, "dimension", dimension)
|
||||
}
|
||||
|
||||
// buildBalanceLowEmailBody builds HTML email for balance low notification.
|
||||
func (s *BalanceNotifyService) buildBalanceLowEmailBody(userName string, balance, threshold float64, siteName string) string {
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: #f5f5f5; margin: 0; padding: 20px; }
|
||||
.container { max-width: 600px; margin: 0 auto; background-color: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
.header { background: linear-gradient(135deg, #f59e0b 0%%, #d97706 100%%); color: white; padding: 30px; text-align: center; }
|
||||
.header h1 { margin: 0; font-size: 24px; }
|
||||
.content { padding: 40px 30px; text-align: center; }
|
||||
.balance { font-size: 36px; font-weight: bold; color: #dc2626; margin: 20px 0; }
|
||||
.info { color: #666; font-size: 14px; line-height: 1.6; margin-top: 20px; }
|
||||
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; color: #999; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header"><h1>%s</h1></div>
|
||||
<div class="content">
|
||||
<p style="font-size: 18px; color: #333;">%s,您的余额不足</p>
|
||||
<p style="color: #666;">Dear %s, your balance is running low</p>
|
||||
<div class="balance">$%.2f</div>
|
||||
<div class="info">
|
||||
<p>您的账户余额已低于提醒阈值 <strong>$%.2f</strong>。</p>
|
||||
<p>Your account balance has fallen below the alert threshold of <strong>$%.2f</strong>.</p>
|
||||
<p>请及时充值以免服务中断。</p>
|
||||
<p>Please top up to avoid service interruption.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><p>此邮件由系统自动发送,请勿回复。</p></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`, siteName, userName, userName, balance, threshold, threshold)
|
||||
}
|
||||
|
||||
// buildQuotaAlertEmailBody builds HTML email for account quota alert.
|
||||
func (s *BalanceNotifyService) buildQuotaAlertEmailBody(accountName, dimLabel string, used, limit, threshold float64, siteName string) string {
|
||||
limitStr := fmt.Sprintf("$%.2f", limit)
|
||||
if limit <= 0 {
|
||||
limitStr = "无限制 / Unlimited"
|
||||
}
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: #f5f5f5; margin: 0; padding: 20px; }
|
||||
.container { max-width: 600px; margin: 0 auto; background-color: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
.header { background: linear-gradient(135deg, #ef4444 0%%, #dc2626 100%%); color: white; padding: 30px; text-align: center; }
|
||||
.header h1 { margin: 0; font-size: 24px; }
|
||||
.content { padding: 40px 30px; }
|
||||
.metric { display: flex; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid #eee; }
|
||||
.metric-label { color: #666; }
|
||||
.metric-value { font-weight: bold; color: #333; }
|
||||
.info { color: #666; font-size: 14px; line-height: 1.6; margin-top: 20px; text-align: center; }
|
||||
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; color: #999; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header"><h1>%s</h1></div>
|
||||
<div class="content">
|
||||
<p style="font-size: 18px; color: #333; text-align: center;">账号限额告警 / Account Quota Alert</p>
|
||||
<div class="metric"><span class="metric-label">账号 / Account</span><span class="metric-value">%s</span></div>
|
||||
<div class="metric"><span class="metric-label">维度 / Dimension</span><span class="metric-value">%s</span></div>
|
||||
<div class="metric"><span class="metric-label">已使用 / Used</span><span class="metric-value">$%.2f</span></div>
|
||||
<div class="metric"><span class="metric-label">限额 / Limit</span><span class="metric-value">%s</span></div>
|
||||
<div class="metric"><span class="metric-label">告警阈值 / Threshold</span><span class="metric-value">$%.2f</span></div>
|
||||
<div class="info">
|
||||
<p>账号配额用量已达到告警阈值,请及时关注。</p>
|
||||
<p>Account quota usage has reached the alert threshold.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><p>此邮件由系统自动发送,请勿回复。</p></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`, siteName, accountName, dimLabel, used, limitStr, threshold)
|
||||
}
|
||||
|
||||
// parseJSONStringArray parses a JSON string array, returns nil on error.
|
||||
func parseJSONStringArray(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "[]" {
|
||||
return nil
|
||||
}
|
||||
var result []string
|
||||
if err := json.Unmarshal([]byte(raw), &result); err != nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
@ -250,9 +250,12 @@ const (
|
||||
// SettingKeyEnableCCHSigning 是否对 billing header 中的 cch 进行 xxHash64 签名(默认 false)
|
||||
SettingKeyEnableCCHSigning = "enable_cch_signing"
|
||||
|
||||
// Web Search Emulation
|
||||
// SettingKeyWebSearchEmulationConfig 全局 web search 模拟配置(JSON)
|
||||
SettingKeyWebSearchEmulationConfig = "web_search_emulation_config"
|
||||
// Balance Low Notification
|
||||
SettingKeyBalanceLowNotifyEnabled = "balance_low_notify_enabled" // 全局开关
|
||||
SettingKeyBalanceLowNotifyThreshold = "balance_low_notify_threshold" // 默认阈值(USD)
|
||||
|
||||
// Account Quota Notification
|
||||
SettingKeyAccountQuotaNotifyEmails = "account_quota_notify_emails" // 管理员通知邮箱列表(JSON 数组)
|
||||
)
|
||||
|
||||
// AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys).
|
||||
|
||||
@ -34,6 +34,11 @@ type EmailCache interface {
|
||||
SetVerificationCode(ctx context.Context, email string, data *VerificationCodeData, ttl time.Duration) error
|
||||
DeleteVerificationCode(ctx context.Context, email string) error
|
||||
|
||||
// Notify email verification code methods
|
||||
GetNotifyVerifyCode(ctx context.Context, email string) (*VerificationCodeData, error)
|
||||
SetNotifyVerifyCode(ctx context.Context, email string, data *VerificationCodeData, ttl time.Duration) error
|
||||
DeleteNotifyVerifyCode(ctx context.Context, email string) error
|
||||
|
||||
// Password reset token methods
|
||||
GetPasswordResetToken(ctx context.Context, email string) (*PasswordResetTokenData, error)
|
||||
SetPasswordResetToken(ctx context.Context, email string, data *PasswordResetTokenData, ttl time.Duration) error
|
||||
|
||||
@ -43,6 +43,7 @@ func newGatewayRecordUsageServiceForTest(usageRepo UsageLogRepository, userRepo
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -569,6 +569,7 @@ type GatewayService struct {
|
||||
resolver *ModelPricingResolver
|
||||
debugGatewayBodyFile atomic.Pointer[os.File] // non-nil when SUB2API_DEBUG_GATEWAY_BODY is set
|
||||
tlsFPProfileService *TLSFingerprintProfileService
|
||||
balanceNotifyService *BalanceNotifyService
|
||||
}
|
||||
|
||||
// NewGatewayService creates a new GatewayService
|
||||
@ -598,6 +599,7 @@ func NewGatewayService(
|
||||
tlsFPProfileService *TLSFingerprintProfileService,
|
||||
channelService *ChannelService,
|
||||
resolver *ModelPricingResolver,
|
||||
balanceNotifyService *BalanceNotifyService,
|
||||
) *GatewayService {
|
||||
userGroupRateTTL := resolveUserGroupRateCacheTTL(cfg)
|
||||
modelsListTTL := resolveModelsListCacheTTL(cfg)
|
||||
@ -632,6 +634,7 @@ func NewGatewayService(
|
||||
tlsFPProfileService: tlsFPProfileService,
|
||||
channelService: channelService,
|
||||
resolver: resolver,
|
||||
balanceNotifyService: balanceNotifyService,
|
||||
}
|
||||
svc.userGroupRateResolver = newUserGroupRateResolver(
|
||||
userGroupRateRepo,
|
||||
@ -7334,6 +7337,20 @@ func finalizePostUsageBilling(p *postUsageBillingParams, deps *billingDeps) {
|
||||
}
|
||||
|
||||
deps.deferredService.ScheduleLastUsedUpdate(p.Account.ID)
|
||||
|
||||
// Balance low notification
|
||||
if !p.IsSubscriptionBill && p.Cost.ActualCost > 0 && p.User != nil && deps.balanceNotifyService != nil {
|
||||
deps.balanceNotifyService.CheckBalanceAfterDeduction(context.Background(), p.User, p.User.Balance, p.Cost.ActualCost)
|
||||
}
|
||||
|
||||
// Account quota notification
|
||||
if p.Cost.TotalCost > 0 && p.Account != nil && p.Account.IsAPIKeyOrBedrock() && deps.balanceNotifyService != nil {
|
||||
accountCost := p.Cost.TotalCost
|
||||
if p.AccountRateMultiplier > 0 {
|
||||
accountCost *= p.AccountRateMultiplier
|
||||
}
|
||||
deps.balanceNotifyService.CheckAccountQuotaAfterIncrement(context.Background(), p.Account, accountCost)
|
||||
}
|
||||
}
|
||||
|
||||
func detachedBillingContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
@ -7356,20 +7373,22 @@ func detachStreamUpstreamContext(ctx context.Context, stream bool) (context.Cont
|
||||
|
||||
// billingDeps 扣费逻辑依赖的服务(由各 gateway service 提供)
|
||||
type billingDeps struct {
|
||||
accountRepo AccountRepository
|
||||
userRepo UserRepository
|
||||
userSubRepo UserSubscriptionRepository
|
||||
billingCacheService *BillingCacheService
|
||||
deferredService *DeferredService
|
||||
accountRepo AccountRepository
|
||||
userRepo UserRepository
|
||||
userSubRepo UserSubscriptionRepository
|
||||
billingCacheService *BillingCacheService
|
||||
deferredService *DeferredService
|
||||
balanceNotifyService *BalanceNotifyService
|
||||
}
|
||||
|
||||
func (s *GatewayService) billingDeps() *billingDeps {
|
||||
return &billingDeps{
|
||||
accountRepo: s.accountRepo,
|
||||
userRepo: s.userRepo,
|
||||
userSubRepo: s.userSubRepo,
|
||||
billingCacheService: s.billingCacheService,
|
||||
deferredService: s.deferredService,
|
||||
accountRepo: s.accountRepo,
|
||||
userRepo: s.userRepo,
|
||||
userSubRepo: s.userSubRepo,
|
||||
billingCacheService: s.billingCacheService,
|
||||
deferredService: s.deferredService,
|
||||
balanceNotifyService: s.balanceNotifyService,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -147,6 +147,7 @@ func newOpenAIRecordUsageServiceForTest(usageRepo UsageLogRepository, userRepo U
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
svc.userGroupRateResolver = newUserGroupRateResolver(
|
||||
rateRepo,
|
||||
|
||||
@ -327,6 +327,7 @@ type OpenAIGatewayService struct {
|
||||
openaiWSResolver OpenAIWSProtocolResolver
|
||||
resolver *ModelPricingResolver
|
||||
channelService *ChannelService
|
||||
balanceNotifyService *BalanceNotifyService
|
||||
|
||||
openaiWSPoolOnce sync.Once
|
||||
openaiWSStateStoreOnce sync.Once
|
||||
@ -364,6 +365,7 @@ func NewOpenAIGatewayService(
|
||||
openAITokenProvider *OpenAITokenProvider,
|
||||
resolver *ModelPricingResolver,
|
||||
channelService *ChannelService,
|
||||
balanceNotifyService *BalanceNotifyService,
|
||||
) *OpenAIGatewayService {
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: accountRepo,
|
||||
@ -393,6 +395,7 @@ func NewOpenAIGatewayService(
|
||||
openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
|
||||
resolver: resolver,
|
||||
channelService: channelService,
|
||||
balanceNotifyService: balanceNotifyService,
|
||||
responseHeaderFilter: compileResponseHeaderFilter(cfg),
|
||||
codexSnapshotThrottle: newAccountWriteThrottle(openAICodexSnapshotPersistMinInterval),
|
||||
}
|
||||
@ -477,11 +480,12 @@ func (s *OpenAIGatewayService) getCodexSnapshotThrottle() *accountWriteThrottle
|
||||
|
||||
func (s *OpenAIGatewayService) billingDeps() *billingDeps {
|
||||
return &billingDeps{
|
||||
accountRepo: s.accountRepo,
|
||||
userRepo: s.userRepo,
|
||||
userSubRepo: s.userSubRepo,
|
||||
billingCacheService: s.billingCacheService,
|
||||
deferredService: s.deferredService,
|
||||
accountRepo: s.accountRepo,
|
||||
userRepo: s.userRepo,
|
||||
userSubRepo: s.userSubRepo,
|
||||
billingCacheService: s.billingCacheService,
|
||||
deferredService: s.deferredService,
|
||||
balanceNotifyService: s.balanceNotifyService,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -617,6 +617,7 @@ func TestNewOpenAIGatewayService_InitializesOpenAIWSResolver(t *testing.T) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
decision := svc.getOpenAIWSProtocolResolver().Resolve(nil)
|
||||
|
||||
@ -18,7 +18,6 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/imroc/req/v3"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
@ -107,7 +106,6 @@ type SettingService struct {
|
||||
cfg *config.Config
|
||||
onUpdate func() // Callback when settings are updated (for cache invalidation)
|
||||
version string // Application version
|
||||
webSearchRedis *redis.Client // optional: Redis client for web search quota tracking
|
||||
}
|
||||
|
||||
// NewSettingService 创建系统设置服务实例
|
||||
@ -170,9 +168,9 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
SettingKeyCustomEndpoints,
|
||||
SettingKeyLinuxDoConnectEnabled,
|
||||
SettingKeyBackendModeEnabled,
|
||||
SettingPaymentEnabled,
|
||||
SettingKeyOIDCConnectEnabled,
|
||||
SettingKeyOIDCConnectProviderName,
|
||||
SettingPaymentEnabled,
|
||||
}
|
||||
|
||||
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
||||
@ -237,9 +235,9 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
CustomEndpoints: settings[SettingKeyCustomEndpoints],
|
||||
LinuxDoOAuthEnabled: linuxDoEnabled,
|
||||
BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
|
||||
PaymentEnabled: settings[SettingPaymentEnabled] == "true",
|
||||
OIDCOAuthEnabled: oidcEnabled,
|
||||
OIDCOAuthProviderName: oidcProviderName,
|
||||
PaymentEnabled: settings[SettingPaymentEnabled] == "true",
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -289,9 +287,9 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
||||
CustomEndpoints json.RawMessage `json:"custom_endpoints"`
|
||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||||
PaymentEnabled bool `json:"payment_enabled"`
|
||||
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
|
||||
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
|
||||
PaymentEnabled bool `json:"payment_enabled"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}{
|
||||
RegistrationEnabled: settings.RegistrationEnabled,
|
||||
@ -319,9 +317,9 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
||||
CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
|
||||
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
||||
BackendModeEnabled: settings.BackendModeEnabled,
|
||||
PaymentEnabled: settings.PaymentEnabled,
|
||||
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
|
||||
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
|
||||
PaymentEnabled: settings.PaymentEnabled,
|
||||
Version: s.version,
|
||||
}, nil
|
||||
}
|
||||
@ -597,6 +595,15 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
|
||||
updates[SettingKeyEnableMetadataPassthrough] = strconv.FormatBool(settings.EnableMetadataPassthrough)
|
||||
updates[SettingKeyEnableCCHSigning] = strconv.FormatBool(settings.EnableCCHSigning)
|
||||
|
||||
// Balance low notification
|
||||
updates[SettingKeyBalanceLowNotifyEnabled] = strconv.FormatBool(settings.BalanceLowNotifyEnabled)
|
||||
updates[SettingKeyBalanceLowNotifyThreshold] = strconv.FormatFloat(settings.BalanceLowNotifyThreshold, 'f', 8, 64)
|
||||
accountQuotaNotifyEmailsJSON, err := json.Marshal(settings.AccountQuotaNotifyEmails)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal account quota notify emails: %w", err)
|
||||
}
|
||||
updates[SettingKeyAccountQuotaNotifyEmails] = string(accountQuotaNotifyEmailsJSON)
|
||||
|
||||
err = s.settingRepo.SetMultiple(ctx, updates)
|
||||
if err == nil {
|
||||
// 先使 inflight singleflight 失效,再刷新缓存,缩小旧值覆盖新值的竞态窗口
|
||||
@ -1219,13 +1226,22 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
||||
result.EnableMetadataPassthrough = settings[SettingKeyEnableMetadataPassthrough] == "true"
|
||||
result.EnableCCHSigning = settings[SettingKeyEnableCCHSigning] == "true"
|
||||
|
||||
// Web search emulation: quick enabled check from the JSON config
|
||||
if raw := settings[SettingKeyWebSearchEmulationConfig]; raw != "" {
|
||||
var wsCfg WebSearchEmulationConfig
|
||||
if err := json.Unmarshal([]byte(raw), &wsCfg); err == nil {
|
||||
result.WebSearchEmulationEnabled = wsCfg.Enabled && len(wsCfg.Providers) > 0
|
||||
// Balance low notification
|
||||
result.BalanceLowNotifyEnabled = settings[SettingKeyBalanceLowNotifyEnabled] == "true"
|
||||
if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 {
|
||||
result.BalanceLowNotifyThreshold = v
|
||||
}
|
||||
|
||||
// Account quota notification emails
|
||||
if raw := strings.TrimSpace(settings[SettingKeyAccountQuotaNotifyEmails]); raw != "" {
|
||||
var emails []string
|
||||
if err := json.Unmarshal([]byte(raw), &emails); err == nil {
|
||||
result.AccountQuotaNotifyEmails = emails
|
||||
}
|
||||
}
|
||||
if result.AccountQuotaNotifyEmails == nil {
|
||||
result.AccountQuotaNotifyEmails = []string{}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@ -107,8 +107,12 @@ type SystemSettings struct {
|
||||
EnableMetadataPassthrough bool // 是否透传客户端原始 metadata(默认 false)
|
||||
EnableCCHSigning bool // 是否对 billing header cch 进行签名(默认 false)
|
||||
|
||||
// Web Search Emulation (read-only quick check; full config via dedicated API)
|
||||
WebSearchEmulationEnabled bool
|
||||
// Balance low notification
|
||||
BalanceLowNotifyEnabled bool
|
||||
BalanceLowNotifyThreshold float64
|
||||
|
||||
// Account quota notification
|
||||
AccountQuotaNotifyEmails []string
|
||||
}
|
||||
|
||||
type DefaultSubscriptionSetting struct {
|
||||
@ -144,9 +148,9 @@ type PublicSettings struct {
|
||||
|
||||
LinuxDoOAuthEnabled bool
|
||||
BackendModeEnabled bool
|
||||
PaymentEnabled bool
|
||||
OIDCOAuthEnabled bool
|
||||
OIDCOAuthProviderName string
|
||||
PaymentEnabled bool
|
||||
Version string
|
||||
}
|
||||
|
||||
|
||||
@ -30,6 +30,11 @@ type User struct {
|
||||
TotpEnabled bool // 是否启用 TOTP
|
||||
TotpEnabledAt *time.Time // TOTP 启用时间
|
||||
|
||||
// 余额不足通知
|
||||
BalanceNotifyEnabled bool
|
||||
BalanceNotifyThreshold *float64
|
||||
BalanceNotifyExtraEmails []string
|
||||
|
||||
APIKeys []APIKey
|
||||
Subscriptions []UserSubscription
|
||||
}
|
||||
|
||||
@ -2,8 +2,10 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
@ -16,6 +18,8 @@ var (
|
||||
ErrInsufficientPerms = infraerrors.Forbidden("INSUFFICIENT_PERMISSIONS", "insufficient permissions")
|
||||
)
|
||||
|
||||
const maxNotifyExtraEmails = 5
|
||||
|
||||
// UserListFilters contains all filter options for listing users
|
||||
type UserListFilters struct {
|
||||
Status string // User status filter
|
||||
@ -58,9 +62,11 @@ type UserRepository interface {
|
||||
|
||||
// UpdateProfileRequest 更新用户资料请求
|
||||
type UpdateProfileRequest struct {
|
||||
Email *string `json:"email"`
|
||||
Username *string `json:"username"`
|
||||
Concurrency *int `json:"concurrency"`
|
||||
Email *string `json:"email"`
|
||||
Username *string `json:"username"`
|
||||
Concurrency *int `json:"concurrency"`
|
||||
BalanceNotifyEnabled *bool `json:"balance_notify_enabled"`
|
||||
BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"`
|
||||
}
|
||||
|
||||
// ChangePasswordRequest 修改密码请求
|
||||
@ -72,14 +78,16 @@ type ChangePasswordRequest struct {
|
||||
// UserService 用户服务
|
||||
type UserService struct {
|
||||
userRepo UserRepository
|
||||
settingRepo SettingRepository
|
||||
authCacheInvalidator APIKeyAuthCacheInvalidator
|
||||
billingCache BillingCache
|
||||
}
|
||||
|
||||
// NewUserService 创建用户服务实例
|
||||
func NewUserService(userRepo UserRepository, authCacheInvalidator APIKeyAuthCacheInvalidator, billingCache BillingCache) *UserService {
|
||||
func NewUserService(userRepo UserRepository, settingRepo SettingRepository, authCacheInvalidator APIKeyAuthCacheInvalidator, billingCache BillingCache) *UserService {
|
||||
return &UserService{
|
||||
userRepo: userRepo,
|
||||
settingRepo: settingRepo,
|
||||
authCacheInvalidator: authCacheInvalidator,
|
||||
billingCache: billingCache,
|
||||
}
|
||||
@ -132,6 +140,17 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
|
||||
user.Concurrency = *req.Concurrency
|
||||
}
|
||||
|
||||
if req.BalanceNotifyEnabled != nil {
|
||||
user.BalanceNotifyEnabled = *req.BalanceNotifyEnabled
|
||||
}
|
||||
if req.BalanceNotifyThreshold != nil {
|
||||
if *req.BalanceNotifyThreshold <= 0 {
|
||||
user.BalanceNotifyThreshold = nil // clear to system default
|
||||
} else {
|
||||
user.BalanceNotifyThreshold = req.BalanceNotifyThreshold
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||
return nil, fmt.Errorf("update user: %w", err)
|
||||
}
|
||||
@ -248,3 +267,148 @@ func (s *UserService) Delete(ctx context.Context, userID int64) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendNotifyEmailCode sends a verification code to the extra notification email.
|
||||
func (s *UserService) SendNotifyEmailCode(ctx context.Context, userID int64, email string, emailService *EmailService, cache EmailCache) error {
|
||||
// Check cooldown
|
||||
existing, err := cache.GetNotifyVerifyCode(ctx, email)
|
||||
if err == nil && existing != nil {
|
||||
if time.Since(existing.CreatedAt) < verifyCodeCooldown {
|
||||
return ErrVerifyCodeTooFrequent
|
||||
}
|
||||
}
|
||||
|
||||
// Generate code
|
||||
code, err := emailService.GenerateVerifyCode()
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate code: %w", err)
|
||||
}
|
||||
|
||||
// Save to cache
|
||||
data := &VerificationCodeData{
|
||||
Code: code,
|
||||
Attempts: 0,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := cache.SetNotifyVerifyCode(ctx, email, data, verifyCodeTTL); err != nil {
|
||||
return fmt.Errorf("save verify code: %w", err)
|
||||
}
|
||||
|
||||
// Get site name
|
||||
siteName := "Sub2API"
|
||||
if s.settingRepo != nil {
|
||||
if name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName); err == nil && name != "" {
|
||||
siteName = name
|
||||
}
|
||||
}
|
||||
|
||||
// Build and send email
|
||||
subject := fmt.Sprintf("[%s] 通知邮箱验证码 / Notification Email Verification", siteName)
|
||||
body := buildNotifyVerifyEmailBody(code, siteName)
|
||||
return emailService.SendEmail(ctx, email, subject, body)
|
||||
}
|
||||
|
||||
// VerifyAndAddNotifyEmail verifies the code and adds the email to user's extra emails.
|
||||
func (s *UserService) VerifyAndAddNotifyEmail(ctx context.Context, userID int64, email, code string, cache EmailCache) error {
|
||||
// Verify code
|
||||
data, err := cache.GetNotifyVerifyCode(ctx, email)
|
||||
if err != nil || data == nil {
|
||||
return ErrInvalidVerifyCode
|
||||
}
|
||||
if data.Attempts >= maxVerifyCodeAttempts {
|
||||
return ErrVerifyCodeMaxAttempts
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(data.Code), []byte(code)) != 1 {
|
||||
data.Attempts++
|
||||
_ = cache.SetNotifyVerifyCode(ctx, email, data, verifyCodeTTL)
|
||||
if data.Attempts >= maxVerifyCodeAttempts {
|
||||
return ErrVerifyCodeMaxAttempts
|
||||
}
|
||||
return ErrInvalidVerifyCode
|
||||
}
|
||||
|
||||
// Delete code after verification
|
||||
_ = cache.DeleteNotifyVerifyCode(ctx, email)
|
||||
|
||||
// Add to user's extra emails
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if already exists
|
||||
for _, e := range user.BalanceNotifyExtraEmails {
|
||||
if strings.EqualFold(e, email) {
|
||||
return nil // Already added
|
||||
}
|
||||
}
|
||||
|
||||
// Check limit
|
||||
if len(user.BalanceNotifyExtraEmails) >= maxNotifyExtraEmails {
|
||||
return infraerrors.BadRequest("TOO_MANY_NOTIFY_EMAILS", fmt.Sprintf("maximum %d extra notification emails allowed", maxNotifyExtraEmails))
|
||||
}
|
||||
|
||||
user.BalanceNotifyExtraEmails = append(user.BalanceNotifyExtraEmails, email)
|
||||
return s.userRepo.Update(ctx, user)
|
||||
}
|
||||
|
||||
// RemoveNotifyEmail removes an email from user's extra notification emails.
|
||||
func (s *UserService) RemoveNotifyEmail(ctx context.Context, userID int64, email string) error {
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filtered := make([]string, 0, len(user.BalanceNotifyExtraEmails))
|
||||
for _, e := range user.BalanceNotifyExtraEmails {
|
||||
if !strings.EqualFold(e, email) {
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
user.BalanceNotifyExtraEmails = filtered
|
||||
return s.userRepo.Update(ctx, user)
|
||||
}
|
||||
|
||||
// buildNotifyVerifyEmailBody builds the HTML email body for notify email verification.
|
||||
func buildNotifyVerifyEmailBody(code, siteName string) string {
|
||||
return fmt.Sprintf(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; background-color: #f5f5f5; margin: 0; padding: 20px; }
|
||||
.container { max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
.header { background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%); color: white; padding: 30px; text-align: center; }
|
||||
.header h1 { margin: 0; font-size: 24px; }
|
||||
.content { padding: 40px 30px; text-align: center; }
|
||||
.code { font-size: 36px; font-weight: bold; letter-spacing: 8px; color: #333; background-color: #f8f9fa; padding: 20px 30px; border-radius: 8px; display: inline-block; margin: 20px 0; font-family: monospace; }
|
||||
.info { color: #666; font-size: 14px; line-height: 1.6; margin-top: 20px; }
|
||||
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; color: #999; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>%s</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p style="font-size: 18px; color: #333;">通知邮箱验证码 / Notification Email Verification</p>
|
||||
<div class="code">%s</div>
|
||||
<div class="info">
|
||||
<p>您正在添加额外的通知邮箱,请输入此验证码完成验证。</p>
|
||||
<p>You are adding an extra notification email. Please enter this code to verify.</p>
|
||||
<p>此验证码将在 <strong>15 分钟</strong>后失效。</p>
|
||||
<p>This code will expire in <strong>15 minutes</strong>.</p>
|
||||
<p>如果您没有请求此验证码,请忽略此邮件。</p>
|
||||
<p>If you did not request this code, please ignore this email.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>此邮件由系统自动发送,请勿回复。/ This is an automated message, please do not reply.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`, siteName, code)
|
||||
}
|
||||
|
||||
@ -114,7 +114,7 @@ func (m *mockBillingCache) InvalidateAPIKeyRateLimit(context.Context, int64) err
|
||||
func TestUpdateBalance_Success(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
cache := &mockBillingCache{}
|
||||
svc := NewUserService(repo, nil, cache)
|
||||
svc := NewUserService(repo, nil, nil, cache)
|
||||
|
||||
err := svc.UpdateBalance(context.Background(), 42, 100.0)
|
||||
require.NoError(t, err)
|
||||
@ -131,7 +131,7 @@ func TestUpdateBalance_Success(t *testing.T) {
|
||||
|
||||
func TestUpdateBalance_NilBillingCache_NoPanic(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
svc := NewUserService(repo, nil, nil) // billingCache = nil
|
||||
svc := NewUserService(repo, nil, nil, nil) // billingCache = nil
|
||||
|
||||
err := svc.UpdateBalance(context.Background(), 1, 50.0)
|
||||
require.NoError(t, err, "billingCache 为 nil 时不应 panic")
|
||||
@ -140,7 +140,7 @@ func TestUpdateBalance_NilBillingCache_NoPanic(t *testing.T) {
|
||||
func TestUpdateBalance_CacheFailure_DoesNotAffectReturn(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
cache := &mockBillingCache{invalidateErr: errors.New("redis connection refused")}
|
||||
svc := NewUserService(repo, nil, cache)
|
||||
svc := NewUserService(repo, nil, nil, cache)
|
||||
|
||||
err := svc.UpdateBalance(context.Background(), 99, 200.0)
|
||||
require.NoError(t, err, "缓存失效失败不应影响主流程返回值")
|
||||
@ -154,7 +154,7 @@ func TestUpdateBalance_CacheFailure_DoesNotAffectReturn(t *testing.T) {
|
||||
func TestUpdateBalance_RepoError_ReturnsError(t *testing.T) {
|
||||
repo := &mockUserRepo{updateBalanceErr: errors.New("database error")}
|
||||
cache := &mockBillingCache{}
|
||||
svc := NewUserService(repo, nil, cache)
|
||||
svc := NewUserService(repo, nil, nil, cache)
|
||||
|
||||
err := svc.UpdateBalance(context.Background(), 1, 100.0)
|
||||
require.Error(t, err, "repo 失败时应返回错误")
|
||||
@ -170,7 +170,7 @@ func TestUpdateBalance_WithAuthCacheInvalidator(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
auth := &mockAuthCacheInvalidator{}
|
||||
cache := &mockBillingCache{}
|
||||
svc := NewUserService(repo, auth, cache)
|
||||
svc := NewUserService(repo, nil, auth, cache)
|
||||
|
||||
err := svc.UpdateBalance(context.Background(), 77, 300.0)
|
||||
require.NoError(t, err)
|
||||
@ -191,7 +191,7 @@ func TestNewUserService_FieldsAssignment(t *testing.T) {
|
||||
auth := &mockAuthCacheInvalidator{}
|
||||
cache := &mockBillingCache{}
|
||||
|
||||
svc := NewUserService(repo, auth, cache)
|
||||
svc := NewUserService(repo, nil, auth, cache)
|
||||
require.NotNil(t, svc)
|
||||
require.Equal(t, repo, svc.userRepo)
|
||||
require.Equal(t, auth, svc.authCacheInvalidator)
|
||||
|
||||
@ -465,6 +465,7 @@ var ProviderSet = wire.NewSet(
|
||||
ProvidePaymentConfigService,
|
||||
NewPaymentService,
|
||||
ProvidePaymentOrderExpiryService,
|
||||
ProvideBalanceNotifyService,
|
||||
)
|
||||
|
||||
// ProvidePaymentConfigService wraps NewPaymentConfigService to accept the named
|
||||
@ -473,6 +474,11 @@ func ProvidePaymentConfigService(entClient *dbent.Client, settingRepo SettingRep
|
||||
return NewPaymentConfigService(entClient, settingRepo, []byte(key))
|
||||
}
|
||||
|
||||
// ProvideBalanceNotifyService creates BalanceNotifyService
|
||||
func ProvideBalanceNotifyService(emailService *EmailService, settingRepo SettingRepository) *BalanceNotifyService {
|
||||
return NewBalanceNotifyService(emailService, settingRepo)
|
||||
}
|
||||
|
||||
// ProvidePaymentOrderExpiryService creates and starts PaymentOrderExpiryService.
|
||||
func ProvidePaymentOrderExpiryService(paymentSvc *PaymentService) *PaymentOrderExpiryService {
|
||||
svc := NewPaymentOrderExpiryService(paymentSvc, 60*time.Second)
|
||||
|
||||
4
backend/migrations/101_add_balance_notify_fields.sql
Normal file
4
backend/migrations/101_add_balance_notify_fields.sql
Normal file
@ -0,0 +1,4 @@
|
||||
-- Balance notification user preferences
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS balance_notify_enabled BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS balance_notify_threshold DECIMAL(20,8) DEFAULT NULL;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS balance_notify_extra_emails TEXT NOT NULL DEFAULT '[]';
|
||||
@ -134,6 +134,11 @@ export interface SystemSettings {
|
||||
payment_cancel_rate_limit_window: number
|
||||
payment_cancel_rate_limit_unit: string
|
||||
payment_cancel_rate_limit_window_mode: string
|
||||
|
||||
// Balance & quota notification
|
||||
balance_low_notify_enabled: boolean
|
||||
balance_low_notify_threshold: number
|
||||
account_quota_notify_emails: string[]
|
||||
}
|
||||
|
||||
export interface UpdateSettingsRequest {
|
||||
@ -233,6 +238,10 @@ export interface UpdateSettingsRequest {
|
||||
payment_cancel_rate_limit_window?: number
|
||||
payment_cancel_rate_limit_unit?: string
|
||||
payment_cancel_rate_limit_window_mode?: string
|
||||
// Balance & quota notification
|
||||
balance_low_notify_enabled?: boolean
|
||||
balance_low_notify_threshold?: number
|
||||
account_quota_notify_emails?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -22,6 +22,9 @@ export async function getProfile(): Promise<User> {
|
||||
*/
|
||||
export async function updateProfile(profile: {
|
||||
username?: string
|
||||
balance_notify_enabled?: boolean
|
||||
balance_notify_threshold?: number | null
|
||||
balance_notify_extra_emails?: string[]
|
||||
}): Promise<User> {
|
||||
const { data } = await apiClient.put<User>('/user', profile)
|
||||
return data
|
||||
@ -45,10 +48,38 @@ export async function changePassword(
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Send verification code for adding a notify email
|
||||
* @param email - Email address to verify
|
||||
*/
|
||||
export async function sendNotifyEmailCode(email: string): Promise<void> {
|
||||
await apiClient.post('/user/notify-email/send-code', { email })
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and add a notify email
|
||||
* @param email - Email address to add
|
||||
* @param code - Verification code
|
||||
*/
|
||||
export async function verifyNotifyEmail(email: string, code: string): Promise<void> {
|
||||
await apiClient.post('/user/notify-email/verify', { email, code })
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a notify email
|
||||
* @param email - Email address to remove
|
||||
*/
|
||||
export async function removeNotifyEmail(email: string): Promise<void> {
|
||||
await apiClient.delete('/user/notify-email', { data: { email } })
|
||||
}
|
||||
|
||||
export const userAPI = {
|
||||
getProfile,
|
||||
updateProfile,
|
||||
changePassword
|
||||
changePassword,
|
||||
sendNotifyEmailCode,
|
||||
verifyNotifyEmail,
|
||||
removeNotifyEmail
|
||||
}
|
||||
|
||||
export default userAPI
|
||||
|
||||
@ -1186,6 +1186,12 @@
|
||||
:weeklyResetDay="editWeeklyResetDay"
|
||||
:weeklyResetHour="editWeeklyResetHour"
|
||||
:resetTimezone="editResetTimezone"
|
||||
:quotaNotifyDailyEnabled="editQuotaNotifyDailyEnabled"
|
||||
:quotaNotifyDailyThreshold="editQuotaNotifyDailyThreshold"
|
||||
:quotaNotifyWeeklyEnabled="editQuotaNotifyWeeklyEnabled"
|
||||
:quotaNotifyWeeklyThreshold="editQuotaNotifyWeeklyThreshold"
|
||||
:quotaNotifyTotalEnabled="editQuotaNotifyTotalEnabled"
|
||||
:quotaNotifyTotalThreshold="editQuotaNotifyTotalThreshold"
|
||||
@update:totalLimit="editQuotaLimit = $event"
|
||||
@update:dailyLimit="editQuotaDailyLimit = $event"
|
||||
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
|
||||
@ -1195,6 +1201,12 @@
|
||||
@update:weeklyResetDay="editWeeklyResetDay = $event"
|
||||
@update:weeklyResetHour="editWeeklyResetHour = $event"
|
||||
@update:resetTimezone="editResetTimezone = $event"
|
||||
@update:quotaNotifyDailyEnabled="editQuotaNotifyDailyEnabled = $event"
|
||||
@update:quotaNotifyDailyThreshold="editQuotaNotifyDailyThreshold = $event"
|
||||
@update:quotaNotifyWeeklyEnabled="editQuotaNotifyWeeklyEnabled = $event"
|
||||
@update:quotaNotifyWeeklyThreshold="editQuotaNotifyWeeklyThreshold = $event"
|
||||
@update:quotaNotifyTotalEnabled="editQuotaNotifyTotalEnabled = $event"
|
||||
@update:quotaNotifyTotalThreshold="editQuotaNotifyTotalThreshold = $event"
|
||||
/>
|
||||
</div>
|
||||
<!-- 配额控制 (非 Anthropic apikey/bedrock) -->
|
||||
@ -1218,6 +1230,12 @@
|
||||
:weeklyResetDay="editWeeklyResetDay"
|
||||
:weeklyResetHour="editWeeklyResetHour"
|
||||
:resetTimezone="editResetTimezone"
|
||||
:quotaNotifyDailyEnabled="editQuotaNotifyDailyEnabled"
|
||||
:quotaNotifyDailyThreshold="editQuotaNotifyDailyThreshold"
|
||||
:quotaNotifyWeeklyEnabled="editQuotaNotifyWeeklyEnabled"
|
||||
:quotaNotifyWeeklyThreshold="editQuotaNotifyWeeklyThreshold"
|
||||
:quotaNotifyTotalEnabled="editQuotaNotifyTotalEnabled"
|
||||
:quotaNotifyTotalThreshold="editQuotaNotifyTotalThreshold"
|
||||
@update:totalLimit="editQuotaLimit = $event"
|
||||
@update:dailyLimit="editQuotaDailyLimit = $event"
|
||||
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
|
||||
@ -1227,6 +1245,12 @@
|
||||
@update:weeklyResetDay="editWeeklyResetDay = $event"
|
||||
@update:weeklyResetHour="editWeeklyResetHour = $event"
|
||||
@update:resetTimezone="editResetTimezone = $event"
|
||||
@update:quotaNotifyDailyEnabled="editQuotaNotifyDailyEnabled = $event"
|
||||
@update:quotaNotifyDailyThreshold="editQuotaNotifyDailyThreshold = $event"
|
||||
@update:quotaNotifyWeeklyEnabled="editQuotaNotifyWeeklyEnabled = $event"
|
||||
@update:quotaNotifyWeeklyThreshold="editQuotaNotifyWeeklyThreshold = $event"
|
||||
@update:quotaNotifyTotalEnabled="editQuotaNotifyTotalEnabled = $event"
|
||||
@update:quotaNotifyTotalThreshold="editQuotaNotifyTotalThreshold = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1960,6 +1984,12 @@ const editWeeklyResetMode = ref<'rolling' | 'fixed' | null>(null)
|
||||
const editWeeklyResetDay = ref<number | null>(null)
|
||||
const editWeeklyResetHour = ref<number | null>(null)
|
||||
const editResetTimezone = ref<string | null>(null)
|
||||
const editQuotaNotifyDailyEnabled = ref<boolean | null>(null)
|
||||
const editQuotaNotifyDailyThreshold = ref<number | null>(null)
|
||||
const editQuotaNotifyWeeklyEnabled = ref<boolean | null>(null)
|
||||
const editQuotaNotifyWeeklyThreshold = ref<number | null>(null)
|
||||
const editQuotaNotifyTotalEnabled = ref<boolean | null>(null)
|
||||
const editQuotaNotifyTotalThreshold = ref<number | null>(null)
|
||||
const openAIWSModeOptions = computed(() => [
|
||||
{ value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') },
|
||||
// TODO: ctx_pool 选项暂时隐藏,待测试完成后恢复
|
||||
@ -2159,6 +2189,13 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
editWeeklyResetDay.value = (extra?.quota_weekly_reset_day as number) ?? null
|
||||
editWeeklyResetHour.value = (extra?.quota_weekly_reset_hour as number) ?? null
|
||||
editResetTimezone.value = (extra?.quota_reset_timezone as string) || null
|
||||
// Load quota notify config
|
||||
editQuotaNotifyDailyEnabled.value = (extra?.quota_notify_daily_enabled as boolean) ?? null
|
||||
editQuotaNotifyDailyThreshold.value = (extra?.quota_notify_daily_threshold as number) ?? null
|
||||
editQuotaNotifyWeeklyEnabled.value = (extra?.quota_notify_weekly_enabled as boolean) ?? null
|
||||
editQuotaNotifyWeeklyThreshold.value = (extra?.quota_notify_weekly_threshold as number) ?? null
|
||||
editQuotaNotifyTotalEnabled.value = (extra?.quota_notify_total_enabled as boolean) ?? null
|
||||
editQuotaNotifyTotalThreshold.value = (extra?.quota_notify_total_threshold as number) ?? null
|
||||
} else {
|
||||
editQuotaLimit.value = null
|
||||
editQuotaDailyLimit.value = null
|
||||
@ -2169,6 +2206,12 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
editWeeklyResetDay.value = null
|
||||
editWeeklyResetHour.value = null
|
||||
editResetTimezone.value = null
|
||||
editQuotaNotifyDailyEnabled.value = null
|
||||
editQuotaNotifyDailyThreshold.value = null
|
||||
editQuotaNotifyWeeklyEnabled.value = null
|
||||
editQuotaNotifyWeeklyThreshold.value = null
|
||||
editQuotaNotifyTotalEnabled.value = null
|
||||
editQuotaNotifyTotalThreshold.value = null
|
||||
}
|
||||
|
||||
// Load antigravity model mapping (Antigravity 只支持映射模式)
|
||||
@ -2283,6 +2326,13 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
editQuotaLimit.value = typeof bedrockExtra.quota_limit === 'number' ? bedrockExtra.quota_limit : null
|
||||
editQuotaDailyLimit.value = typeof bedrockExtra.quota_daily_limit === 'number' ? bedrockExtra.quota_daily_limit : null
|
||||
editQuotaWeeklyLimit.value = typeof bedrockExtra.quota_weekly_limit === 'number' ? bedrockExtra.quota_weekly_limit : null
|
||||
// Load quota notify for bedrock
|
||||
editQuotaNotifyDailyEnabled.value = (bedrockExtra.quota_notify_daily_enabled as boolean) ?? null
|
||||
editQuotaNotifyDailyThreshold.value = (bedrockExtra.quota_notify_daily_threshold as number) ?? null
|
||||
editQuotaNotifyWeeklyEnabled.value = (bedrockExtra.quota_notify_weekly_enabled as boolean) ?? null
|
||||
editQuotaNotifyWeeklyThreshold.value = (bedrockExtra.quota_notify_weekly_threshold as number) ?? null
|
||||
editQuotaNotifyTotalEnabled.value = (bedrockExtra.quota_notify_total_enabled as boolean) ?? null
|
||||
editQuotaNotifyTotalThreshold.value = (bedrockExtra.quota_notify_total_threshold as number) ?? null
|
||||
|
||||
// Load model mappings for bedrock
|
||||
const existingMappings = bedrockCreds.model_mapping as Record<string, string> | undefined
|
||||
@ -3198,6 +3248,40 @@ const handleSubmit = async () => {
|
||||
} else {
|
||||
delete newExtra.quota_reset_timezone
|
||||
}
|
||||
// Quota notify config
|
||||
if (editQuotaNotifyDailyEnabled.value) {
|
||||
newExtra.quota_notify_daily_enabled = true
|
||||
if (editQuotaNotifyDailyThreshold.value != null) {
|
||||
newExtra.quota_notify_daily_threshold = editQuotaNotifyDailyThreshold.value
|
||||
} else {
|
||||
delete newExtra.quota_notify_daily_threshold
|
||||
}
|
||||
} else {
|
||||
delete newExtra.quota_notify_daily_enabled
|
||||
delete newExtra.quota_notify_daily_threshold
|
||||
}
|
||||
if (editQuotaNotifyWeeklyEnabled.value) {
|
||||
newExtra.quota_notify_weekly_enabled = true
|
||||
if (editQuotaNotifyWeeklyThreshold.value != null) {
|
||||
newExtra.quota_notify_weekly_threshold = editQuotaNotifyWeeklyThreshold.value
|
||||
} else {
|
||||
delete newExtra.quota_notify_weekly_threshold
|
||||
}
|
||||
} else {
|
||||
delete newExtra.quota_notify_weekly_enabled
|
||||
delete newExtra.quota_notify_weekly_threshold
|
||||
}
|
||||
if (editQuotaNotifyTotalEnabled.value) {
|
||||
newExtra.quota_notify_total_enabled = true
|
||||
if (editQuotaNotifyTotalThreshold.value != null) {
|
||||
newExtra.quota_notify_total_threshold = editQuotaNotifyTotalThreshold.value
|
||||
} else {
|
||||
delete newExtra.quota_notify_total_threshold
|
||||
}
|
||||
} else {
|
||||
delete newExtra.quota_notify_total_enabled
|
||||
delete newExtra.quota_notify_total_threshold
|
||||
}
|
||||
updatePayload.extra = newExtra
|
||||
}
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
totalLimit: number | null
|
||||
dailyLimit: number | null
|
||||
weeklyLimit: number | null
|
||||
@ -14,7 +14,20 @@ const props = defineProps<{
|
||||
weeklyResetDay: number | null
|
||||
weeklyResetHour: number | null
|
||||
resetTimezone: string | null
|
||||
}>()
|
||||
quotaNotifyDailyEnabled?: boolean | null
|
||||
quotaNotifyDailyThreshold?: number | null
|
||||
quotaNotifyWeeklyEnabled?: boolean | null
|
||||
quotaNotifyWeeklyThreshold?: number | null
|
||||
quotaNotifyTotalEnabled?: boolean | null
|
||||
quotaNotifyTotalThreshold?: number | null
|
||||
}>(), {
|
||||
quotaNotifyDailyEnabled: null,
|
||||
quotaNotifyDailyThreshold: null,
|
||||
quotaNotifyWeeklyEnabled: null,
|
||||
quotaNotifyWeeklyThreshold: null,
|
||||
quotaNotifyTotalEnabled: null,
|
||||
quotaNotifyTotalThreshold: null,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:totalLimit': [value: number | null]
|
||||
@ -26,6 +39,12 @@ const emit = defineEmits<{
|
||||
'update:weeklyResetDay': [value: number | null]
|
||||
'update:weeklyResetHour': [value: number | null]
|
||||
'update:resetTimezone': [value: string | null]
|
||||
'update:quotaNotifyDailyEnabled': [value: boolean | null]
|
||||
'update:quotaNotifyDailyThreshold': [value: number | null]
|
||||
'update:quotaNotifyWeeklyEnabled': [value: boolean | null]
|
||||
'update:quotaNotifyWeeklyThreshold': [value: number | null]
|
||||
'update:quotaNotifyTotalEnabled': [value: boolean | null]
|
||||
'update:quotaNotifyTotalThreshold': [value: number | null]
|
||||
}>()
|
||||
|
||||
const enabled = computed(() =>
|
||||
@ -203,6 +222,36 @@ const onWeeklyModeChange = (e: Event) => {
|
||||
{{ t('admin.accounts.quotaDailyLimitHint') }}
|
||||
</template>
|
||||
</p>
|
||||
<!-- 日配额告警 -->
|
||||
<div v-if="dailyLimit && dailyLimit > 0" class="ml-4 mt-2 flex items-center gap-3">
|
||||
<label class="text-sm text-gray-500 whitespace-nowrap">{{ t('admin.accounts.quotaNotify.alert') }}</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="emit('update:quotaNotifyDailyEnabled', !(props.quotaNotifyDailyEnabled))"
|
||||
:class="[
|
||||
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
|
||||
props.quotaNotifyDailyEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
props.quotaNotifyDailyEnabled ? 'translate-x-4' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="props.quotaNotifyDailyEnabled" class="relative flex-1">
|
||||
<span class="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400 text-sm">$</span>
|
||||
<input
|
||||
:value="props.quotaNotifyDailyThreshold"
|
||||
@input="emit('update:quotaNotifyDailyThreshold', parseFloat(($event.target as HTMLInputElement).value) || null)"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="input pl-6 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 周配额 -->
|
||||
@ -259,6 +308,36 @@ const onWeeklyModeChange = (e: Event) => {
|
||||
{{ t('admin.accounts.quotaWeeklyLimitHint') }}
|
||||
</template>
|
||||
</p>
|
||||
<!-- 周配额告警 -->
|
||||
<div v-if="weeklyLimit && weeklyLimit > 0" class="ml-4 mt-2 flex items-center gap-3">
|
||||
<label class="text-sm text-gray-500 whitespace-nowrap">{{ t('admin.accounts.quotaNotify.alert') }}</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="emit('update:quotaNotifyWeeklyEnabled', !(props.quotaNotifyWeeklyEnabled))"
|
||||
:class="[
|
||||
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
|
||||
props.quotaNotifyWeeklyEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
props.quotaNotifyWeeklyEnabled ? 'translate-x-4' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="props.quotaNotifyWeeklyEnabled" class="relative flex-1">
|
||||
<span class="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400 text-sm">$</span>
|
||||
<input
|
||||
:value="props.quotaNotifyWeeklyThreshold"
|
||||
@input="emit('update:quotaNotifyWeeklyThreshold', parseFloat(($event.target as HTMLInputElement).value) || null)"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="input pl-6 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 时区选择(当任一维度使用固定模式时显示) -->
|
||||
@ -289,6 +368,36 @@ const onWeeklyModeChange = (e: Event) => {
|
||||
/>
|
||||
</div>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaTotalLimitHint') }}</p>
|
||||
<!-- 总配额告警 -->
|
||||
<div v-if="totalLimit && totalLimit > 0" class="ml-4 mt-2 flex items-center gap-3">
|
||||
<label class="text-sm text-gray-500 whitespace-nowrap">{{ t('admin.accounts.quotaNotify.alert') }}</label>
|
||||
<button
|
||||
type="button"
|
||||
@click="emit('update:quotaNotifyTotalEnabled', !(props.quotaNotifyTotalEnabled))"
|
||||
:class="[
|
||||
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
|
||||
props.quotaNotifyTotalEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
props.quotaNotifyTotalEnabled ? 'translate-x-4' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="props.quotaNotifyTotalEnabled" class="relative flex-1">
|
||||
<span class="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400 text-sm">$</span>
|
||||
<input
|
||||
:value="props.quotaNotifyTotalThreshold"
|
||||
@input="emit('update:quotaNotifyTotalThreshold', parseFloat(($event.target as HTMLInputElement).value) || null)"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="input pl-6 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ t('profile.balanceNotify.title') }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="px-6 py-6 space-y-6">
|
||||
<!-- Enable toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label">{{ t('profile.balanceNotify.enabled') }}</label>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" v-model="notifyEnabled" @change="handleToggle" class="sr-only peer" />
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 dark:peer-focus:ring-primary-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:after:border-gray-600 peer-checked:bg-primary-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Custom threshold -->
|
||||
<div v-if="notifyEnabled">
|
||||
<label class="input-label">
|
||||
{{ t('profile.balanceNotify.threshold') }}
|
||||
<span class="text-xs text-gray-400 ml-2">{{ t('profile.balanceNotify.thresholdHint') }}</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-500">$</span>
|
||||
<input
|
||||
v-model.number="customThreshold"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="input flex-1"
|
||||
:placeholder="t('profile.balanceNotify.thresholdPlaceholder')"
|
||||
@blur="handleThresholdUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extra emails -->
|
||||
<div v-if="notifyEnabled">
|
||||
<label class="input-label">{{ t('profile.balanceNotify.extraEmails') }}</label>
|
||||
|
||||
<!-- Existing emails list -->
|
||||
<div v-if="extraEmails.length > 0" class="space-y-2 mb-4">
|
||||
<div v-for="email in extraEmails" :key="email"
|
||||
class="flex items-center justify-between px-3 py-2 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ email }}</span>
|
||||
<button @click="handleRemoveEmail(email)" class="text-red-500 hover:text-red-700 text-sm">
|
||||
{{ t('profile.balanceNotify.removeEmail') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add new email -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="newEmail"
|
||||
type="email"
|
||||
class="input flex-1"
|
||||
:placeholder="t('profile.balanceNotify.emailPlaceholder')"
|
||||
:disabled="codeSent"
|
||||
/>
|
||||
<button
|
||||
@click="handleSendCode"
|
||||
:disabled="!newEmail || sendingCode || codeCountdown > 0"
|
||||
class="btn btn-outline whitespace-nowrap"
|
||||
>
|
||||
{{ codeCountdown > 0 ? `${codeCountdown}s` : (codeSent ? t('profile.balanceNotify.codeSent') : t('profile.balanceNotify.sendCode')) }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="codeSent" class="flex gap-2">
|
||||
<input
|
||||
v-model="verifyCode"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
class="input flex-1"
|
||||
:placeholder="t('profile.balanceNotify.codePlaceholder')"
|
||||
/>
|
||||
<button
|
||||
@click="handleVerify"
|
||||
:disabled="!verifyCode || verifyCode.length !== 6 || verifying"
|
||||
class="btn btn-primary whitespace-nowrap"
|
||||
>
|
||||
{{ t('profile.balanceNotify.verify') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { userAPI } from '@/api'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
|
||||
const props = defineProps<{
|
||||
enabled: boolean
|
||||
threshold: number | null
|
||||
extraEmails: string[]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const notifyEnabled = ref(props.enabled)
|
||||
const customThreshold = ref<number | null>(props.threshold)
|
||||
const extraEmails = ref<string[]>([...props.extraEmails])
|
||||
const newEmail = ref('')
|
||||
const verifyCode = ref('')
|
||||
const codeSent = ref(false)
|
||||
const sendingCode = ref(false)
|
||||
const verifying = ref(false)
|
||||
const codeCountdown = ref(0)
|
||||
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
watch(() => props.enabled, (val) => { notifyEnabled.value = val })
|
||||
watch(() => props.threshold, (val) => { customThreshold.value = val })
|
||||
watch(() => props.extraEmails, (val) => { extraEmails.value = [...val] })
|
||||
|
||||
const handleToggle = async () => {
|
||||
try {
|
||||
const updated = await userAPI.updateProfile({ balance_notify_enabled: notifyEnabled.value })
|
||||
authStore.user = updated
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
notifyEnabled.value = !notifyEnabled.value
|
||||
}
|
||||
}
|
||||
|
||||
const handleThresholdUpdate = async () => {
|
||||
try {
|
||||
const threshold = customThreshold.value && customThreshold.value > 0 ? customThreshold.value : 0
|
||||
const updated = await userAPI.updateProfile({ balance_notify_threshold: threshold })
|
||||
authStore.user = updated
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendCode = async () => {
|
||||
if (!newEmail.value) return
|
||||
sendingCode.value = true
|
||||
try {
|
||||
await userAPI.sendNotifyEmailCode(newEmail.value)
|
||||
codeSent.value = true
|
||||
codeCountdown.value = 60
|
||||
countdownTimer = setInterval(() => {
|
||||
codeCountdown.value--
|
||||
if (codeCountdown.value <= 0) {
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
}, 1000)
|
||||
appStore.showSuccess(t('profile.balanceNotify.codeSent'))
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
sendingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!verifyCode.value || verifyCode.value.length !== 6) return
|
||||
verifying.value = true
|
||||
try {
|
||||
await userAPI.verifyNotifyEmail(newEmail.value, verifyCode.value)
|
||||
extraEmails.value.push(newEmail.value)
|
||||
newEmail.value = ''
|
||||
verifyCode.value = ''
|
||||
codeSent.value = false
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
codeCountdown.value = 0
|
||||
appStore.showSuccess(t('profile.balanceNotify.verifySuccess'))
|
||||
// Refresh user data
|
||||
const updated = await userAPI.getProfile()
|
||||
authStore.user = updated
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
verifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveEmail = async (email: string) => {
|
||||
try {
|
||||
await userAPI.removeNotifyEmail(email)
|
||||
extraEmails.value = extraEmails.value.filter(e => e !== email)
|
||||
appStore.showSuccess(t('profile.balanceNotify.removeSuccess'))
|
||||
const updated = await userAPI.getProfile()
|
||||
authStore.user = updated
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -902,6 +902,31 @@ export default {
|
||||
sendCode: 'Send Code',
|
||||
codeSent: 'Verification code sent to your email',
|
||||
sendCodeFailed: 'Failed to send verification code'
|
||||
},
|
||||
balanceNotify: {
|
||||
title: 'Balance Low Notification',
|
||||
description: 'Send email alert when account balance falls below threshold',
|
||||
enabled: 'Enable Balance Low Notification',
|
||||
threshold: 'Custom Threshold',
|
||||
thresholdHint: 'Leave empty to use system default',
|
||||
thresholdPlaceholder: 'Enter amount',
|
||||
systemDefault: 'System Default',
|
||||
extraEmails: 'Extra Notification Emails',
|
||||
noExtraEmails: 'No extra notification emails',
|
||||
enterEmail: 'Enter email address',
|
||||
addEmail: 'Add Email',
|
||||
emailPlaceholder: 'Enter email address',
|
||||
sendCode: 'Send Code',
|
||||
codeSent: 'Verification code sent',
|
||||
codeSentTo: 'Code sent to {email}',
|
||||
enterCode: 'Enter verification code',
|
||||
codePlaceholder: '6-digit code',
|
||||
verify: 'Verify & Add',
|
||||
emailAdded: 'Email added',
|
||||
emailRemoved: 'Email removed',
|
||||
verifySuccess: 'Email added successfully',
|
||||
removeEmail: 'Remove',
|
||||
removeSuccess: 'Email removed',
|
||||
}
|
||||
},
|
||||
|
||||
@ -2228,6 +2253,12 @@ export default {
|
||||
},
|
||||
quotaLimitAmount: 'Total Limit',
|
||||
quotaLimitAmountHint: 'Cumulative spending limit. Does not auto-reset.',
|
||||
quotaNotify: {
|
||||
alert: 'Alert Threshold',
|
||||
enabled: 'Enable Alert',
|
||||
threshold: 'Alert Amount',
|
||||
thresholdPlaceholder: 'Enter alert amount',
|
||||
},
|
||||
testConnection: 'Test Connection',
|
||||
reAuthorize: 'Re-Authorize',
|
||||
refreshToken: 'Refresh Token',
|
||||
@ -4593,6 +4624,22 @@ export default {
|
||||
supportedTypesHint: 'Comma-separated, e.g. alipay,wxpay',
|
||||
refundEnabled: 'Allow Refund',
|
||||
},
|
||||
balanceNotify: {
|
||||
title: 'Balance Low Notification',
|
||||
description: 'Send email notification when user balance falls below threshold',
|
||||
enabled: 'Enable Balance Low Notification',
|
||||
threshold: 'Default Threshold',
|
||||
thresholdHint: 'Used when user has not set a custom value',
|
||||
thresholdPlaceholder: 'Enter amount',
|
||||
},
|
||||
quotaNotify: {
|
||||
title: 'Account Quota Notification',
|
||||
description: 'Notify admins when account quota usage reaches alert threshold',
|
||||
emails: 'Notification Emails',
|
||||
emailsHint: 'Leave empty to disable notifications',
|
||||
addEmail: 'Add Email',
|
||||
emailPlaceholder: 'Enter email address',
|
||||
},
|
||||
smtp: {
|
||||
title: 'SMTP Settings',
|
||||
description: 'Configure email sending for verification codes',
|
||||
|
||||
@ -906,6 +906,31 @@ export default {
|
||||
sendCode: '发送验证码',
|
||||
codeSent: '验证码已发送到您的邮箱',
|
||||
sendCodeFailed: '发送验证码失败'
|
||||
},
|
||||
balanceNotify: {
|
||||
title: '余额不足提醒',
|
||||
description: '当账户余额低于阈值时发送邮件提醒',
|
||||
enabled: '启用余额不足提醒',
|
||||
threshold: '自定义提醒阈值',
|
||||
thresholdHint: '留空使用系统默认值',
|
||||
thresholdPlaceholder: '输入金额',
|
||||
systemDefault: '系统默认值',
|
||||
extraEmails: '额外通知邮箱',
|
||||
noExtraEmails: '暂无额外通知邮箱',
|
||||
enterEmail: '输入邮箱地址',
|
||||
addEmail: '添加邮箱',
|
||||
emailPlaceholder: '输入邮箱地址',
|
||||
sendCode: '发送验证码',
|
||||
codeSent: '验证码已发送',
|
||||
codeSentTo: '验证码已发送到 {email}',
|
||||
enterCode: '输入验证码',
|
||||
codePlaceholder: '6位验证码',
|
||||
verify: '确认添加',
|
||||
emailAdded: '邮箱已添加',
|
||||
emailRemoved: '邮箱已移除',
|
||||
verifySuccess: '邮箱添加成功',
|
||||
removeEmail: '移除',
|
||||
removeSuccess: '邮箱已移除',
|
||||
}
|
||||
},
|
||||
|
||||
@ -2226,6 +2251,12 @@ export default {
|
||||
},
|
||||
quotaLimitAmount: '总限额',
|
||||
quotaLimitAmountHint: '累计消费上限,不会自动重置。',
|
||||
quotaNotify: {
|
||||
alert: '告警阈值',
|
||||
enabled: '启用告警',
|
||||
threshold: '告警金额',
|
||||
thresholdPlaceholder: '输入告警金额',
|
||||
},
|
||||
testConnection: '测试连接',
|
||||
reAuthorize: '重新授权',
|
||||
refreshToken: '刷新令牌',
|
||||
@ -4757,6 +4788,22 @@ export default {
|
||||
supportedTypesHint: '逗号分隔,如 alipay,wxpay',
|
||||
refundEnabled: '允许退款',
|
||||
},
|
||||
balanceNotify: {
|
||||
title: '余额不足提醒',
|
||||
description: '当用户余额低于阈值时发送邮件提醒',
|
||||
enabled: '启用余额不足提醒',
|
||||
threshold: '默认提醒阈值',
|
||||
thresholdHint: '用户未自定义时使用此值',
|
||||
thresholdPlaceholder: '输入金额',
|
||||
},
|
||||
quotaNotify: {
|
||||
title: '账号限额通知',
|
||||
description: '当账号配额用量达到告警阈值时通知管理员',
|
||||
emails: '通知邮箱',
|
||||
emailsHint: '留空则不发送通知',
|
||||
addEmail: '添加邮箱',
|
||||
emailPlaceholder: '输入邮箱地址',
|
||||
},
|
||||
smtp: {
|
||||
title: 'SMTP 设置',
|
||||
description: '配置用于发送验证码的邮件服务',
|
||||
|
||||
@ -33,6 +33,9 @@ export interface User {
|
||||
concurrency: number // Allowed concurrent requests
|
||||
status: 'active' | 'disabled' // Account status
|
||||
allowed_groups: number[] | null // Allowed group IDs (null = all non-exclusive groups)
|
||||
balance_notify_enabled: boolean
|
||||
balance_notify_threshold: number | null
|
||||
balance_notify_extra_emails: string[]
|
||||
subscriptions?: UserSubscription[] // User's active subscriptions
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
||||
@ -2562,6 +2562,60 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Balance Low Notification -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h3 class="text-base font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.balanceNotify.title') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.balanceNotify.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-6 py-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="mb-0 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.settings.balanceNotify.enabled') }}</label>
|
||||
<Toggle v-model="form.balance_low_notify_enabled" />
|
||||
</div>
|
||||
<div v-if="form.balance_low_notify_enabled">
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.settings.balanceNotify.threshold') }}</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">$</span>
|
||||
<input v-model.number="form.balance_low_notify_threshold" type="number" min="0" step="0.01" class="input pl-7" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.balanceNotify.thresholdHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Quota Notification -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h3 class="text-base font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.quotaNotify.title') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.quotaNotify.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-6 py-6 space-y-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.settings.quotaNotify.emails') }}</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(_, index) in (form.account_quota_notify_emails || [])" :key="index" class="flex items-center gap-2">
|
||||
<input v-model="form.account_quota_notify_emails[index]" type="email" class="input flex-1" />
|
||||
<button @click="form.account_quota_notify_emails.splice(index, 1)" class="btn btn-secondary px-2" type="button">
|
||||
<Icon name="x" size="xs" class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button @click="addQuotaNotifyEmail" class="btn btn-secondary btn-sm" type="button">
|
||||
+ {{ t('admin.settings.quotaNotify.addEmail') }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.quotaNotify.emailsHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /Tab: Email -->
|
||||
|
||||
<!-- Tab: Backup -->
|
||||
@ -2840,7 +2894,11 @@ const form = reactive<SettingsForm>({
|
||||
// Gateway forwarding behavior
|
||||
enable_fingerprint_unification: true,
|
||||
enable_metadata_passthrough: false,
|
||||
enable_cch_signing: false
|
||||
enable_cch_signing: false,
|
||||
// Balance & quota notification
|
||||
balance_low_notify_enabled: false,
|
||||
balance_low_notify_threshold: 0,
|
||||
account_quota_notify_emails: [] as string[]
|
||||
})
|
||||
|
||||
// Web Search Emulation config (loaded/saved separately)
|
||||
@ -2972,6 +3030,14 @@ function handleRegistrationEmailSuffixWhitelistPaste(event: ClipboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// Quota notify email helpers
|
||||
const addQuotaNotifyEmail = () => {
|
||||
if (!form.account_quota_notify_emails) {
|
||||
form.account_quota_notify_emails = []
|
||||
}
|
||||
form.account_quota_notify_emails.push('')
|
||||
}
|
||||
|
||||
// LinuxDo OAuth redirect URL suggestion
|
||||
const linuxdoRedirectUrlSuggestion = computed(() => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
@ -3311,6 +3377,10 @@ async function saveSettings() {
|
||||
payment_cancel_rate_limit_window: Number(form.payment_cancel_rate_limit_window) || 1,
|
||||
payment_cancel_rate_limit_unit: form.payment_cancel_rate_limit_unit,
|
||||
payment_cancel_rate_limit_window_mode: form.payment_cancel_rate_limit_window_mode,
|
||||
// Balance & quota notification
|
||||
balance_low_notify_enabled: form.balance_low_notify_enabled,
|
||||
balance_low_notify_threshold: Number(form.balance_low_notify_threshold) || 0,
|
||||
account_quota_notify_emails: (form.account_quota_notify_emails || []).filter((e: string) => e.trim() !== ''),
|
||||
}
|
||||
|
||||
const updated = await adminAPI.settings.updateSettings(payload)
|
||||
|
||||
@ -14,6 +14,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<ProfileEditForm :initial-username="user?.username || ''" />
|
||||
<ProfileBalanceNotifyCard
|
||||
v-if="user"
|
||||
:enabled="user.balance_notify_enabled ?? true"
|
||||
:threshold="user.balance_notify_threshold"
|
||||
:extra-emails="user.balance_notify_extra_emails ?? []"
|
||||
/>
|
||||
<ProfilePasswordForm />
|
||||
<ProfileTotpCard />
|
||||
</div>
|
||||
@ -27,6 +33,7 @@ import { authAPI } from '@/api'; import AppLayout from '@/components/layout/AppL
|
||||
import StatCard from '@/components/common/StatCard.vue'
|
||||
import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue'
|
||||
import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue'
|
||||
import ProfileBalanceNotifyCard from '@/components/user/profile/ProfileBalanceNotifyCard.vue'
|
||||
import ProfilePasswordForm from '@/components/user/profile/ProfilePasswordForm.vue'
|
||||
import ProfileTotpCard from '@/components/user/profile/ProfileTotpCard.vue'
|
||||
import { Icon } from '@/components/icons'
|
||||
|
||||
Reference in New Issue
Block a user