From 034795e9ac53c84814887f85291f974cf198c6c2 Mon Sep 17 00:00:00 2001 From: Qi HU Date: Wed, 6 May 2026 15:28:25 +0800 Subject: [PATCH 1/2] feat: add user timezone preferences --- backend/internal/handler/dto/mappers.go | 1 + backend/internal/handler/dto/settings.go | 1 + backend/internal/handler/dto/types.go | 3 +- backend/internal/handler/setting_handler.go | 1 + backend/internal/handler/user_handler.go | 2 + backend/internal/server/api_contract_test.go | 1 + backend/internal/service/domain_constants.go | 3 + backend/internal/service/setting_service.go | 19 +++++- backend/internal/service/settings_view.go | 1 + backend/internal/service/user.go | 1 + backend/internal/service/user_service.go | 64 +++++++++++++++++++ frontend/src/api/user.ts | 1 + .../components/account/QuotaDimensionRow.vue | 11 +--- .../src/components/account/QuotaLimitCard.vue | 9 +-- .../user/profile/ProfileEditForm.vue | 47 +++++++++++++- .../user/profile/ProfileInfoCard.vue | 4 ++ frontend/src/constants/timezone.ts | 32 ++++++++++ frontend/src/i18n/locales/en.ts | 2 + frontend/src/i18n/locales/zh.ts | 2 + frontend/src/stores/app.ts | 1 + frontend/src/types/index.ts | 2 + frontend/src/views/user/ProfileView.vue | 3 + 22 files changed, 189 insertions(+), 22 deletions(-) create mode 100644 frontend/src/constants/timezone.ts diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 2559b112cb9..16b5921b5d6 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -30,6 +30,7 @@ func UserFromServiceShallow(u *service.User) *User { BalanceNotifyExtraEmails: NotifyEmailEntriesFromService(u.BalanceNotifyExtraEmails), TotalRecharged: u.TotalRecharged, RPMLimit: u.RPMLimit, + Timezone: u.Timezone, } } diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 2d4cefa1595..6881c50a0c9 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -284,6 +284,7 @@ type PublicSettings struct { AffiliateEnabled bool `json:"affiliate_enabled"` RiskControlEnabled bool `json:"risk_control_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..1e6fd8b81ab 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -27,7 +27,8 @@ 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"` 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..2213f6067b3 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -86,6 +86,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { AffiliateEnabled: settings.AffiliateEnabled, RiskControlEnabled: settings.RiskControlEnabled, + ServerTimezone: settings.ServerTimezone, }) } diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 3f6ed8c2bcc..1e7d0b7368d 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -50,6 +50,7 @@ type UpdateProfileRequest struct { AvatarURL *string `json:"avatar_url"` BalanceNotifyEnabled *bool `json:"balance_notify_enabled"` BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"` + Timezone *string `json:"timezone"` } type userProfileResponse struct { @@ -146,6 +147,7 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) { AvatarURL: req.AvatarURL, BalanceNotifyEnabled: req.BalanceNotifyEnabled, BalanceNotifyThreshold: req.BalanceNotifyThreshold, + 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..91a03ced378 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -65,6 +65,7 @@ func TestAPIContracts(t *testing.T) { "balance_notify_threshold": null, "balance_notify_extra_emails": null, "total_recharged": 0, + "timezone": "UTC", "linuxdo_bound": false, "oidc_bound": false, "wechat_bound": false, diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 8eb90a6ba24..fdc0dd184bc 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -382,6 +382,9 @@ const ( // Web Search Emulation SettingKeyWebSearchEmulationConfig = "web_search_emulation_config" // JSON 配置 + + // User Timezone Preference (用户时区偏好) + SettingKeyUserTimezonePrefix = "user_timezone:" // + 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..163727176d1 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" ) @@ -729,12 +730,24 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true", - AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true", - + AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true", RiskControlEnabled: settings[SettingKeyRiskControlEnabled] == "true", + 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 ( @@ -887,6 +900,7 @@ type PublicSettingsInjectionPayload struct { AvailableChannelsEnabled bool `json:"available_channels_enabled"` AffiliateEnabled bool `json:"affiliate_enabled"` RiskControlEnabled bool `json:"risk_control_enabled"` + ServerTimezone string `json:"server_timezone"` } // GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection. @@ -948,6 +962,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any AvailableChannelsEnabled: settings.AvailableChannelsEnabled, AffiliateEnabled: settings.AffiliateEnabled, RiskControlEnabled: settings.RiskControlEnabled, + ServerTimezone: settings.ServerTimezone, }, nil } diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 80b8b32a901..3e2a57aa124 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -262,6 +262,7 @@ type PublicSettings struct { // 风控中心功能开关 RiskControlEnabled bool `json:"risk_control_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..317440127f3 100644 --- a/backend/internal/service/user.go +++ b/backend/internal/service/user.go @@ -57,6 +57,7 @@ type User struct { // nil = 该 API Key 对应的 (user, group) 无 override;非 nil 时 checkRPM 直接使用, // 避免每请求查 DB。字段不持久化到数据库。 UserGroupRPMOverride *int + Timezone string APIKeys []APIKey Subscriptions []UserSubscription diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index f84e6f0ab06..ea627e23f9a 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -24,6 +24,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 +174,7 @@ type UpdateProfileRequest struct { Concurrency *int `json:"concurrency"` BalanceNotifyEnabled *bool `json:"balance_notify_enabled"` BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"` + Timezone *string `json:"timezone"` } type UserAvatar struct { @@ -242,6 +244,7 @@ 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.hydrateUserTimezone(ctx, user) return user, nil } @@ -454,10 +457,71 @@ 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) + } + } + s.hydrateUserTimezone(ctx, user) return user, oldConcurrency, nil } +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 == "" { diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index da7a91eb46c..97dd9469663 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -38,6 +38,7 @@ export async function updateProfile(profile: { balance_notify_enabled?: boolean balance_notify_threshold?: number | null balance_notify_extra_emails?: NotifyEmailEntry[] + timezone?: string }): 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 '' - } -} diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index e85f8681e5e..54d5601fcfe 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1099,7 +1099,7 @@ export default { user: 'User', username: 'Username', timezone: 'Timezone', - timezoneHelp: 'Used to display data in your local time. Leave empty to use server timezone: {timezone}', + timezoneHelp: 'Used for billing statement periods and timestamps. Leave empty to use server timezone: {timezone}', email: 'Email', status: 'Status', role: 'Role', @@ -1192,6 +1192,15 @@ export default { unverified: 'Unverified', verified: 'Verified', }, + billingStatement: { + title: 'Billing Statement Email', + description: 'Choose which billing statement emails to receive', + dailyEnabled: 'Receive Daily Statement', + weeklyEnabled: 'Receive Weekly Statement', + monthlyEnabled: 'Receive Monthly Statement', + saved: 'Billing statement preferences saved', + saveFailed: 'Failed to save billing statement preferences', + }, avatar: { title: 'Profile Avatar', description: 'Upload an avatar image. Static uploads are compressed to 20KB before saving.', @@ -5614,6 +5623,18 @@ export default { addEmail: 'Add Email', emailPlaceholder: 'Enter email address', }, + billingStatement: { + title: 'Billing Statement Email', + description: 'Send periodic billing statement emails to users with model usage, costs, and balance', + enabled: 'Enable Billing Statement Email', + dailyEnabled: 'Enable Daily Statement', + weeklyEnabled: 'Enable Weekly Statement', + monthlyEnabled: 'Enable Monthly Statement', + dailySchedule: 'Daily Schedule (Cron)', + weeklySchedule: 'Weekly Schedule (Cron)', + monthlySchedule: 'Monthly Schedule (Cron)', + scheduleHint: 'Cron expression (5-field), e.g. "0 8 * * *" means every day at 08:00', + }, smtp: { title: 'SMTP Settings', description: 'Configure email sending for verification codes', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 78eae3bafe9..f71b0e23438 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1103,7 +1103,7 @@ export default { user: '用户', username: '用户名', timezone: '时区', - timezoneHelp: '用于按你的本地时间展示数据。留空时使用服务器时区:{timezone}', + timezoneHelp: '用于账单邮件周期和时间显示。留空时使用服务器时区:{timezone}', email: '邮箱', status: '状态', role: '角色', @@ -1196,6 +1196,15 @@ export default { unverified: '未验证', verified: '已验证', }, + billingStatement: { + title: '账单邮件偏好', + description: '选择接收哪些周期的账单邮件', + dailyEnabled: '接收日账单', + weeklyEnabled: '接收周账单', + monthlyEnabled: '接收月账单', + saved: '账单邮件偏好已保存', + saveFailed: '保存账单邮件偏好失败', + }, avatar: { title: '资料头像', description: '仅支持上传头像图片;静态图片会自动压缩到 20KB 以内后再保存。', @@ -5775,6 +5784,18 @@ export default { addEmail: '添加邮箱', emailPlaceholder: '输入邮箱地址', }, + billingStatement: { + title: '账单邮件提醒', + description: '定期向用户发送使用账单邮件,包含模型用量、费用和余额信息', + enabled: '启用账单邮件提醒', + dailyEnabled: '启用日账单', + weeklyEnabled: '启用周账单', + monthlyEnabled: '启用月账单', + dailySchedule: '日账单发送时间(Cron)', + weeklySchedule: '周账单发送时间(Cron)', + monthlySchedule: '月账单发送时间(Cron)', + scheduleHint: 'Cron 表达式,5 字段格式,如 "0 8 * * *" 表示每天 08:00', + }, smtp: { title: 'SMTP 设置', description: '配置用于发送验证码的邮件服务', diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index c1e593f4bb8..1ea0c0da9f4 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -359,6 +359,10 @@ export const useAppStore = defineStore('app', () => { available_channels_enabled: false, risk_control_enabled: false, affiliate_enabled: false, + billing_statement_email_enabled: false, + billing_statement_daily_enabled: false, + billing_statement_weekly_enabled: false, + billing_statement_monthly_enabled: false, server_timezone: 'UTC', } } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7050c950c87..59b07d06ccd 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -94,6 +94,10 @@ export interface User { balance_notify_threshold: number | null balance_notify_extra_emails: NotifyEmailEntry[] timezone: string + // Billing statement email preferences + billing_statement_daily_enabled: boolean + billing_statement_weekly_enabled: boolean + billing_statement_monthly_enabled: boolean subscriptions?: UserSubscription[] // User's active subscriptions last_active_at?: string | null created_at: string @@ -233,6 +237,10 @@ export interface PublicSettings { channel_monitor_default_interval_seconds: number available_channels_enabled: boolean affiliate_enabled: boolean + billing_statement_email_enabled: boolean + billing_statement_daily_enabled: boolean + billing_statement_weekly_enabled: boolean + billing_statement_monthly_enabled: boolean server_timezone: string } diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 26a88bc2625..487b820af3c 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -5974,6 +5974,61 @@ + + +
+
+

+ {{ t('admin.settings.billingStatement.title') }} +

+

+ {{ t('admin.settings.billingStatement.description') }} +

+
+
+
+ + +
+ +
+
@@ -6498,6 +6553,17 @@ const form = reactive({ affiliate_enabled: false, }); +// Billing statement email form +const billingStatementForm = reactive({ + enabled: false, + daily_enabled: false, + weekly_enabled: false, + monthly_enabled: false, + daily_schedule: '0 8 * * *', + weekly_schedule: '0 8 * * 1', + monthly_schedule: '0 8 1 * *', +}) + const authSourceDefaults = reactive( buildAuthSourceDefaultsState({}), ); @@ -7198,6 +7264,16 @@ async function loadSettings() { openaiFastPolicyLoaded.value = true; } + // Load billing statement email config + if (settings.billing_statement_email_config) { + try { + const bsCfg = JSON.parse(settings.billing_statement_email_config) + if (bsCfg && typeof bsCfg === 'object') { + Object.assign(billingStatementForm, bsCfg) + } + } catch { /* ignore parse errors */ } + } + // Load web search emulation config separately await loadWebSearchConfig(); } catch (error: unknown) { @@ -7599,6 +7675,15 @@ async function saveSettings() { available_channels_enabled: form.available_channels_enabled, // Affiliate (邀请返利) feature switch affiliate_enabled: form.affiliate_enabled, + billing_statement_email_config: JSON.stringify({ + enabled: billingStatementForm.enabled, + daily_enabled: billingStatementForm.daily_enabled, + weekly_enabled: billingStatementForm.weekly_enabled, + monthly_enabled: billingStatementForm.monthly_enabled, + daily_schedule: billingStatementForm.daily_schedule || '0 8 * * *', + weekly_schedule: billingStatementForm.weekly_schedule || '0 8 * * 1', + monthly_schedule: billingStatementForm.monthly_schedule || '0 8 1 * *', + }), }; // 仅当 openai_fast_policy_settings 已成功从后端加载时才回写, diff --git a/frontend/src/views/user/ProfileView.vue b/frontend/src/views/user/ProfileView.vue index d5b1dec238e..0b7a408e611 100644 --- a/frontend/src/views/user/ProfileView.vue +++ b/frontend/src/views/user/ProfileView.vue @@ -43,6 +43,16 @@ :user-email="user.email" /> + + @@ -54,6 +64,7 @@ import { useI18n } from 'vue-i18n' import { Icon } from '@/components/icons' import AppLayout from '@/components/layout/AppLayout.vue' import ProfileBalanceNotifyCard from '@/components/user/profile/ProfileBalanceNotifyCard.vue' +import ProfileBillingStatementCard from '@/components/user/profile/ProfileBillingStatementCard.vue' import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue' import ProfilePasswordForm from '@/components/user/profile/ProfilePasswordForm.vue' import ProfileTotpCard from '@/components/user/profile/ProfileTotpCard.vue' @@ -70,6 +81,10 @@ const contactInfo = ref('') const balanceLowNotifyEnabled = ref(false) const systemDefaultThreshold = ref(0) const serverTimezone = ref('UTC') +const billingStatementEmailEnabled = ref(false) +const billingStatementDailyEnabled = ref(false) +const billingStatementWeeklyEnabled = ref(false) +const billingStatementMonthlyEnabled = ref(false) const linuxdoOAuthEnabled = ref(false) const wechatOAuthEnabled = ref(false) const wechatOAuthOpenEnabled = ref(undefined) @@ -91,6 +106,10 @@ onMounted(async () => { balanceLowNotifyEnabled.value = settings.balance_low_notify_enabled ?? false systemDefaultThreshold.value = settings.balance_low_notify_threshold ?? 0 serverTimezone.value = settings.server_timezone || 'UTC' + billingStatementEmailEnabled.value = settings.billing_statement_email_enabled ?? false + billingStatementDailyEnabled.value = settings.billing_statement_daily_enabled ?? false + billingStatementWeeklyEnabled.value = settings.billing_statement_weekly_enabled ?? false + billingStatementMonthlyEnabled.value = settings.billing_statement_monthly_enabled ?? false linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled ?? false wechatOAuthEnabled.value = isWeChatWebOAuthEnabled(settings) wechatOAuthOpenEnabled.value = typeof settings.wechat_oauth_open_enabled === 'boolean'