diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 9bfa27174db..b30407ecc77 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -98,6 +98,7 @@ func provideCleanup( backupSvc *service.BackupService, paymentOrderExpiry *service.PaymentOrderExpiryService, channelMonitorRunner *service.ChannelMonitorRunner, + billingStatementEmail *service.BillingStatementEmailService, ) func() { return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -246,6 +247,12 @@ func provideCleanup( } return nil }}, + {"BillingStatementEmailService", func() error { + if billingStatementEmail != nil { + billingStatementEmail.Stop() + } + return nil + }}, } infraSteps := []cleanupStep{ diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index a550118139e..0eb4a33ad95 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -260,13 +260,14 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { opsAlertEvaluatorService := service.ProvideOpsAlertEvaluatorService(opsService, opsRepository, emailService, redisClient, configConfig) opsCleanupService := service.ProvideOpsCleanupService(opsRepository, db, redisClient, configConfig, channelMonitorService, settingRepository, opsService) opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig) + billingStatementEmailService := service.ProvideBillingStatementEmailService(settingRepository, userRepository, groupRepository, usageLogRepository, emailService, redisClient, configConfig) tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI) accountExpiryService := service.ProvideAccountExpiryService(accountRepository) subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository) scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig) paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService) channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService) - v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner) + v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, billingStatementEmailService) application := &Application{ Server: httpServer, Cleanup: v, @@ -321,6 +322,7 @@ func provideCleanup( backupSvc *service.BackupService, paymentOrderExpiry *service.PaymentOrderExpiryService, channelMonitorRunner *service.ChannelMonitorRunner, + billingStatementEmail *service.BillingStatementEmailService, ) func() { return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -468,6 +470,12 @@ func provideCleanup( } return nil }}, + {"BillingStatementEmailService", func() error { + if billingStatementEmail != nil { + billingStatementEmail.Stop() + } + return nil + }}, } infraSteps := []cleanupStep{ diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go index 5ccd67fb5cd..395c5cdaf5e 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -77,6 +77,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { nil, // backupSvc nil, // paymentOrderExpiry nil, // channelMonitorRunner + nil, // billingStatementEmail ) require.NotPanics(t, func() { diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 7ad51660670..37cd1ac7795 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -263,6 +263,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { AvailableChannelsEnabled: settings.AvailableChannelsEnabled, AffiliateEnabled: settings.AffiliateEnabled, + + BillingStatementEmailConfig: settings.BillingStatementEmailConfig, } // OpenAI fast policy (stored under a dedicated setting key) @@ -569,6 +571,9 @@ type UpdateSettingsRequest struct { // 风控中心功能开关 RiskControlEnabled *bool `json:"risk_control_enabled"` + // Billing statement email config (JSON string, only updated when non-empty) + BillingStatementEmailConfig *string `json:"billing_statement_email_config,omitempty"` + // OpenAI fast/flex policy (optional, only updated when provided) OpenAIFastPolicySettings *dto.OpenAIFastPolicySettings `json:"openai_fast_policy_settings,omitempty"` } @@ -1505,6 +1510,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.RiskControlEnabled }(), + BillingStatementEmailConfig: func() string { + if req.BillingStatementEmailConfig != nil { + return *req.BillingStatementEmailConfig + } + return previousSettings.BillingStatementEmailConfig + }(), } authSourceDefaults := &service.AuthSourceDefaultSettings{ @@ -1783,9 +1794,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled, - AffiliateEnabled: updatedSettings.AffiliateEnabled, - - RiskControlEnabled: updatedSettings.RiskControlEnabled, + AffiliateEnabled: updatedSettings.AffiliateEnabled, + RiskControlEnabled: updatedSettings.RiskControlEnabled, + BillingStatementEmailConfig: updatedSettings.BillingStatementEmailConfig, } if fastPolicy, err := h.settingService.GetOpenAIFastPolicySettings(c.Request.Context()); err != nil { slog.Error("openai_fast_policy_settings_get_failed", "error", err) diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 2559b112cb9..870d6dde37c 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -13,23 +13,27 @@ 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, - LastActiveAt: u.LastActiveAt, - CreatedAt: u.CreatedAt, - UpdatedAt: u.UpdatedAt, - BalanceNotifyEnabled: u.BalanceNotifyEnabled, - BalanceNotifyThresholdType: u.BalanceNotifyThresholdType, - BalanceNotifyThreshold: u.BalanceNotifyThreshold, - BalanceNotifyExtraEmails: NotifyEmailEntriesFromService(u.BalanceNotifyExtraEmails), - TotalRecharged: u.TotalRecharged, - RPMLimit: u.RPMLimit, + ID: u.ID, + Email: u.Email, + Username: u.Username, + Role: u.Role, + Balance: u.Balance, + Concurrency: u.Concurrency, + Status: u.Status, + AllowedGroups: u.AllowedGroups, + LastActiveAt: u.LastActiveAt, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + BalanceNotifyEnabled: u.BalanceNotifyEnabled, + BalanceNotifyThresholdType: u.BalanceNotifyThresholdType, + BalanceNotifyThreshold: u.BalanceNotifyThreshold, + BalanceNotifyExtraEmails: NotifyEmailEntriesFromService(u.BalanceNotifyExtraEmails), + TotalRecharged: u.TotalRecharged, + RPMLimit: u.RPMLimit, + BillingStatementDailyEnabled: u.BillingStatementDailyEnabled, + BillingStatementWeeklyEnabled: u.BillingStatementWeeklyEnabled, + BillingStatementMonthlyEnabled: u.BillingStatementMonthlyEnabled, + Timezone: u.Timezone, } } diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 2d4cefa1595..2a4d11295ae 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -219,6 +219,9 @@ type SystemSettings struct { // Affiliate (邀请返利) feature switch AffiliateEnabled bool `json:"affiliate_enabled"` + // Billing statement email config (JSON string) + BillingStatementEmailConfig string `json:"billing_statement_email_config"` + // OpenAI fast/flex policy OpenAIFastPolicySettings *OpenAIFastPolicySettings `json:"openai_fast_policy_settings,omitempty"` } @@ -283,7 +286,12 @@ type PublicSettings struct { AffiliateEnabled bool `json:"affiliate_enabled"` - RiskControlEnabled bool `json:"risk_control_enabled"` + RiskControlEnabled bool `json:"risk_control_enabled"` + BillingStatementEmailEnabled bool `json:"billing_statement_email_enabled"` + BillingStatementDailyEnabled bool `json:"billing_statement_daily_enabled"` + BillingStatementWeeklyEnabled bool `json:"billing_statement_weekly_enabled"` + BillingStatementMonthlyEnabled bool `json:"billing_statement_monthly_enabled"` + ServerTimezone string `json:"server_timezone"` } type LoginAgreementDocument struct { diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index e15a916eec4..60042bbd308 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -27,7 +27,13 @@ type User struct { TotalRecharged float64 `json:"total_recharged"` // RPMLimit 用户级每分钟请求数上限(0 = 不限制),仅在所用分组未设置 rpm_limit 时作为兜底生效。 - RPMLimit int `json:"rpm_limit"` + RPMLimit int `json:"rpm_limit"` + Timezone string `json:"timezone"` + + // 账单邮件偏好 + BillingStatementDailyEnabled bool `json:"billing_statement_daily_enabled"` + BillingStatementWeeklyEnabled bool `json:"billing_statement_weekly_enabled"` + BillingStatementMonthlyEnabled bool `json:"billing_statement_monthly_enabled"` APIKeys []APIKey `json:"api_keys,omitempty"` Subscriptions []UserSubscription `json:"subscriptions,omitempty"` diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 6c389e3da1e..5937c0ded62 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -85,7 +85,12 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { AffiliateEnabled: settings.AffiliateEnabled, - RiskControlEnabled: settings.RiskControlEnabled, + RiskControlEnabled: settings.RiskControlEnabled, + BillingStatementEmailEnabled: settings.BillingStatementEmailEnabled, + BillingStatementDailyEnabled: settings.BillingStatementDailyEnabled, + BillingStatementWeeklyEnabled: settings.BillingStatementWeeklyEnabled, + BillingStatementMonthlyEnabled: settings.BillingStatementMonthlyEnabled, + ServerTimezone: settings.ServerTimezone, }) } diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 3f6ed8c2bcc..2306c85ef2a 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -50,6 +50,12 @@ type UpdateProfileRequest struct { AvatarURL *string `json:"avatar_url"` BalanceNotifyEnabled *bool `json:"balance_notify_enabled"` BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"` + + // Billing statement email preferences + BillingStatementDailyEnabled *bool `json:"billing_statement_daily_enabled"` + BillingStatementWeeklyEnabled *bool `json:"billing_statement_weekly_enabled"` + BillingStatementMonthlyEnabled *bool `json:"billing_statement_monthly_enabled"` + Timezone *string `json:"timezone"` } type userProfileResponse struct { @@ -142,10 +148,14 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) { } svcReq := service.UpdateProfileRequest{ - Username: req.Username, - AvatarURL: req.AvatarURL, - BalanceNotifyEnabled: req.BalanceNotifyEnabled, - BalanceNotifyThreshold: req.BalanceNotifyThreshold, + Username: req.Username, + AvatarURL: req.AvatarURL, + BalanceNotifyEnabled: req.BalanceNotifyEnabled, + BalanceNotifyThreshold: req.BalanceNotifyThreshold, + BillingStatementDailyEnabled: req.BillingStatementDailyEnabled, + BillingStatementWeeklyEnabled: req.BillingStatementWeeklyEnabled, + BillingStatementMonthlyEnabled: req.BillingStatementMonthlyEnabled, + Timezone: req.Timezone, } updatedUser, err := h.userService.UpdateProfile(c.Request.Context(), subject.UserID, svcReq) if err != nil { diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 27358865666..420432c562b 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -65,6 +65,10 @@ func TestAPIContracts(t *testing.T) { "balance_notify_threshold": null, "balance_notify_extra_emails": null, "total_recharged": 0, + "timezone": "UTC", + "billing_statement_daily_enabled": false, + "billing_statement_weekly_enabled": false, + "billing_statement_monthly_enabled": false, "linuxdo_bound": false, "oidc_bound": false, "wechat_bound": false, @@ -818,6 +822,7 @@ func TestAPIContracts(t *testing.T) { "balance_low_notify_threshold": 0, "balance_low_notify_recharge_url": "", "account_quota_notify_emails": [], + "billing_statement_email_config": "", "channel_monitor_enabled": true, "channel_monitor_default_interval_seconds": 60, "available_channels_enabled": false, @@ -1029,6 +1034,7 @@ func TestAPIContracts(t *testing.T) { "balance_low_notify_threshold": 0, "balance_low_notify_recharge_url": "", "account_quota_notify_emails": [], + "billing_statement_email_config": "", "channel_monitor_enabled": true, "channel_monitor_default_interval_seconds": 60, "available_channels_enabled": false, diff --git a/backend/internal/service/billing_statement_email_service.go b/backend/internal/service/billing_statement_email_service.go new file mode 100644 index 00000000000..b090f1007c8 --- /dev/null +++ b/backend/internal/service/billing_statement_email_service.go @@ -0,0 +1,834 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "log" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/google/uuid" + "github.com/robfig/cron/v3" +) + +// ───────────────────────────────────────────────────────────────────────────── +// Configuration model (stored as JSON in setting key billing_statement_email_config) +// ───────────────────────────────────────────────────────────────────────────── + +// BillingStatementEmailConfig is the JSON structure stored in the setting key. +type BillingStatementEmailConfig struct { + Enabled bool `json:"enabled"` + DailyEnabled bool `json:"daily_enabled"` + WeeklyEnabled bool `json:"weekly_enabled"` + MonthlyEnabled bool `json:"monthly_enabled"` + DailySchedule string `json:"daily_schedule"` // cron spec (5-field) + WeeklySchedule string `json:"weekly_schedule"` // cron spec (5-field) + MonthlySchedule string `json:"monthly_schedule"` // cron spec (5-field) +} + +// DefaultBillingStatementEmailConfig returns the default configuration. +func DefaultBillingStatementEmailConfig() BillingStatementEmailConfig { + return BillingStatementEmailConfig{ + Enabled: false, + DailyEnabled: false, + WeeklyEnabled: false, + MonthlyEnabled: false, + DailySchedule: "0 8 * * *", // every day at 08:00 + WeeklySchedule: "0 8 * * 1", // every Monday at 08:00 + MonthlySchedule: "0 8 1 * *", // 1st of month at 08:00 + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// User preference model (stored per-user in setting key billing_statement_user_pref:{userID}) +// ───────────────────────────────────────────────────────────────────────────── + +// BillingStatementUserPreference represents a user's opt-in/out for each billing period. +type BillingStatementUserPreference struct { + DailyEnabled bool `json:"daily_enabled"` + WeeklyEnabled bool `json:"weekly_enabled"` + MonthlyEnabled bool `json:"monthly_enabled"` +} + +// DefaultBillingStatementUserPreference returns the default preference (all disabled). +// Existing users without an explicit preference JSON should not receive billing +// statements until they opt in from their profile. +func DefaultBillingStatementUserPreference() BillingStatementUserPreference { + return BillingStatementUserPreference{ + DailyEnabled: false, + WeeklyEnabled: false, + MonthlyEnabled: false, + } +} + +// ParseBillingStatementUserPreference parses JSON into preference, falling back to defaults. +func ParseBillingStatementUserPreference(raw string) BillingStatementUserPreference { + pref := DefaultBillingStatementUserPreference() + if strings.TrimSpace(raw) == "" { + return pref + } + if err := json.Unmarshal([]byte(raw), &pref); err != nil { + return DefaultBillingStatementUserPreference() + } + return pref +} + +// billingStatementUserPreferenceSettingKey returns the setting key for a user's billing preference. +func billingStatementUserPreferenceSettingKey(userID int64) string { + return SettingKeyBillingStatementUserPreferencePrefix + strconv.FormatInt(userID, 10) +} + +// ParseBillingStatementEmailConfig parses JSON into config, falling back to defaults. +func ParseBillingStatementEmailConfig(raw string) BillingStatementEmailConfig { + cfg := DefaultBillingStatementEmailConfig() + if strings.TrimSpace(raw) == "" { + return cfg + } + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + return DefaultBillingStatementEmailConfig() + } + return cfg +} + +// ───────────────────────────────────────────────────────────────────────────── +// Aggregation DTO (computed from usage_logs) +// ───────────────────────────────────────────────────────────────────────────── + +// BillingStatementLine represents one aggregated line in the billing statement. +type BillingStatementLine struct { + Model string `json:"model"` + BillingMode string `json:"billing_mode"` // "token" / "image" / "" + GroupID *int64 `json:"group_id"` + GroupName string `json:"group_name"` + Subscription *int64 `json:"subscription_id"` + Requests int64 `json:"requests"` + TotalTokens int64 `json:"total_tokens"` + TotalCost float64 `json:"total_cost"` // standard price + ActualCost float64 `json:"actual_cost"` // user price after multiplier + Discount float64 `json:"discount"` // total_cost - actual_cost +} + +// BillingStatement is the full statement for one user in a time range. +type BillingStatement struct { + UserID int64 + UserEmail string + PeriodName string // "日账单" / "周账单" / "月账单" + Start time.Time + End time.Time + Timezone string + Lines []BillingStatementLine + TotalCost float64 + ActualCost float64 + Discount float64 + Balance float64 +} + +// ───────────────────────────────────────────────────────────────────────────── +// Service +// ───────────────────────────────────────────────────────────────────────────── + +const ( + billingStatementLeaderLockKey = "billing_statement:leader" + billingStatementLeaderLockTTL = 5 * time.Minute + + billingStatementLastRunKeyPrefix = "billing_statement:last_run:" + billingStatementTickInterval = 1 * time.Minute + + billingStatementUserPageSize = 100 +) + +var billingStatementCronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) + +type billingStatementRedisClient interface { + SetNX(ctx context.Context, key string, value any, expiration time.Duration) (bool, error) + Get(ctx context.Context, key string) (string, error) + Set(ctx context.Context, key string, value any, expiration time.Duration) error + ReleaseIfValue(ctx context.Context, key string, value string) error +} + +// BillingStatementEmailService sends periodic billing statement emails to users. +type BillingStatementEmailService struct { + settingRepo SettingRepository + userRepo UserRepository + groupRepo GroupRepository + usageRepo UsageLogRepository + emailService *EmailService + redisClient billingStatementRedisClient + cfg *config.Config + + instanceID string + loc *time.Location + + distributedLockOn bool + warnNoRedisOnce sync.Once + + startOnce sync.Once + stopOnce sync.Once + stopCtx context.Context + stop context.CancelFunc + wg sync.WaitGroup +} + +// NewBillingStatementEmailService creates the service. +func NewBillingStatementEmailService( + settingRepo SettingRepository, + userRepo UserRepository, + groupRepo GroupRepository, + usageRepo UsageLogRepository, + emailService *EmailService, + redisClient billingStatementRedisClient, + cfg *config.Config, +) *BillingStatementEmailService { + lockOn := cfg == nil || strings.TrimSpace(cfg.RunMode) != config.RunModeSimple + + loc := time.Local + if cfg != nil && strings.TrimSpace(cfg.Timezone) != "" { + if parsed, err := time.LoadLocation(strings.TrimSpace(cfg.Timezone)); err == nil && parsed != nil { + loc = parsed + } + } + return &BillingStatementEmailService{ + settingRepo: settingRepo, + userRepo: userRepo, + groupRepo: groupRepo, + usageRepo: usageRepo, + emailService: emailService, + redisClient: redisClient, + cfg: cfg, + instanceID: uuid.NewString(), + loc: loc, + distributedLockOn: lockOn, + } +} + +// Start begins the background ticker. +func (s *BillingStatementEmailService) Start() { + s.StartWithContext(context.Background()) +} + +// StartWithContext begins the background ticker with a parent context. +func (s *BillingStatementEmailService) StartWithContext(ctx context.Context) { + if s == nil { + return + } + if ctx == nil { + ctx = context.Background() + } + if s.emailService == nil || s.settingRepo == nil { + return + } + + s.startOnce.Do(func() { + s.stopCtx, s.stop = context.WithCancel(ctx) + s.wg.Add(1) + go s.run() + }) +} + +// Stop gracefully stops the service. +func (s *BillingStatementEmailService) Stop() { + if s == nil { + return + } + s.stopOnce.Do(func() { + if s.stop != nil { + s.stop() + } + }) + s.wg.Wait() +} + +func (s *BillingStatementEmailService) run() { + defer s.wg.Done() + + ticker := time.NewTicker(billingStatementTickInterval) + defer ticker.Stop() + + s.runOnce() + for { + select { + case <-ticker.C: + s.runOnce() + case <-s.stopCtx.Done(): + return + } + } +} + +func (s *BillingStatementEmailService) runOnce() { + if s == nil || s.emailService == nil || s.settingRepo == nil { + return + } + + ctx, cancel := context.WithTimeout(s.stopCtx, 120*time.Second) + defer cancel() + + // Read config from settings + billingCfg := s.loadConfig(ctx) + if !billingCfg.Enabled { + return + } + + // Acquire leader lock + release, ok := s.tryAcquireLeaderLock(ctx) + if !ok { + return + } + if release != nil { + defer release() + } + + now := time.Now() + if s.loc != nil { + now = now.In(s.loc) + } + + type statementDef struct { + enabled bool + kind string + name string + schedule string + } + + defs := []statementDef{ + {enabled: billingCfg.DailyEnabled, kind: "daily", name: "日账单 / Daily Billing Statement", schedule: billingCfg.DailySchedule}, + {enabled: billingCfg.WeeklyEnabled, kind: "weekly", name: "周账单 / Weekly Billing Statement", schedule: billingCfg.WeeklySchedule}, + {enabled: billingCfg.MonthlyEnabled, kind: "monthly", name: "月账单 / Monthly Billing Statement", schedule: billingCfg.MonthlySchedule}, + } + + for _, d := range defs { + if !d.enabled { + continue + } + spec := strings.TrimSpace(d.schedule) + if spec == "" { + continue + } + sched, err := billingStatementCronParser.Parse(spec) + if err != nil { + log.Printf("[BillingStatement] invalid cron spec=%q for kind=%s: %v", spec, d.kind, err) + continue + } + + lastRun := s.getLastRunAt(ctx, d.kind) + base := lastRun + if base.IsZero() { + base = now.Add(-1 * time.Minute) + } + next := sched.Next(base) + if next.IsZero() || next.After(now) { + continue + } + if !s.isEmailDeliveryConfigured(ctx) { + continue + } + + // Time to run this statement. Only record last_run after the + // send cycle completes without the context being canceled/timing out, + // so interrupted runs remain eligible for retry on the next pass. + s.sendStatements(ctx, d.kind, d.name, now) + if ctx.Err() != nil { + log.Printf("[BillingStatement] send interrupted for kind=%s; last_run not updated: %v", d.kind, ctx.Err()) + continue + } + s.setLastRunAt(ctx, d.kind, now) + } +} + +func (s *BillingStatementEmailService) isEmailDeliveryConfigured(ctx context.Context) bool { + if s == nil || s.emailService == nil { + return false + } + if _, err := s.emailService.GetSMTPConfig(ctx); err != nil { + log.Printf("[BillingStatement] email delivery is not configured; skipping: %v", err) + return false + } + return true +} + +func (s *BillingStatementEmailService) loadConfig(ctx context.Context) BillingStatementEmailConfig { + raw, err := s.settingRepo.GetValue(ctx, SettingKeyBillingStatementEmailConfig) + if err != nil || strings.TrimSpace(raw) == "" { + return DefaultBillingStatementEmailConfig() + } + return ParseBillingStatementEmailConfig(raw) +} + +func (s *BillingStatementEmailService) sendStatements(ctx context.Context, kind string, periodName string, now time.Time) { + page := 1 + for { + users, pageResult, err := s.userRepo.List(ctx, pagination.PaginationParams{ + Page: page, + PageSize: billingStatementUserPageSize, + }) + if err != nil { + log.Printf("[BillingStatement] list users page=%d error: %v", page, err) + return + } + + for i := range users { + user := &users[i] + if !isValidEmailForBilling(user.Email) { + continue + } + // Check user preference for this period kind + if !s.isUserPeriodEnabled(ctx, user.ID, kind) { + continue + } + loc := s.userLocation(ctx, user.ID) + start, end := billingStatementPeriodRange(kind, now, loc) + s.sendStatementToUser(ctx, user, periodName, start, end, loc) + } + + if pageResult == nil || page >= pageResult.Pages { + break + } + page++ + } +} + +func (s *BillingStatementEmailService) userLocation(ctx context.Context, userID int64) *time.Location { + if s == nil { + return time.Local + } + if s.settingRepo != nil { + if raw, err := s.settingRepo.GetValue(ctx, userTimezoneSettingKey(userID)); err == nil { + if loc, err := time.LoadLocation(strings.TrimSpace(raw)); err == nil && loc != nil { + return loc + } + } + } + if s.loc != nil { + return s.loc + } + return time.Local +} + +func billingStatementPeriodRange(kind string, now time.Time, loc *time.Location) (time.Time, time.Time) { + if loc == nil { + loc = time.Local + } + localNow := now.In(loc) + switch kind { + case "weekly": + end := startOfBillingStatementWeek(localNow, loc) + return end.AddDate(0, 0, -7), end + case "monthly": + end := time.Date(localNow.Year(), localNow.Month(), 1, 0, 0, 0, 0, loc) + return end.AddDate(0, -1, 0), end + default: + end := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, loc) + return end.AddDate(0, 0, -1), end + } +} + +func startOfBillingStatementWeek(t time.Time, loc *time.Location) time.Time { + if loc == nil { + loc = time.Local + } + t = t.In(loc) + weekday := int(t.Weekday()) + if weekday == 0 { + weekday = 7 + } + return time.Date(t.Year(), t.Month(), t.Day()-weekday+1, 0, 0, 0, 0, loc) +} + +// isUserPeriodEnabled checks whether the user has opted in for the given period kind. +func (s *BillingStatementEmailService) isUserPeriodEnabled(ctx context.Context, userID int64, kind string) bool { + raw, err := s.settingRepo.GetValue(ctx, billingStatementUserPreferenceSettingKey(userID)) + if err != nil || strings.TrimSpace(raw) == "" { + // Default: no explicit user opt-in, do not send. + return false + } + pref := ParseBillingStatementUserPreference(raw) + switch kind { + case "daily": + return pref.DailyEnabled + case "weekly": + return pref.WeeklyEnabled + case "monthly": + return pref.MonthlyEnabled + default: + return true + } +} + +func (s *BillingStatementEmailService) sendStatementToUser(ctx context.Context, user *User, periodName string, start, end time.Time, loc *time.Location) { + // Aggregate usage for this user in the time range + lines := s.aggregateUserUsage(ctx, user.ID, start, end) + if len(lines) == 0 { + // No usage in this period, skip sending + return + } + + var totalCost, actualCost, discount float64 + for _, l := range lines { + totalCost += l.TotalCost + actualCost += l.ActualCost + discount += l.Discount + } + + stmt := &BillingStatement{ + UserID: user.ID, + UserEmail: user.Email, + PeriodName: periodName, + Start: start, + End: end, + Timezone: billingStatementLocationName(loc), + Lines: lines, + TotalCost: totalCost, + ActualCost: actualCost, + Discount: discount, + Balance: user.Balance, + } + + subject := fmt.Sprintf("[%s] %s (%s ~ %s)", + "Sub2API", + periodName, + start.In(loc).Format("2006-01-02"), + end.In(loc).Format("2006-01-02"), + ) + + // Try to get site name + if siteName, err := s.settingRepo.GetValue(ctx, SettingKeySiteName); err == nil && strings.TrimSpace(siteName) != "" { + subject = fmt.Sprintf("[%s] %s (%s ~ %s)", + strings.TrimSpace(siteName), + periodName, + start.In(loc).Format("2006-01-02"), + end.In(loc).Format("2006-01-02"), + ) + } + + body := buildBillingStatementEmailHTML(stmt) + if err := s.emailService.SendEmail(ctx, user.Email, subject, body); err != nil { + log.Printf("[BillingStatement] send email to %s failed: %v", user.Email, err) + } +} + +func billingStatementLocationName(loc *time.Location) string { + if loc == nil { + return "UTC" + } + name := strings.TrimSpace(loc.String()) + if name == "" || name == "Local" { + return "UTC" + } + return name +} + +func (s *BillingStatementEmailService) aggregateUserUsage(ctx context.Context, userID int64, start, end time.Time) []BillingStatementLine { + // Use ListByUserAndTimeRange to get raw logs, then aggregate in-memory. + // This is the minimal viable approach using existing repository methods. + logs, _, err := s.usageRepo.ListByUserAndTimeRange(ctx, userID, start, end) + if err != nil { + log.Printf("[BillingStatement] query usage for user=%d error: %v", userID, err) + return nil + } + if len(logs) == 0 { + return nil + } + + type aggKey struct { + Model string + BillingMode string + GroupID int64 // 0 = nil + Subscription int64 // 0 = nil + } + + agg := make(map[aggKey]*BillingStatementLine) + for i := range logs { + l := &logs[i] + bm := "" + if l.BillingMode != nil { + bm = *l.BillingMode + } + var gid int64 + if l.GroupID != nil { + gid = *l.GroupID + } + var sid int64 + if l.SubscriptionID != nil { + sid = *l.SubscriptionID + } + key := aggKey{ + Model: l.Model, + BillingMode: bm, + GroupID: gid, + Subscription: sid, + } + entry, ok := agg[key] + if !ok { + entry = &BillingStatementLine{ + Model: l.Model, + BillingMode: bm, + } + if gid != 0 { + g := gid + entry.GroupID = &g + } + if sid != 0 { + ss := sid + entry.Subscription = &ss + } + agg[key] = entry + } + entry.Requests++ + entry.TotalTokens += int64(l.TotalTokens()) + entry.TotalCost += l.TotalCost + entry.ActualCost += l.ActualCost + entry.Discount += l.TotalCost - l.ActualCost + } + + result := make([]BillingStatementLine, 0, len(agg)) + for _, v := range agg { + result = append(result, *v) + } + s.hydrateBillingStatementGroupNames(ctx, result) + sort.SliceStable(result, func(i, j int) bool { + if result[i].Model != result[j].Model { + return result[i].Model < result[j].Model + } + if result[i].GroupName != result[j].GroupName { + return result[i].GroupName < result[j].GroupName + } + return result[i].BillingMode < result[j].BillingMode + }) + return result +} + +func (s *BillingStatementEmailService) hydrateBillingStatementGroupNames(ctx context.Context, lines []BillingStatementLine) { + if s == nil || s.groupRepo == nil || len(lines) == 0 { + return + } + cache := make(map[int64]string) + for i := range lines { + if lines[i].GroupID == nil { + continue + } + groupID := *lines[i].GroupID + if name, ok := cache[groupID]; ok { + lines[i].GroupName = name + continue + } + group, err := s.groupRepo.GetByIDLite(ctx, groupID) + if err != nil || group == nil || strings.TrimSpace(group.Name) == "" { + cache[groupID] = fmt.Sprintf("#%d", groupID) + } else { + cache[groupID] = strings.TrimSpace(group.Name) + } + lines[i].GroupName = cache[groupID] + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Email HTML template +// ───────────────────────────────────────────────────────────────────────────── + +func buildBillingStatementEmailHTML(stmt *BillingStatement) string { + if stmt == nil { + return "

无数据 / No data.

" + } + + var rows strings.Builder + for _, line := range stmt.Lines { + billingMode := billingStatementBillingModeLabel(line.BillingMode) + groupStr := "-" + if strings.TrimSpace(line.GroupName) != "" { + groupStr = strings.TrimSpace(line.GroupName) + } else if line.GroupID != nil { + groupStr = fmt.Sprintf("#%d", *line.GroupID) + } + subStr := "-" + if line.Subscription != nil { + subStr = fmt.Sprintf("#%d", *line.Subscription) + } + _, _ = rows.WriteString(fmt.Sprintf( + "%s%s%s%s%d%d$%.4f$%.4f$%.4f", + billingStatementHTMLEscape(line.Model), + billingStatementHTMLEscape(billingMode), + billingStatementHTMLEscape(groupStr), + billingStatementHTMLEscape(subStr), + line.Requests, + line.TotalTokens, + line.TotalCost, + line.ActualCost, + line.Discount, + )) + } + + return fmt.Sprintf(` + + + + + + +
+

%s

+
+

致 %s:
To %s:

+

时间段 / Period: %s ~ %s (%s)

+ + +%s +
模型 / Model计费模式 / Billing Mode分组 / Group订阅 / Subscription请求数 / RequestsToken 数 / Tokens标准价格 / Standard Price实际价格 / Actual Price优惠差额 / Discount
+
+

标准总价 / Total Cost: $%.4f

+

实际总价 / Actual Cost: $%.4f

+

优惠总额 / Total Discount: $%.4f

+

账户余额 / Balance: $%.4f

+
+
+ +
+ +`, + billingStatementBilingualTitleHTML(stmt.PeriodName), + billingStatementHTMLEscape(stmt.UserEmail), + billingStatementHTMLEscape(stmt.UserEmail), + billingStatementHTMLEscape(stmt.Start.Format("2006-01-02 15:04")), + billingStatementHTMLEscape(stmt.End.Format("2006-01-02 15:04")), + billingStatementHTMLEscape(stmt.Timezone), + rows.String(), + stmt.TotalCost, + stmt.ActualCost, + stmt.Discount, + stmt.Balance, + ) +} + +func billingStatementBillingModeLabel(mode string) string { + switch strings.TrimSpace(strings.ToLower(mode)) { + case "image": + return "图片 / Image" + case "token", "": + return "Token" + default: + return mode + } +} + +func billingStatementBilingualTitleHTML(title string) string { + parts := strings.SplitN(strings.TrimSpace(title), " / ", 2) + if len(parts) != 2 { + return billingStatementHTMLEscape(title) + } + return fmt.Sprintf("%s%s", + billingStatementHTMLEscape(parts[0]), + billingStatementHTMLEscape(parts[1]), + ) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +// isValidEmailForBilling returns true if the email is a real deliverable address. +func isValidEmailForBilling(email string) bool { + addr := strings.TrimSpace(email) + if addr == "" { + return false + } + if !strings.Contains(addr, "@") { + return false + } + // Skip synthetic/invalid emails from third-party auth + if strings.HasSuffix(addr, ".invalid") { + return false + } + return true +} + +func billingStatementHTMLEscape(s string) string { + replacer := strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + `"`, """, + ) + return replacer.Replace(s) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Distributed lock + last_run (same pattern as ops_scheduled_report_service) +// ───────────────────────────────────────────────────────────────────────────── + +func (s *BillingStatementEmailService) tryAcquireLeaderLock(ctx context.Context) (func(), bool) { + if s == nil || !s.distributedLockOn { + return nil, true + } + if s.redisClient == nil { + s.warnNoRedisOnce.Do(func() { + log.Printf("[BillingStatement] redis not configured; running without distributed lock") + }) + return nil, true + } + + ok, err := s.redisClient.SetNX(ctx, billingStatementLeaderLockKey, s.instanceID, billingStatementLeaderLockTTL) + if err != nil { + log.Printf("[BillingStatement] leader lock SetNX failed; skipping: %v", err) + return nil, false + } + if !ok { + return nil, false + } + return func() { + _ = s.redisClient.ReleaseIfValue(ctx, billingStatementLeaderLockKey, s.instanceID) + }, true +} + +func (s *BillingStatementEmailService) getLastRunAt(ctx context.Context, kind string) time.Time { + if s == nil || s.redisClient == nil { + return time.Time{} + } + key := billingStatementLastRunKeyPrefix + strings.TrimSpace(kind) + raw, err := s.redisClient.Get(ctx, key) + if err != nil || strings.TrimSpace(raw) == "" { + return time.Time{} + } + sec, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil || sec <= 0 { + return time.Time{} + } + last := time.Unix(sec, 0) + if s.loc != nil { + return last.In(s.loc) + } + return last.UTC() +} + +func (s *BillingStatementEmailService) setLastRunAt(ctx context.Context, kind string, t time.Time) { + if s == nil || s.redisClient == nil { + return + } + key := billingStatementLastRunKeyPrefix + strings.TrimSpace(kind) + if t.IsZero() { + t = time.Now().UTC() + } + _ = s.redisClient.Set(ctx, key, strconv.FormatInt(t.UTC().Unix(), 10), 35*24*time.Hour) +} diff --git a/backend/internal/service/billing_statement_email_service_test.go b/backend/internal/service/billing_statement_email_service_test.go new file mode 100644 index 00000000000..63b070ffc58 --- /dev/null +++ b/backend/internal/service/billing_statement_email_service_test.go @@ -0,0 +1,133 @@ +package service + +import ( + "testing" + "time" +) + +func TestParseBillingStatementEmailConfig_Empty(t *testing.T) { + cfg := ParseBillingStatementEmailConfig("") + def := DefaultBillingStatementEmailConfig() + if cfg.Enabled != def.Enabled { + t.Errorf("expected Enabled=%v, got %v", def.Enabled, cfg.Enabled) + } + if cfg.DailySchedule != def.DailySchedule { + t.Errorf("expected DailySchedule=%q, got %q", def.DailySchedule, cfg.DailySchedule) + } +} + +func TestParseBillingStatementEmailConfig_Valid(t *testing.T) { + raw := `{"enabled":true,"daily_enabled":true,"weekly_enabled":false,"monthly_enabled":true,"daily_schedule":"30 9 * * *","weekly_schedule":"0 8 * * 1","monthly_schedule":"0 8 1 * *"}` + cfg := ParseBillingStatementEmailConfig(raw) + if !cfg.Enabled { + t.Error("expected Enabled=true") + } + if !cfg.DailyEnabled { + t.Error("expected DailyEnabled=true") + } + if cfg.WeeklyEnabled { + t.Error("expected WeeklyEnabled=false") + } + if !cfg.MonthlyEnabled { + t.Error("expected MonthlyEnabled=true") + } + if cfg.DailySchedule != "30 9 * * *" { + t.Errorf("expected DailySchedule='30 9 * * *', got %q", cfg.DailySchedule) + } +} + +func TestParseBillingStatementEmailConfig_Invalid(t *testing.T) { + cfg := ParseBillingStatementEmailConfig("{invalid json") + def := DefaultBillingStatementEmailConfig() + if cfg.Enabled != def.Enabled { + t.Errorf("expected fallback to default on invalid JSON") + } +} + +func TestIsValidEmailForBilling(t *testing.T) { + tests := []struct { + email string + want bool + }{ + {"user@example.com", true}, + {"", false}, + {"noatsign", false}, + {"user@linuxdo-connect.invalid", false}, + {"user@oidc-connect.invalid", false}, + {"user@wechat-connect.invalid", false}, + {"admin@company.org", true}, + } + for _, tt := range tests { + got := isValidEmailForBilling(tt.email) + if got != tt.want { + t.Errorf("isValidEmailForBilling(%q) = %v, want %v", tt.email, got, tt.want) + } + } +} + +func TestBuildBillingStatementEmailHTML_Nil(t *testing.T) { + html := buildBillingStatementEmailHTML(nil) + if html != "

无数据 / No data.

" { + t.Errorf("expected no-data HTML for nil statement") + } +} + +func TestBuildBillingStatementEmailHTML_Basic(t *testing.T) { + gid := int64(1) + stmt := &BillingStatement{ + UserID: 1, + UserEmail: "test@example.com", + PeriodName: "日账单", + Start: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + End: time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), + Lines: []BillingStatementLine{ + { + Model: "claude-sonnet-4-20250514", + BillingMode: "token", + GroupID: &gid, + Requests: 10, + TotalTokens: 50000, + TotalCost: 1.5, + ActualCost: 1.2, + Discount: 0.3, + }, + }, + TotalCost: 1.5, + ActualCost: 1.2, + Discount: 0.3, + Balance: 8.5, + } + html := buildBillingStatementEmailHTML(stmt) + if html == "" { + t.Error("expected non-empty HTML") + } + // Check key content is present + if !containsStr(html, "日账单") { + t.Error("expected period name in HTML") + } + if !containsStr(html, "致 test@example.com") || !containsStr(html, "To test@example.com") { + t.Error("expected recipient greeting in HTML") + } + if !containsStr(html, "claude-sonnet-4-20250514") { + t.Error("expected model name in HTML") + } + if !containsStr(html, "$1.5000") { + t.Error("expected total cost in HTML") + } + if !containsStr(html, "$8.5000") { + t.Error("expected balance in HTML") + } +} + +func containsStr(s, substr string) bool { + return len(s) > 0 && len(substr) > 0 && (s == substr || len(s) > len(substr) && findSubstr(s, substr)) +} + +func findSubstr(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 8eb90a6ba24..a921facb1f0 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -382,6 +382,15 @@ const ( // Web Search Emulation SettingKeyWebSearchEmulationConfig = "web_search_emulation_config" // JSON 配置 + + // User Timezone Preference (用户时区偏好) + SettingKeyUserTimezonePrefix = "user_timezone:" // + userID + + // Billing Statement Email (账单邮件) + SettingKeyBillingStatementEmailConfig = "billing_statement_email_config" // JSON 配置 + + // Billing Statement User Preference (用户账单邮件偏好) + SettingKeyBillingStatementUserPreferencePrefix = "billing_statement_user_pref:" // + userID ) // AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys). diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 283a239b8d1..8e6cc376dc2 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -19,6 +19,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/imroc/req/v3" "golang.org/x/sync/singleflight" ) @@ -627,6 +628,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyAvailableChannelsEnabled, SettingKeyAffiliateEnabled, SettingKeyRiskControlEnabled, + SettingKeyBillingStatementEmailConfig, } settings, err := s.settingRepo.GetMultiple(ctx, keys) @@ -677,6 +679,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 { balanceLowNotifyThreshold = v } + billingStatementCfg := ParseBillingStatementEmailConfig(settings[SettingKeyBillingStatementEmailConfig]) + billingStatementEnabled := billingStatementCfg.Enabled return &PublicSettings{ RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", @@ -728,13 +732,29 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]), AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true", - - AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true", - - RiskControlEnabled: settings[SettingKeyRiskControlEnabled] == "true", + AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true", + RiskControlEnabled: settings[SettingKeyRiskControlEnabled] == "true", + + BillingStatementEmailEnabled: billingStatementEnabled, + BillingStatementDailyEnabled: billingStatementEnabled && billingStatementCfg.DailyEnabled, + BillingStatementWeeklyEnabled: billingStatementEnabled && billingStatementCfg.WeeklyEnabled, + BillingStatementMonthlyEnabled: billingStatementEnabled && billingStatementCfg.MonthlyEnabled, + ServerTimezone: defaultPublicServerTimezone(), }, nil } +func defaultPublicServerTimezone() string { + if name := strings.TrimSpace(timezone.Name()); name != "" && name != "Local" { + return name + } + if loc := timezone.Location(); loc != nil { + if name := strings.TrimSpace(loc.String()); name != "" && name != "Local" { + return name + } + } + return "UTC" +} + // channelMonitorIntervalMin / channelMonitorIntervalMax bound the default interval // (mirrors the monitor-level constraint but lives here so setting_service stays decoupled). const ( @@ -882,11 +902,16 @@ type PublicSettingsInjectionPayload struct { // Feature flags — MUST match the opt-in/opt-out registry in // frontend/src/utils/featureFlags.ts. Missing a field here is the bug // that hid the "可用渠道" menu on page refresh. - ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` - ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` - AvailableChannelsEnabled bool `json:"available_channels_enabled"` - AffiliateEnabled bool `json:"affiliate_enabled"` - RiskControlEnabled bool `json:"risk_control_enabled"` + ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` + ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` + AvailableChannelsEnabled bool `json:"available_channels_enabled"` + AffiliateEnabled bool `json:"affiliate_enabled"` + RiskControlEnabled bool `json:"risk_control_enabled"` + BillingStatementEmailEnabled bool `json:"billing_statement_email_enabled"` + BillingStatementDailyEnabled bool `json:"billing_statement_daily_enabled"` + BillingStatementWeeklyEnabled bool `json:"billing_statement_weekly_enabled"` + BillingStatementMonthlyEnabled bool `json:"billing_statement_monthly_enabled"` + ServerTimezone string `json:"server_timezone"` } // GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection. @@ -948,6 +973,11 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any AvailableChannelsEnabled: settings.AvailableChannelsEnabled, AffiliateEnabled: settings.AffiliateEnabled, RiskControlEnabled: settings.RiskControlEnabled, + BillingStatementEmailEnabled: settings.BillingStatementEmailEnabled, + BillingStatementDailyEnabled: settings.BillingStatementDailyEnabled, + BillingStatementWeeklyEnabled: settings.BillingStatementWeeklyEnabled, + BillingStatementMonthlyEnabled: settings.BillingStatementMonthlyEnabled, + ServerTimezone: settings.ServerTimezone, }, nil } @@ -1597,6 +1627,10 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyAccountQuotaNotifyEnabled] = strconv.FormatBool(settings.AccountQuotaNotifyEnabled) updates[SettingKeyAccountQuotaNotifyEmails] = MarshalNotifyEmails(settings.AccountQuotaNotifyEmails) + if settings.BillingStatementEmailConfig != "" { + updates[SettingKeyBillingStatementEmailConfig] = settings.BillingStatementEmailConfig + } + return updates, nil } @@ -2764,6 +2798,8 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin result.AccountQuotaNotifyEmails = []NotifyEmailEntry{} } + result.BillingStatementEmailConfig = settings[SettingKeyBillingStatementEmailConfig] + return result } diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 80b8b32a901..a02be6febf1 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -193,6 +193,9 @@ type SystemSettings struct { // Account quota notification AccountQuotaNotifyEnabled bool AccountQuotaNotifyEmails []NotifyEmailEntry + + // Billing statement email config (JSON) + BillingStatementEmailConfig string } type DefaultSubscriptionSetting struct { @@ -262,6 +265,14 @@ type PublicSettings struct { // 风控中心功能开关 RiskControlEnabled bool `json:"risk_control_enabled"` + + // User-facing billing statement email availability. These expose only whether + // each period is globally available, not the admin cron schedule. + BillingStatementEmailEnabled bool `json:"billing_statement_email_enabled"` + BillingStatementDailyEnabled bool `json:"billing_statement_daily_enabled"` + BillingStatementWeeklyEnabled bool `json:"billing_statement_weekly_enabled"` + BillingStatementMonthlyEnabled bool `json:"billing_statement_monthly_enabled"` + ServerTimezone string `json:"server_timezone"` } type LoginAgreementDocument struct { diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go index f98336111ba..d3a523976fd 100644 --- a/backend/internal/service/user.go +++ b/backend/internal/service/user.go @@ -58,6 +58,12 @@ type User struct { // 避免每请求查 DB。字段不持久化到数据库。 UserGroupRPMOverride *int + // 账单邮件偏好(不持久化到 users 表,从 setting 表 hydrate) + BillingStatementDailyEnabled bool + BillingStatementWeeklyEnabled bool + BillingStatementMonthlyEnabled bool + Timezone string + APIKeys []APIKey Subscriptions []UserSubscription } diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index f84e6f0ab06..f22ed9c3346 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -7,6 +7,7 @@ import ( "crypto/subtle" "encoding/base64" "encoding/hex" + "encoding/json" "fmt" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" @@ -24,6 +25,7 @@ import ( "sync" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" xdraw "golang.org/x/image/draw" "golang.org/x/sync/singleflight" ) @@ -173,6 +175,12 @@ type UpdateProfileRequest struct { Concurrency *int `json:"concurrency"` BalanceNotifyEnabled *bool `json:"balance_notify_enabled"` BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"` + Timezone *string `json:"timezone"` + + // Billing statement email preferences + BillingStatementDailyEnabled *bool `json:"billing_statement_daily_enabled"` + BillingStatementWeeklyEnabled *bool `json:"billing_statement_weekly_enabled"` + BillingStatementMonthlyEnabled *bool `json:"billing_statement_monthly_enabled"` } type UserAvatar struct { @@ -242,6 +250,8 @@ func (s *UserService) GetProfile(ctx context.Context, userID int64) (*User, erro if err := s.hydrateUserAvatar(ctx, user); err != nil { return nil, fmt.Errorf("get user avatar: %w", err) } + s.hydrateBillingStatementPreference(ctx, user) + s.hydrateUserTimezone(ctx, user) return user, nil } @@ -454,10 +464,147 @@ func (s *UserService) updateProfile(ctx context.Context, userID int64, req Updat if err := s.userRepo.Update(ctx, user); err != nil { return nil, oldConcurrency, fmt.Errorf("update user: %w", err) } + if req.Timezone != nil { + if err := s.updateUserTimezone(ctx, userID, *req.Timezone); err != nil { + return nil, oldConcurrency, fmt.Errorf("update user timezone: %w", err) + } + } + + // Update billing statement preferences if any field is provided + if req.BillingStatementDailyEnabled != nil || req.BillingStatementWeeklyEnabled != nil || req.BillingStatementMonthlyEnabled != nil { + if err := s.updateBillingStatementPreference(ctx, userID, req); err != nil { + return nil, oldConcurrency, fmt.Errorf("update billing statement preference: %w", err) + } + } + s.hydrateBillingStatementPreference(ctx, user) + s.hydrateUserTimezone(ctx, user) return user, oldConcurrency, nil } +// hydrateBillingStatementPreference reads the user's billing statement preference from settings +// and populates the User struct fields. Defaults to all-disabled if not found. +func (s *UserService) hydrateBillingStatementPreference(ctx context.Context, user *User) { + if user == nil || s.settingRepo == nil { + return + } + key := SettingKeyBillingStatementUserPreferencePrefix + strconv.FormatInt(user.ID, 10) + raw, err := s.settingRepo.GetValue(ctx, key) + if err != nil || strings.TrimSpace(raw) == "" { + pref := DefaultBillingStatementUserPreference() + user.BillingStatementDailyEnabled = pref.DailyEnabled + user.BillingStatementWeeklyEnabled = pref.WeeklyEnabled + user.BillingStatementMonthlyEnabled = pref.MonthlyEnabled + return + } + pref := ParseBillingStatementUserPreference(raw) + user.BillingStatementDailyEnabled = pref.DailyEnabled + user.BillingStatementWeeklyEnabled = pref.WeeklyEnabled + user.BillingStatementMonthlyEnabled = pref.MonthlyEnabled +} + +// updateBillingStatementPreference updates the user's billing statement preference in settings. +func (s *UserService) updateBillingStatementPreference(ctx context.Context, userID int64, req UpdateProfileRequest) error { + if s.settingRepo == nil { + return nil + } + key := SettingKeyBillingStatementUserPreferencePrefix + strconv.FormatInt(userID, 10) + adminCfg, err := s.settingRepo.GetValue(ctx, SettingKeyBillingStatementEmailConfig) + if err == nil { + cfg := ParseBillingStatementEmailConfig(adminCfg) + if !cfg.Enabled { + // Global feature disabled: keep stored user preference untouched. + return nil + } + if req.BillingStatementDailyEnabled != nil && !cfg.DailyEnabled { + req.BillingStatementDailyEnabled = nil + } + if req.BillingStatementWeeklyEnabled != nil && !cfg.WeeklyEnabled { + req.BillingStatementWeeklyEnabled = nil + } + if req.BillingStatementMonthlyEnabled != nil && !cfg.MonthlyEnabled { + req.BillingStatementMonthlyEnabled = nil + } + } + + // Read existing preference + raw, _ := s.settingRepo.GetValue(ctx, key) + pref := ParseBillingStatementUserPreference(raw) + + // Apply updates + if req.BillingStatementDailyEnabled != nil { + pref.DailyEnabled = *req.BillingStatementDailyEnabled + } + if req.BillingStatementWeeklyEnabled != nil { + pref.WeeklyEnabled = *req.BillingStatementWeeklyEnabled + } + if req.BillingStatementMonthlyEnabled != nil { + pref.MonthlyEnabled = *req.BillingStatementMonthlyEnabled + } + + // Serialize and save + data, err := json.Marshal(pref) + if err != nil { + return fmt.Errorf("marshal billing statement preference: %w", err) + } + return s.settingRepo.Set(ctx, key, string(data)) +} + +func userTimezoneSettingKey(userID int64) string { + return SettingKeyUserTimezonePrefix + strconv.FormatInt(userID, 10) +} + +func defaultUserTimezone() string { + if name := strings.TrimSpace(timezone.Name()); name != "" && name != "Local" { + return name + } + if loc := timezone.Location(); loc != nil { + if name := strings.TrimSpace(loc.String()); name != "" && name != "Local" { + return name + } + } + return "UTC" +} + +func normalizeUserTimezone(value string) (string, error) { + tz := strings.TrimSpace(value) + if tz == "" { + return defaultUserTimezone(), nil + } + if _, err := time.LoadLocation(tz); err != nil { + return "", infraerrors.BadRequest("USER_TIMEZONE_INVALID", "timezone must be a valid IANA timezone name") + } + return tz, nil +} + +func (s *UserService) hydrateUserTimezone(ctx context.Context, user *User) { + if user == nil { + return + } + user.Timezone = defaultUserTimezone() + if s.settingRepo == nil { + return + } + raw, err := s.settingRepo.GetValue(ctx, userTimezoneSettingKey(user.ID)) + if err != nil || strings.TrimSpace(raw) == "" { + return + } + if tz, err := normalizeUserTimezone(raw); err == nil { + user.Timezone = tz + } +} + +func (s *UserService) updateUserTimezone(ctx context.Context, userID int64, value string) error { + if s.settingRepo == nil { + return nil + } + tz, err := normalizeUserTimezone(value) + if err != nil { + return err + } + return s.settingRepo.Set(ctx, userTimezoneSettingKey(userID), tz) +} + func (s *UserService) SetAvatar(ctx context.Context, userID int64, raw string) (*UserAvatar, error) { avatarValue := strings.TrimSpace(raw) if avatarValue == "" { @@ -951,6 +1098,8 @@ func (s *UserService) GetByID(ctx context.Context, id int64) (*User, error) { if err := s.hydrateUserAvatar(ctx, user); err != nil { return nil, fmt.Errorf("get user avatar: %w", err) } + s.hydrateBillingStatementPreference(ctx, user) + s.hydrateUserTimezone(ctx, user) return user, nil } diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index dc96be0c063..f98b4b4c15e 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -515,6 +515,7 @@ var ProviderSet = wire.NewSet( NewPaymentService, ProvidePaymentOrderExpiryService, ProvideBalanceNotifyService, + ProvideBillingStatementEmailService, ProvideChannelMonitorService, ProvideChannelMonitorRunner, NewChannelMonitorRequestTemplateService, @@ -547,6 +548,60 @@ func ProvideChannelMonitorService( return NewChannelMonitorService(repo, encryptor) } +// ProvideBillingStatementEmailService creates and starts BillingStatementEmailService. +func ProvideBillingStatementEmailService( + settingRepo SettingRepository, + userRepo UserRepository, + groupRepo GroupRepository, + usageRepo UsageLogRepository, + emailService *EmailService, + redisClient *redis.Client, + cfg *config.Config, +) *BillingStatementEmailService { + svc := NewBillingStatementEmailService(settingRepo, userRepo, groupRepo, usageRepo, emailService, billingStatementRedisAdapter{client: redisClient}, cfg) + svc.Start() + return svc +} + +type billingStatementRedisAdapter struct { + client *redis.Client +} + +var billingStatementReleaseScript = redis.NewScript(` +if redis.call("GET", KEYS[1]) == ARGV[1] then + return redis.call("DEL", KEYS[1]) +end +return 0 +`) + +func (a billingStatementRedisAdapter) SetNX(ctx context.Context, key string, value any, expiration time.Duration) (bool, error) { + if a.client == nil { + return false, nil + } + return a.client.SetNX(ctx, key, value, expiration).Result() +} + +func (a billingStatementRedisAdapter) Get(ctx context.Context, key string) (string, error) { + if a.client == nil { + return "", nil + } + return a.client.Get(ctx, key).Result() +} + +func (a billingStatementRedisAdapter) Set(ctx context.Context, key string, value any, expiration time.Duration) error { + if a.client == nil { + return nil + } + return a.client.Set(ctx, key, value, expiration).Err() +} + +func (a billingStatementRedisAdapter) ReleaseIfValue(ctx context.Context, key string, value string) error { + if a.client == nil { + return nil + } + return billingStatementReleaseScript.Run(ctx, a.client, []string{key}, value).Err() +} + // ProvideChannelMonitorRunner 创建并启动渠道监控调度器。 // 通过 SetScheduler 注入回 service 后再 Start,确保启动时加载所有 enabled monitor, // 后续 CRUD 也能即时同步任务表。Runner.Stop 由 cleanup function 调用。 diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 01d6969db5b..43a66db4143 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -526,6 +526,9 @@ export interface SystemSettings { // OpenAI fast/flex policy openai_fast_policy_settings?: OpenAIFastPolicySettings; + + // Billing statement email config + billing_statement_email_config?: string; } export interface UpdateSettingsRequest { @@ -719,6 +722,9 @@ export interface UpdateSettingsRequest { // OpenAI fast/flex policy openai_fast_policy_settings?: OpenAIFastPolicySettings; + + // Billing statement email config + billing_statement_email_config?: string; } /** diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index da7a91eb46c..07b44cc6854 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -38,6 +38,10 @@ export async function updateProfile(profile: { balance_notify_enabled?: boolean balance_notify_threshold?: number | null balance_notify_extra_emails?: NotifyEmailEntry[] + timezone?: string + billing_statement_daily_enabled?: boolean + billing_statement_weekly_enabled?: boolean + billing_statement_monthly_enabled?: boolean }): Promise { const { data } = await apiClient.put('/user', profile) return data diff --git a/frontend/src/components/account/QuotaDimensionRow.vue b/frontend/src/components/account/QuotaDimensionRow.vue index e7fe2d0bc8d..d44c0c889a5 100644 --- a/frontend/src/components/account/QuotaDimensionRow.vue +++ b/frontend/src/components/account/QuotaDimensionRow.vue @@ -2,6 +2,7 @@ import { useI18n } from 'vue-i18n' import QuotaNotifyToggle from './QuotaNotifyToggle.vue' import type { QuotaThresholdType, QuotaResetMode } from '@/constants/account' +import { getTimezoneOffsetLabel } from '@/constants/timezone' const { t } = useI18n() @@ -54,16 +55,6 @@ const onModeChange = (e: Event) => { } } -function getTimezoneOffsetLabel(tz: string): string { - try { - const dtf = new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'shortOffset' }) - const parts = dtf.formatToParts(new Date()) - const tzPart = parts.find(p => p.type === 'timeZoneName') - return tzPart ? (tzPart.value === 'GMT' ? 'GMT+0' : tzPart.value) : '' - } catch { - return '' - } -}