From 20fa21a524ebca753e0bf670112b2481c4687d80 Mon Sep 17 00:00:00 2001 From: GodD6366 Date: Thu, 28 May 2026 13:37:38 +0800 Subject: [PATCH 1/3] feat: add reset_at_time option for temp unschedulable rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new 'reset_at_time' field to temp unschedulable rules, allowing accounts to be automatically恢复 at a specific time of day (e.g., 00:00 for daily reset) instead of using a fixed duration. Changes: - Backend: Add ResetAtTime field to TempUnschedulableRule struct - Backend: Update triggerTempUnschedulable to support time-based reset - Frontend: Add time picker with quick-fill button for daily reset - i18n: Add translations for the new field --- backend/internal/service/account.go | 11 +++- backend/internal/service/ratelimit_service.go | 32 ++++++++-- .../components/account/CreateAccountModal.vue | 57 +++++++++++++++--- .../components/account/EditAccountModal.vue | 60 ++++++++++++++++--- frontend/src/i18n/locales/en.ts | 6 +- frontend/src/i18n/locales/zh.ts | 6 +- frontend/src/types/index.ts | 4 ++ 7 files changed, 152 insertions(+), 24 deletions(-) diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index d488aa75fd1..a4902599955 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -71,6 +71,10 @@ type TempUnschedulableRule struct { Keywords []string `json:"keywords"` DurationMinutes int `json:"duration_minutes"` Description string `json:"description"` + // ResetAtTime 按时间点重置(格式:"HH:MM",如"00:00"表示每天凌晨0点重置) + // 设置后,临时不可调度状态将在下一个该时间点自动解除 + // 如果同时设置了DurationMinutes,则优先使用ResetAtTime + ResetAtTime string `json:"reset_at_time,omitempty"` } func (a *Account) IsActive() bool { @@ -312,9 +316,14 @@ func (a *Account) GetTempUnschedulableRules() []TempUnschedulableRule { Keywords: parseTempUnschedStrings(entry["keywords"]), DurationMinutes: parseTempUnschedInt(entry["duration_minutes"]), Description: parseTempUnschedString(entry["description"]), + ResetAtTime: parseTempUnschedString(entry["reset_at_time"]), } - if rule.ErrorCode <= 0 || rule.DurationMinutes <= 0 || len(rule.Keywords) == 0 { + // 验证:必须有错误码和关键词,且至少有duration_minutes或reset_at_time之一 + if rule.ErrorCode <= 0 || len(rule.Keywords) == 0 { + continue + } + if rule.DurationMinutes <= 0 && rule.ResetAtTime == "" { continue } diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index d12824eccff..399efdd2b89 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -1756,12 +1756,36 @@ func (s *RateLimitService) triggerTempUnschedulable(ctx context.Context, account if account == nil { return false } - if rule.DurationMinutes <= 0 { - return false - } now := time.Now() - until := now.Add(time.Duration(rule.DurationMinutes) * time.Minute) + var until time.Time + + // 优先使用ResetAtTime(按时间点重置) + if rule.ResetAtTime != "" { + // 解析时间点(格式:"HH:MM") + parts := strings.Split(rule.ResetAtTime, ":") + if len(parts) == 2 { + hour, err1 := strconv.Atoi(parts[0]) + minute, err2 := strconv.Atoi(parts[1]) + if err1 == nil && err2 == nil && hour >= 0 && hour < 24 && minute >= 0 && minute < 60 { + // 计算下一个该时间点 + nextReset := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location()) + if !nextReset.After(now) { + // 如果今天的时间点已过,则设置为明天 + nextReset = nextReset.AddDate(0, 0, 1) + } + until = nextReset + } + } + } + + // 如果ResetAtTime未设置或解析失败,则使用DurationMinutes + if until.IsZero() { + if rule.DurationMinutes <= 0 { + return false + } + until = now.Add(time.Duration(rule.DurationMinutes) * time.Minute) + } state := &TempUnschedState{ UntilUnix: until.Unix(), diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 331295f77ac..08a847f54dc 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2008,6 +2008,26 @@ class="input" :placeholder="t('admin.accounts.tempUnschedulable.durationPlaceholder')" /> +

{{ t('admin.accounts.tempUnschedulable.durationMinutesHint') }}

+ +
+ +
+ + +
+

{{ t('admin.accounts.tempUnschedulable.resetAtTimeHint') }}

@@ -3297,6 +3317,8 @@ interface TempUnschedRuleForm { keywords: string duration_minutes: number | null description: string + // 按时间点重置(格式:"HH:MM",如"00:00"表示每天凌晨0点重置) + reset_at_time?: string } // State @@ -3538,7 +3560,8 @@ const tempUnschedPresets = computed(() => [ error_code: 529, keywords: 'overloaded, too many', duration_minutes: 60, - description: t('admin.accounts.tempUnschedulable.presets.overloadDesc') + description: t('admin.accounts.tempUnschedulable.presets.overloadDesc'), + reset_at_time: '' } }, { @@ -3547,7 +3570,8 @@ const tempUnschedPresets = computed(() => [ error_code: 429, keywords: 'rate limit, too many requests', duration_minutes: 10, - description: t('admin.accounts.tempUnschedulable.presets.rateLimitDesc') + description: t('admin.accounts.tempUnschedulable.presets.rateLimitDesc'), + reset_at_time: '' } }, { @@ -3556,7 +3580,8 @@ const tempUnschedPresets = computed(() => [ error_code: 503, keywords: 'unavailable, maintenance', duration_minutes: 30, - description: t('admin.accounts.tempUnschedulable.presets.unavailableDesc') + description: t('admin.accounts.tempUnschedulable.presets.unavailableDesc'), + reset_at_time: '' } } ]) @@ -3898,7 +3923,8 @@ const addTempUnschedRule = (preset?: TempUnschedRuleForm) => { error_code: null, keywords: '', duration_minutes: 30, - description: '' + description: '', + reset_at_time: '' }) } @@ -3921,27 +3947,42 @@ const buildTempUnschedRules = (rules: TempUnschedRuleForm[]) => { keywords: string[] duration_minutes: number description: string + reset_at_time?: string }> = [] for (const rule of rules) { const errorCode = Number(rule.error_code) const duration = Number(rule.duration_minutes) const keywords = splitTempUnschedKeywords(rule.keywords) + const resetAtTime = rule.reset_at_time?.trim() || '' if (!Number.isFinite(errorCode) || errorCode < 100 || errorCode > 599) { continue } - if (!Number.isFinite(duration) || duration <= 0) { + // 验证:duration_minutes或reset_at_time至少有一个有效 + const hasValidDuration = Number.isFinite(duration) && duration > 0 + const hasValidResetTime = /^\d{2}:\d{2}$/.test(resetAtTime) + if (!hasValidDuration && !hasValidResetTime) { continue } if (keywords.length === 0) { continue } - out.push({ + const entry: { + error_code: number + keywords: string[] + duration_minutes: number + description: string + reset_at_time?: string + } = { error_code: Math.trunc(errorCode), keywords, - duration_minutes: Math.trunc(duration), + duration_minutes: hasValidDuration ? Math.trunc(duration) : 0, description: rule.description.trim() - }) + } + if (hasValidResetTime) { + entry.reset_at_time = resetAtTime + } + out.push(entry) } return out diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index f44b5d38046..ab1a7c764e1 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1207,6 +1207,26 @@ class="input" :placeholder="t('admin.accounts.tempUnschedulable.durationPlaceholder')" /> +

{{ t('admin.accounts.tempUnschedulable.durationMinutesHint') }}

+
+
+ +
+ + +
+

{{ t('admin.accounts.tempUnschedulable.resetAtTimeHint') }}

@@ -2315,6 +2335,8 @@ interface TempUnschedRuleForm { keywords: string duration_minutes: number | null description: string + // 按时间点重置(格式:"HH:MM",如"00:00"表示每天凌晨0点重置) + reset_at_time?: string } // State @@ -2587,7 +2609,8 @@ const tempUnschedPresets = computed(() => [ error_code: 529, keywords: 'overloaded, too many', duration_minutes: 60, - description: t('admin.accounts.tempUnschedulable.presets.overloadDesc') + description: t('admin.accounts.tempUnschedulable.presets.overloadDesc'), + reset_at_time: '' } }, { @@ -2596,7 +2619,8 @@ const tempUnschedPresets = computed(() => [ error_code: 429, keywords: 'rate limit, too many requests', duration_minutes: 10, - description: t('admin.accounts.tempUnschedulable.presets.rateLimitDesc') + description: t('admin.accounts.tempUnschedulable.presets.rateLimitDesc'), + reset_at_time: '' } }, { @@ -2605,7 +2629,8 @@ const tempUnschedPresets = computed(() => [ error_code: 503, keywords: 'unavailable, maintenance', duration_minutes: 30, - description: t('admin.accounts.tempUnschedulable.presets.unavailableDesc') + description: t('admin.accounts.tempUnschedulable.presets.unavailableDesc'), + reset_at_time: '' } } ]) @@ -3105,7 +3130,8 @@ const addTempUnschedRule = (preset?: TempUnschedRuleForm) => { error_code: null, keywords: '', duration_minutes: 30, - description: '' + description: '', + reset_at_time: '' }) } @@ -3128,27 +3154,42 @@ const buildTempUnschedRules = (rules: TempUnschedRuleForm[]) => { keywords: string[] duration_minutes: number description: string + reset_at_time?: string }> = [] for (const rule of rules) { const errorCode = Number(rule.error_code) const duration = Number(rule.duration_minutes) const keywords = splitTempUnschedKeywords(rule.keywords) + const resetAtTime = rule.reset_at_time?.trim() || '' if (!Number.isFinite(errorCode) || errorCode < 100 || errorCode > 599) { continue } - if (!Number.isFinite(duration) || duration <= 0) { + // 验证:duration_minutes或reset_at_time至少有一个有效 + const hasValidDuration = Number.isFinite(duration) && duration > 0 + const hasValidResetTime = /^\d{2}:\d{2}$/.test(resetAtTime) + if (!hasValidDuration && !hasValidResetTime) { continue } if (keywords.length === 0) { continue } - out.push({ + const entry: { + error_code: number + keywords: string[] + duration_minutes: number + description: string + reset_at_time?: string + } = { error_code: Math.trunc(errorCode), keywords, - duration_minutes: Math.trunc(duration), + duration_minutes: hasValidDuration ? Math.trunc(duration) : 0, description: rule.description.trim() - }) + } + if (hasValidResetTime) { + entry.reset_at_time = resetAtTime + } + out.push(entry) } return out @@ -3186,7 +3227,8 @@ function loadTempUnschedRules(credentials?: Record) { error_code: toPositiveNumber(entry.error_code), keywords: formatTempUnschedKeywords(entry.keywords), duration_minutes: toPositiveNumber(entry.duration_minutes), - description: typeof entry.description === 'string' ? entry.description : '' + description: typeof entry.description === 'string' ? entry.description : '', + reset_at_time: typeof entry.reset_at_time === 'string' ? entry.reset_at_time : '' } }) } diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index ff5ea651cf8..ba429e543dc 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3132,12 +3132,16 @@ export default { errorCodePlaceholder: 'e.g. 429', durationMinutes: 'Duration (minutes)', durationPlaceholder: 'e.g. 30', + durationMinutesHint: 'Duration of temporary unschedulable state. Choose either this or "Reset at time".', + resetAtTime: 'Reset at time', + resetAtTimePlaceholder: 'Select time', + resetAtTimeHint: 'When set, the temp unschedulable state will be cleared at the next occurrence of this time. E.g., setting 00:00 means reset daily at midnight.', keywords: 'Keywords', keywordsPlaceholder: 'e.g. overloaded, too many requests', keywordsHint: 'Separate keywords with commas; any keyword match will trigger.', description: 'Description', descriptionPlaceholder: 'Optional note for this rule', - rulesInvalid: 'Add at least one rule with error code, keywords, and duration.', + rulesInvalid: 'Add at least one rule with error code, keywords, and duration or reset time.', viewDetails: 'View temp unschedulable details', accountName: 'Account', triggeredAt: 'Triggered At', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index b8ac7d2cc24..6b8de07d791 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -3261,12 +3261,16 @@ export default { errorCodePlaceholder: '例如 429', durationMinutes: '持续时间(分钟)', durationPlaceholder: '例如 30', + durationMinutesHint: '临时不可调度状态的持续时长,与"按时间点重置"二选一。', + resetAtTime: '按时间点重置', + resetAtTimePlaceholder: '选择时间点', + resetAtTimeHint: '设置后,临时不可调度状态将在下一个该时间点自动解除。例如设置00:00表示每天凌晨0点重置。', keywords: '关键词', keywordsPlaceholder: '例如 overloaded, too many requests', keywordsHint: '多个关键词用逗号分隔,匹配时必须命中其中之一。', description: '描述', descriptionPlaceholder: '可选,便于记忆规则用途', - rulesInvalid: '请至少填写一条包含错误码、关键词和时长的规则。', + rulesInvalid: '请至少填写一条包含错误码、关键词和持续时间或重置时间的规则。', viewDetails: '查看临时不可调度详情', accountName: '账号', triggeredAt: '触发时间', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index eae5e455e89..703de56c4b6 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -794,6 +794,10 @@ export interface TempUnschedulableRule { keywords: string[] duration_minutes: number description: string + // 按时间点重置(格式:"HH:MM",如"00:00"表示每天凌晨0点重置) + // 设置后,临时不可调度状态将在下一个该时间点自动解除 + // 如果同时设置了duration_minutes,则优先使用reset_at_time + reset_at_time?: string } export interface TempUnschedulableState { From 3a85191811fca3c5fb529a03af30bf2639754986 Mon Sep 17 00:00:00 2001 From: GodD6366 Date: Sun, 31 May 2026 00:24:15 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(temp-unsched):=20=E5=A2=9E=E5=BC=BAres?= =?UTF-8?q?et=5Fat=5Ftime=E6=94=AF=E6=8C=81=E4=B8=8E=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端增加严格的时间格式校验和服务器时区计算 - 前端抽取规则构建函数,添加前端校验和国际化文案 - 修复无效时间回退到duration_minutes的逻辑 - 在创建/更新账号时对credentials进行前置校验 - 新增单元测试覆盖验证、计算和回退场景 --- backend/internal/service/account.go | 116 +++++++++++++- backend/internal/service/account_service.go | 6 + backend/internal/service/admin_service.go | 18 ++- backend/internal/service/ratelimit_service.go | 48 ++++-- backend/internal/service/temp_unsched_test.go | 150 ++++++++++++++++++ .../components/account/CreateAccountModal.vue | 71 ++------- .../components/account/EditAccountModal.vue | 67 ++------ .../__tests__/credentialsBuilder.spec.ts | 67 +++++++- .../components/account/credentialsBuilder.ts | 78 +++++++++ frontend/src/i18n/locales/en.ts | 4 +- frontend/src/i18n/locales/zh.ts | 4 +- 11 files changed, 491 insertions(+), 138 deletions(-) diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 906f9743605..89be27803cb 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -4,6 +4,7 @@ package service import ( "encoding/json" "errors" + "fmt" "hash/fnv" "log/slog" "reflect" @@ -14,6 +15,8 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/domain" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" ) type Account struct { @@ -80,12 +83,91 @@ type TempUnschedulableRule struct { Keywords []string `json:"keywords"` DurationMinutes int `json:"duration_minutes"` Description string `json:"description"` - // ResetAtTime 按时间点重置(格式:"HH:MM",如"00:00"表示每天凌晨0点重置) + // ResetAtTime 按服务器配置时区的时间点重置(格式:"HH:MM",如"00:00"表示每天凌晨0点重置) // 设置后,临时不可调度状态将在下一个该时间点自动解除 // 如果同时设置了DurationMinutes,则优先使用ResetAtTime ResetAtTime string `json:"reset_at_time,omitempty"` } +type tempUnschedResetClock struct { + Hour int + Minute int +} + +const tempUnschedResetAtTimeLayout = "15:04" + +func parseTempUnschedResetAtTime(value string) (tempUnschedResetClock, bool) { + value = strings.TrimSpace(value) + if value == "" { + return tempUnschedResetClock{}, false + } + if len(value) != len(tempUnschedResetAtTimeLayout) || value[2] != ':' { + return tempUnschedResetClock{}, false + } + for idx, ch := range value { + if idx == 2 { + continue + } + if ch < '0' || ch > '9' { + return tempUnschedResetClock{}, false + } + } + parsed, err := time.Parse(tempUnschedResetAtTimeLayout, value) + if err != nil { + return tempUnschedResetClock{}, false + } + return tempUnschedResetClock{Hour: parsed.Hour(), Minute: parsed.Minute()}, true +} + +func isValidTempUnschedResetAtTime(value string) bool { + _, ok := parseTempUnschedResetAtTime(value) + return ok +} + +func nextTempUnschedResetAt(now time.Time, resetAtTime string) (time.Time, bool) { + clock, ok := parseTempUnschedResetAtTime(resetAtTime) + if !ok { + return time.Time{}, false + } + + loc := timezone.Location() + localNow := now.In(loc) + nextReset := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), clock.Hour, clock.Minute, 0, 0, loc) + if !nextReset.After(localNow) { + nextReset = nextReset.AddDate(0, 0, 1) + } + return nextReset, true +} + +func ValidateTempUnschedulableCredentials(credentials map[string]any) error { + if credentials == nil { + return nil + } + raw, ok := credentials["temp_unschedulable_rules"] + if !ok || raw == nil { + return nil + } + + for idx, item := range tempUnschedRuleItems(raw) { + entry, ok := item.(map[string]any) + if !ok || entry == nil { + continue + } + resetAtTime := parseTempUnschedString(entry["reset_at_time"]) + if resetAtTime == "" { + continue + } + if !isValidTempUnschedResetAtTime(resetAtTime) { + return infraerrors.BadRequest( + "TEMP_UNSCHED_RESET_AT_TIME_INVALID", + fmt.Sprintf("temp_unschedulable_rules[%d].reset_at_time must use HH:MM between 00:00 and 23:59", idx), + ) + } + } + + return nil +} + func (a *Account) IsActive() bool { return a.Status == StatusActive } @@ -308,13 +390,13 @@ func (a *Account) GetTempUnschedulableRules() []TempUnschedulableRule { return nil } - arr, ok := raw.([]any) - if !ok { + items := tempUnschedRuleItems(raw) + if len(items) == 0 { return nil } - rules := make([]TempUnschedulableRule, 0, len(arr)) - for _, item := range arr { + rules := make([]TempUnschedulableRule, 0, len(items)) + for idx, item := range items { entry, ok := item.(map[string]any) if !ok || entry == nil { continue @@ -332,6 +414,15 @@ func (a *Account) GetTempUnschedulableRules() []TempUnschedulableRule { if rule.ErrorCode <= 0 || len(rule.Keywords) == 0 { continue } + if rule.ResetAtTime != "" && !isValidTempUnschedResetAtTime(rule.ResetAtTime) { + slog.Warn("temp_unsched_invalid_reset_at_time", + "account_id", a.ID, + "rule_index", idx, + "reset_at_time", rule.ResetAtTime, + "duration_minutes", rule.DurationMinutes, + ) + rule.ResetAtTime = "" + } if rule.DurationMinutes <= 0 && rule.ResetAtTime == "" { continue } @@ -342,6 +433,21 @@ func (a *Account) GetTempUnschedulableRules() []TempUnschedulableRule { return rules } +func tempUnschedRuleItems(value any) []any { + switch v := value.(type) { + case []any: + return v + case []map[string]any: + items := make([]any, 0, len(v)) + for _, item := range v { + items = append(items, item) + } + return items + default: + return nil + } +} + func parseTempUnschedString(value any) string { s, ok := value.(string) if !ok { diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index 748840b75d7..a4ea8ac5ea6 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -148,6 +148,9 @@ func (s *AccountService) Create(ctx context.Context, req CreateAccountRequest) ( return nil, err } } + if err := ValidateTempUnschedulableCredentials(req.Credentials); err != nil { + return nil, err + } // 创建账号 account := &Account{ @@ -248,6 +251,9 @@ func (s *AccountService) Update(ctx context.Context, id int64, req UpdateAccount } if req.Credentials != nil { + if err := ValidateTempUnschedulableCredentials(*req.Credentials); err != nil { + return nil, err + } account.Credentials = *req.Credentials } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index d46b636f2cb..21b5c05b543 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -2444,6 +2444,13 @@ func (s *adminServiceImpl) GetAccountsByIDs(ctx context.Context, ids []int64) ([ } func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) { + if input == nil { + return nil, ErrAccountNilInput + } + if err := ValidateTempUnschedulableCredentials(input.Credentials); err != nil { + return nil, err + } + // 绑定分组 groupIDs := input.GroupIDs // 如果没有指定分组,自动绑定对应平台的默认分组 @@ -2566,7 +2573,11 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if len(input.Credentials) > 0 { // 敏感子键采用"incoming 没提供就保留"的合并语义:前端响应已脱敏, // 全对象 PUT 编辑时不会再带回 token,避免覆盖时清空已有凭证。 - account.Credentials = MergePreservingSensitiveCreds(account.Credentials, input.Credentials) + mergedCredentials := MergePreservingSensitiveCreds(account.Credentials, input.Credentials) + if err := ValidateTempUnschedulableCredentials(mergedCredentials); err != nil { + return nil, err + } + account.Credentials = mergedCredentials } // Extra 使用 map:需要区分“未提供(nil)”与“显式清空({})”。 // 关闭配额限制时前端会删除 quota_* 键并提交 extra:{},此时也必须落库。 @@ -2744,6 +2755,11 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp return nil, errors.New("rate_multiplier must be >= 0") } } + if len(input.Credentials) > 0 { + if err := ValidateTempUnschedulableCredentials(input.Credentials); err != nil { + return nil, err + } + } // Prepare bulk updates for columns and JSONB fields. repoUpdates := AccountBulkUpdate{ diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index f3293c70231..df4fec415aa 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -1451,6 +1451,10 @@ func (s *RateLimitService) ClearRateLimit(ctx context.Context, accountID int64) if err := s.accountRepo.ClearTempUnschedulable(ctx, accountID); err != nil { return err } + slog.Info("account_temp_unschedulable_cleared", + "account_id", accountID, + "reason", "rate_limit_cleared", + ) if s.tempUnschedCache != nil { if err := s.tempUnschedCache.DeleteTempUnsched(ctx, accountID); err != nil { slog.Warn("temp_unsched_cache_delete_failed", "account_id", accountID, "error", err) @@ -1516,6 +1520,10 @@ func (s *RateLimitService) ClearTempUnschedulable(ctx context.Context, accountID if err := s.accountRepo.ClearTempUnschedulable(ctx, accountID); err != nil { return err } + slog.Info("account_temp_unschedulable_cleared", + "account_id", accountID, + "reason", "explicit_clear", + ) if s.tempUnschedCache != nil { if err := s.tempUnschedCache.DeleteTempUnsched(ctx, accountID); err != nil { slog.Warn("temp_unsched_cache_delete_failed", "account_id", accountID, "error", err) @@ -1758,26 +1766,21 @@ func (s *RateLimitService) triggerTempUnschedulable(ctx context.Context, account now := time.Now() var until time.Time - // 优先使用ResetAtTime(按时间点重置) + // 优先使用 ResetAtTime(按应用配置的服务器时区计算) if rule.ResetAtTime != "" { - // 解析时间点(格式:"HH:MM") - parts := strings.Split(rule.ResetAtTime, ":") - if len(parts) == 2 { - hour, err1 := strconv.Atoi(parts[0]) - minute, err2 := strconv.Atoi(parts[1]) - if err1 == nil && err2 == nil && hour >= 0 && hour < 24 && minute >= 0 && minute < 60 { - // 计算下一个该时间点 - nextReset := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location()) - if !nextReset.After(now) { - // 如果今天的时间点已过,则设置为明天 - nextReset = nextReset.AddDate(0, 0, 1) - } - until = nextReset - } + if nextReset, ok := nextTempUnschedResetAt(now, rule.ResetAtTime); ok { + until = nextReset + } else { + slog.Warn("temp_unsched_invalid_reset_at_time", + "account_id", account.ID, + "rule_index", ruleIndex, + "reset_at_time", rule.ResetAtTime, + "duration_minutes", rule.DurationMinutes, + ) } } - // 如果ResetAtTime未设置或解析失败,则使用DurationMinutes + // 如果 ResetAtTime 未设置或解析失败,则使用 DurationMinutes if until.IsZero() { if rule.DurationMinutes <= 0 { return false @@ -1814,7 +1817,18 @@ func (s *RateLimitService) triggerTempUnschedulable(ctx context.Context, account } } - slog.Info("account_temp_unschedulable", "account_id", account.ID, "until", until, "rule_index", ruleIndex, "status_code", statusCode) + resetType := "duration_minutes" + if rule.ResetAtTime != "" { + resetType = "reset_at_time(" + rule.ResetAtTime + ")" + } + slog.Info("account_temp_unschedulable", + "account_id", account.ID, + "until", until, + "rule_index", ruleIndex, + "status_code", statusCode, + "matched_keyword", matchedKeyword, + "reset_type", resetType, + ) return true } diff --git a/backend/internal/service/temp_unsched_test.go b/backend/internal/service/temp_unsched_test.go index d132c2bc60b..83f43b6acca 100644 --- a/backend/internal/service/temp_unsched_test.go +++ b/backend/internal/service/temp_unsched_test.go @@ -3,6 +3,7 @@ package service import ( + "context" "testing" "time" @@ -204,6 +205,52 @@ func TestAccount_GetTempUnschedulableRules(t *testing.T) { }, wantCount: 2, }, + { + name: "reset_at_time_only_rule", + account: &Account{ + Credentials: map[string]any{ + "temp_unschedulable_rules": []any{ + map[string]any{ + "error_code": float64(503), + "keywords": []any{"overloaded"}, + "reset_at_time": "00:00", + }, + }, + }, + }, + wantCount: 1, + }, + { + name: "invalid_reset_at_time_without_duration_skipped", + account: &Account{ + Credentials: map[string]any{ + "temp_unschedulable_rules": []any{ + map[string]any{ + "error_code": float64(503), + "keywords": []any{"overloaded"}, + "reset_at_time": "24:00", + }, + }, + }, + }, + wantCount: 0, + }, + { + name: "invalid_reset_at_time_with_duration_falls_back_to_duration_rule", + account: &Account{ + Credentials: map[string]any{ + "temp_unschedulable_rules": []any{ + map[string]any{ + "error_code": float64(503), + "keywords": []any{"overloaded"}, + "duration_minutes": float64(5), + "reset_at_time": "99:99", + }, + }, + }, + }, + wantCount: 1, + }, { name: "empty_rules", account: &Account{ @@ -258,6 +305,109 @@ func TestTempUnschedulableRule_Parse(t *testing.T) { require.Equal(t, 5, rule.DurationMinutes) } +func TestTempUnschedResetAtTimeValidation(t *testing.T) { + valid := []string{"00:00", "09:30", "23:59"} + for _, value := range valid { + t.Run("valid_"+value, func(t *testing.T) { + require.True(t, isValidTempUnschedResetAtTime(value)) + }) + } + + invalid := []string{"", "0:00", "24:00", "99:99", "12:60", "12:3", "ab:cd"} + for _, value := range invalid { + t.Run("invalid_"+value, func(t *testing.T) { + require.False(t, isValidTempUnschedResetAtTime(value)) + }) + } +} + +func TestNextTempUnschedResetAt(t *testing.T) { + loc := time.Local + beforeTarget := time.Date(2026, 5, 29, 8, 30, 0, 0, loc) + sameDay, ok := nextTempUnschedResetAt(beforeTarget, "09:00") + require.True(t, ok) + require.Equal(t, time.Date(2026, 5, 29, 9, 0, 0, 0, loc), sameDay) + + afterTarget := time.Date(2026, 5, 29, 9, 30, 0, 0, loc) + nextDay, ok := nextTempUnschedResetAt(afterTarget, "09:00") + require.True(t, ok) + require.Equal(t, time.Date(2026, 5, 30, 9, 0, 0, 0, loc), nextDay) + + _, ok = nextTempUnschedResetAt(afterTarget, "24:00") + require.False(t, ok) +} + +func TestValidateTempUnschedulableCredentials(t *testing.T) { + valid := map[string]any{ + "temp_unschedulable_rules": []any{ + map[string]any{ + "error_code": float64(503), + "keywords": []any{"overloaded"}, + "duration_minutes": float64(5), + "reset_at_time": "23:59", + }, + }, + } + require.NoError(t, ValidateTempUnschedulableCredentials(valid)) + + invalid := map[string]any{ + "temp_unschedulable_rules": []any{ + map[string]any{ + "error_code": float64(503), + "keywords": []any{"overloaded"}, + "duration_minutes": float64(5), + "reset_at_time": "24:00", + }, + }, + } + require.Error(t, ValidateTempUnschedulableCredentials(invalid)) +} + +type tempUnschedRecorderRepo struct { + mockAccountRepoForGemini + tempCalls int + until time.Time +} + +func (r *tempUnschedRecorderRepo) SetTempUnschedulable(_ context.Context, _ int64, until time.Time, _ string) error { + r.tempCalls++ + r.until = until + return nil +} + +func TestRateLimitService_TempUnschedResetAtTimeFallback(t *testing.T) { + repo := &tempUnschedRecorderRepo{} + svc := NewRateLimitService(repo, nil, nil, nil, nil) + account := &Account{ID: 1} + + resetOnlyRule := TempUnschedulableRule{ + ErrorCode: 503, + Keywords: []string{"overloaded"}, + ResetAtTime: time.Now().Add(time.Hour).Format("15:04"), + } + require.True(t, svc.triggerTempUnschedulable(context.Background(), account, resetOnlyRule, 0, 503, "overloaded", []byte("overloaded"))) + require.Equal(t, 1, repo.tempCalls) + require.True(t, repo.until.After(time.Now())) + + repo = &tempUnschedRecorderRepo{} + svc = NewRateLimitService(repo, nil, nil, nil, nil) + rule := TempUnschedulableRule{ + ErrorCode: 503, + Keywords: []string{"overloaded"}, + DurationMinutes: 5, + ResetAtTime: "99:99", + } + require.True(t, svc.triggerTempUnschedulable(context.Background(), account, rule, 0, 503, "overloaded", []byte("overloaded"))) + require.Equal(t, 1, repo.tempCalls) + require.WithinDuration(t, time.Now().Add(5*time.Minute), repo.until, 2*time.Second) + + repo = &tempUnschedRecorderRepo{} + svc = NewRateLimitService(repo, nil, nil, nil, nil) + rule.DurationMinutes = 0 + require.False(t, svc.triggerTempUnschedulable(context.Background(), account, rule, 0, 503, "overloaded", []byte("overloaded"))) + require.Equal(t, 0, repo.tempCalls) +} + // TestTruncateTempUnschedMessage 测试消息截断 func TestTruncateTempUnschedMessage(t *testing.T) { tests := []struct { diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index c99aaaf3c32..fd93ba03c9f 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2024,7 +2024,7 @@ @click="rule.reset_at_time = '00:00'" class="shrink-0 rounded-lg border border-gray-200 px-2.5 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:border-dark-600 dark:text-gray-400 dark:hover:bg-dark-600 dark:hover:text-gray-200" > - 次日0点 + {{ t('admin.accounts.tempUnschedulable.resetAtMidnightShortcut') }}

{{ t('admin.accounts.tempUnschedulable.resetAtTimeHint') }}

@@ -3258,7 +3258,11 @@ import ProxyAdBanner from '@/components/common/ProxyAdBanner.vue' import GroupSelector from '@/components/common/GroupSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue' -import { applyInterceptWarmup } from '@/components/account/credentialsBuilder' +import { + applyInterceptWarmup, + buildTempUnschedRules, + hasInvalidTempUnschedResetAtTime +} from '@/components/account/credentialsBuilder' import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format' import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' import { VERTEX_LOCATION_OPTIONS } from '@/constants/account' @@ -4053,53 +4057,6 @@ const moveTempUnschedRule = (index: number, direction: number) => { rules[target] = current } -const buildTempUnschedRules = (rules: TempUnschedRuleForm[]) => { - const out: Array<{ - error_code: number - keywords: string[] - duration_minutes: number - description: string - reset_at_time?: string - }> = [] - - for (const rule of rules) { - const errorCode = Number(rule.error_code) - const duration = Number(rule.duration_minutes) - const keywords = splitTempUnschedKeywords(rule.keywords) - const resetAtTime = rule.reset_at_time?.trim() || '' - if (!Number.isFinite(errorCode) || errorCode < 100 || errorCode > 599) { - continue - } - // 验证:duration_minutes或reset_at_time至少有一个有效 - const hasValidDuration = Number.isFinite(duration) && duration > 0 - const hasValidResetTime = /^\d{2}:\d{2}$/.test(resetAtTime) - if (!hasValidDuration && !hasValidResetTime) { - continue - } - if (keywords.length === 0) { - continue - } - const entry: { - error_code: number - keywords: string[] - duration_minutes: number - description: string - reset_at_time?: string - } = { - error_code: Math.trunc(errorCode), - keywords, - duration_minutes: hasValidDuration ? Math.trunc(duration) : 0, - description: rule.description.trim() - } - if (hasValidResetTime) { - entry.reset_at_time = resetAtTime - } - out.push(entry) - } - - return out -} - const applyTempUnschedConfig = (credentials: Record) => { if (!tempUnschedEnabled.value) { delete credentials.temp_unschedulable_enabled @@ -4107,6 +4064,11 @@ const applyTempUnschedConfig = (credentials: Record) => { return true } + if (hasInvalidTempUnschedResetAtTime(tempUnschedRules.value)) { + appStore.showError(t('admin.accounts.tempUnschedulable.resetAtTimeInvalid')) + return false + } + const rules = buildTempUnschedRules(tempUnschedRules.value) if (rules.length === 0) { appStore.showError(t('admin.accounts.tempUnschedulable.rulesInvalid')) @@ -4118,13 +4080,6 @@ const applyTempUnschedConfig = (credentials: Record) => { return true } -const splitTempUnschedKeywords = (value: string) => { - return value - .split(/[,;]/) - .map((item) => item.trim()) - .filter((item) => item.length > 0) -} - const needsMixedChannelCheck = (platform: AccountPlatform) => platform === 'antigravity' || platform === 'anthropic' const buildMixedChannelDetails = (resp?: CheckMixedChannelResponse) => { @@ -5420,6 +5375,10 @@ const handleCookieAuth = async (sessionKey: string) => { const tempUnschedPayload = tempUnschedEnabled.value ? buildTempUnschedRules(tempUnschedRules.value) : [] + if (tempUnschedEnabled.value && hasInvalidTempUnschedResetAtTime(tempUnschedRules.value)) { + appStore.showError(t('admin.accounts.tempUnschedulable.resetAtTimeInvalid')) + return + } if (tempUnschedEnabled.value && tempUnschedPayload.length === 0) { appStore.showError(t('admin.accounts.tempUnschedulable.rulesInvalid')) return diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 6f2d3283726..e248f08c088 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1223,7 +1223,7 @@ @click="rule.reset_at_time = '00:00'" class="shrink-0 rounded-lg border border-gray-200 px-2.5 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:border-dark-600 dark:text-gray-400 dark:hover:bg-dark-600 dark:hover:text-gray-200" > - 次日0点 + {{ t('admin.accounts.tempUnschedulable.resetAtMidnightShortcut') }}

{{ t('admin.accounts.tempUnschedulable.resetAtTimeHint') }}

@@ -2418,7 +2418,11 @@ import ProxyAdBanner from '@/components/common/ProxyAdBanner.vue' import GroupSelector from '@/components/common/GroupSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue' -import { applyInterceptWarmup } from '@/components/account/credentialsBuilder' +import { + applyInterceptWarmup, + buildTempUnschedRules, + hasInvalidTempUnschedResetAtTime +} from '@/components/account/credentialsBuilder' import { formatDateTime, formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format' import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' import { VERTEX_LOCATION_OPTIONS } from '@/constants/account' @@ -3391,53 +3395,6 @@ const moveTempUnschedRule = (index: number, direction: number) => { rules[target] = current } -const buildTempUnschedRules = (rules: TempUnschedRuleForm[]) => { - const out: Array<{ - error_code: number - keywords: string[] - duration_minutes: number - description: string - reset_at_time?: string - }> = [] - - for (const rule of rules) { - const errorCode = Number(rule.error_code) - const duration = Number(rule.duration_minutes) - const keywords = splitTempUnschedKeywords(rule.keywords) - const resetAtTime = rule.reset_at_time?.trim() || '' - if (!Number.isFinite(errorCode) || errorCode < 100 || errorCode > 599) { - continue - } - // 验证:duration_minutes或reset_at_time至少有一个有效 - const hasValidDuration = Number.isFinite(duration) && duration > 0 - const hasValidResetTime = /^\d{2}:\d{2}$/.test(resetAtTime) - if (!hasValidDuration && !hasValidResetTime) { - continue - } - if (keywords.length === 0) { - continue - } - const entry: { - error_code: number - keywords: string[] - duration_minutes: number - description: string - reset_at_time?: string - } = { - error_code: Math.trunc(errorCode), - keywords, - duration_minutes: hasValidDuration ? Math.trunc(duration) : 0, - description: rule.description.trim() - } - if (hasValidResetTime) { - entry.reset_at_time = resetAtTime - } - out.push(entry) - } - - return out -} - const applyTempUnschedConfig = (credentials: Record) => { if (!tempUnschedEnabled.value) { delete credentials.temp_unschedulable_enabled @@ -3445,6 +3402,11 @@ const applyTempUnschedConfig = (credentials: Record) => { return true } + if (hasInvalidTempUnschedResetAtTime(tempUnschedRules.value)) { + appStore.showError(t('admin.accounts.tempUnschedulable.resetAtTimeInvalid')) + return false + } + const rules = buildTempUnschedRules(tempUnschedRules.value) if (rules.length === 0) { appStore.showError(t('admin.accounts.tempUnschedulable.rulesInvalid')) @@ -3570,13 +3532,6 @@ function formatTempUnschedKeywords(value: unknown) { return '' } -const splitTempUnschedKeywords = (value: string) => { - return value - .split(/[,;]/) - .map((item) => item.trim()) - .filter((item) => item.length > 0) -} - function toPositiveNumber(value: unknown) { const num = Number(value) if (!Number.isFinite(num) || num <= 0) { diff --git a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts index be2a8d521cb..62a689b0003 100644 --- a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts +++ b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest' -import { applyInterceptWarmup } from '../credentialsBuilder' +import { + applyInterceptWarmup, + buildTempUnschedRules, + hasInvalidTempUnschedResetAtTime, + isValidTempUnschedResetAtTime +} from '../credentialsBuilder' describe('applyInterceptWarmup', () => { it('create + enabled=true: should set intercept_warmup_requests to true', () => { @@ -44,3 +49,63 @@ describe('applyInterceptWarmup', () => { expect('intercept_warmup_requests' in creds).toBe(false) }) }) + +describe('temp unschedulable rule builder', () => { + it('accepts reset_at_time without duration when the time is in range', () => { + const rules = buildTempUnschedRules([ + { + error_code: 503, + keywords: 'overloaded', + duration_minutes: 0, + description: 'daily reset', + reset_at_time: '00:00' + } + ]) + + expect(rules).toEqual([ + { + error_code: 503, + keywords: ['overloaded'], + duration_minutes: 0, + description: 'daily reset', + reset_at_time: '00:00' + } + ]) + }) + + it('rejects out-of-range reset_at_time values even with a duration fallback', () => { + expect(isValidTempUnschedResetAtTime('23:59')).toBe(true) + expect(isValidTempUnschedResetAtTime('24:00')).toBe(false) + expect(isValidTempUnschedResetAtTime('99:99')).toBe(false) + expect( + hasInvalidTempUnschedResetAtTime([ + { + error_code: 503, + keywords: 'overloaded', + duration_minutes: 30, + description: '', + reset_at_time: '24:00' + } + ]) + ).toBe(true) + + const rules = buildTempUnschedRules([ + { + error_code: 503, + keywords: 'overloaded', + duration_minutes: 30, + description: '', + reset_at_time: '24:00' + }, + { + error_code: 429, + keywords: 'rate limit', + duration_minutes: 10, + description: '', + reset_at_time: '99:99' + } + ]) + + expect(rules).toEqual([]) + }) +}) diff --git a/frontend/src/components/account/credentialsBuilder.ts b/frontend/src/components/account/credentialsBuilder.ts index b8008e8bfb1..ae7138f8b9e 100644 --- a/frontend/src/components/account/credentialsBuilder.ts +++ b/frontend/src/components/account/credentialsBuilder.ts @@ -9,3 +9,81 @@ export function applyInterceptWarmup( delete credentials.intercept_warmup_requests } } + +export interface TempUnschedRuleFormInput { + error_code: number | string | null | undefined + keywords: string + duration_minutes: number | string | null | undefined + description: string + reset_at_time?: string | null +} + +export interface TempUnschedRulePayload { + error_code: number + keywords: string[] + duration_minutes: number + description: string + reset_at_time?: string +} + +const tempUnschedResetAtTimePattern = /^([01]\d|2[0-3]):[0-5]\d$/ + +export function isValidTempUnschedResetAtTime(value: string): boolean { + return tempUnschedResetAtTimePattern.test(value.trim()) +} + +export function splitTempUnschedKeywords(value: string): string[] { + return value + .split(/[,;]/) + .map((item) => item.trim()) + .filter((item) => item.length > 0) +} + +export function hasInvalidTempUnschedResetAtTime(rules: TempUnschedRuleFormInput[]): boolean { + return rules.some((rule) => { + const resetAtTime = rule.reset_at_time?.trim() || '' + return resetAtTime !== '' && !isValidTempUnschedResetAtTime(resetAtTime) + }) +} + +export function buildTempUnschedRules( + rules: TempUnschedRuleFormInput[] +): TempUnschedRulePayload[] { + const out: TempUnschedRulePayload[] = [] + + for (const rule of rules) { + const errorCode = Number(rule.error_code) + const duration = Number(rule.duration_minutes) + const keywords = splitTempUnschedKeywords(rule.keywords) + const resetAtTime = rule.reset_at_time?.trim() || '' + if (!Number.isFinite(errorCode) || errorCode < 100 || errorCode > 599) { + continue + } + + const hasValidDuration = Number.isFinite(duration) && duration > 0 + const hasResetTime = resetAtTime !== '' + const hasValidResetTime = hasResetTime && isValidTempUnschedResetAtTime(resetAtTime) + if (!hasValidDuration && !hasValidResetTime) { + continue + } + if (hasResetTime && !hasValidResetTime) { + continue + } + if (keywords.length === 0) { + continue + } + + const entry: TempUnschedRulePayload = { + error_code: Math.trunc(errorCode), + keywords, + duration_minutes: hasValidDuration ? Math.trunc(duration) : 0, + description: rule.description.trim() + } + if (hasValidResetTime) { + entry.reset_at_time = resetAtTime + } + out.push(entry) + } + + return out +} diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 82d84d6d1a5..337b55c4daf 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3158,7 +3158,9 @@ export default { durationMinutesHint: 'Duration of temporary unschedulable state. Choose either this or "Reset at time".', resetAtTime: 'Reset at time', resetAtTimePlaceholder: 'Select time', - resetAtTimeHint: 'When set, the temp unschedulable state will be cleared at the next occurrence of this time. E.g., setting 00:00 means reset daily at midnight.', + resetAtTimeHint: 'When set, the temp unschedulable state clears at the next occurrence of this server-time value. E.g., 00:00 means server midnight.', + resetAtMidnightShortcut: 'Next 00:00', + resetAtTimeInvalid: 'Reset time must be between 00:00 and 23:59.', keywords: 'Keywords', keywordsPlaceholder: 'e.g. overloaded, too many requests', keywordsHint: 'Separate keywords with commas; any keyword match will trigger.', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 33d6c372c78..6d2ecdb9188 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -3287,7 +3287,9 @@ export default { durationMinutesHint: '临时不可调度状态的持续时长,与"按时间点重置"二选一。', resetAtTime: '按时间点重置', resetAtTimePlaceholder: '选择时间点', - resetAtTimeHint: '设置后,临时不可调度状态将在下一个该时间点自动解除。例如设置00:00表示每天凌晨0点重置。', + resetAtTimeHint: '设置后,临时不可调度状态将在下一个服务器时区的该时间点自动解除。例如 00:00 表示服务器时区凌晨 0 点。', + resetAtMidnightShortcut: '次日0点', + resetAtTimeInvalid: '重置时间必须在 00:00 到 23:59 之间。', keywords: '关键词', keywordsPlaceholder: '例如 overloaded, too many requests', keywordsHint: '多个关键词用逗号分隔,匹配时必须命中其中之一。', From b327404cb37fe7042e462e4e1a5b4622116f6dc3 Mon Sep 17 00:00:00 2001 From: GodD6366 Date: Tue, 2 Jun 2026 11:38:17 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(settings):=20=E6=9A=B4=E9=9C=B2?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=E6=97=B6=E5=8C=BA=E5=B9=B6=E5=9C=A8?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PublicSettings 新增 server_timezone 字段 - 管理界面时区提示显示当前服务器时区 - 更新中英文 i18n 文案 --- backend/internal/handler/dto/settings.go | 1 + backend/internal/handler/setting_handler.go | 1 + .../handler/setting_handler_public_test.go | 27 +++++++++++++++++++ backend/internal/service/setting_service.go | 4 +++ .../service/setting_service_public_test.go | 12 +++++++++ backend/internal/service/settings_view.go | 1 + .../components/account/CreateAccountModal.vue | 7 ++++- .../components/account/EditAccountModal.vue | 7 ++++- frontend/src/i18n/locales/en.ts | 3 ++- frontend/src/i18n/locales/zh.ts | 3 ++- frontend/src/stores/app.ts | 4 +++ frontend/src/types/index.ts | 1 + 12 files changed, 67 insertions(+), 4 deletions(-) diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 17772a2eef7..ae4c3aa3376 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -283,6 +283,7 @@ type PublicSettings struct { DocURL string `json:"doc_url"` HomeContent string `json:"home_content"` HideCcsImportButton bool `json:"hide_ccs_import_button"` + ServerTimezone string `json:"server_timezone"` PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` PurchaseSubscriptionURL string `json:"purchase_subscription_url"` TableDefaultPageSize int `json:"table_default_page_size"` diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 7413b840cac..f938c70eef7 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -66,6 +66,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { DocURL: settings.DocURL, HomeContent: settings.HomeContent, HideCcsImportButton: settings.HideCcsImportButton, + ServerTimezone: settings.ServerTimezone, PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, TableDefaultPageSize: settings.TableDefaultPageSize, diff --git a/backend/internal/handler/setting_handler_public_test.go b/backend/internal/handler/setting_handler_public_test.go index 45d66f8e337..dd80a92c1b9 100644 --- a/backend/internal/handler/setting_handler_public_test.go +++ b/backend/internal/handler/setting_handler_public_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" @@ -82,6 +83,32 @@ func TestSettingHandler_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t require.True(t, resp.Data.ForceEmailOnThirdPartySignup) } +func TestSettingHandler_GetPublicSettings_ExposesServerTimezone(t *testing.T) { + require.NoError(t, timezone.Init("Asia/Shanghai")) + t.Cleanup(func() { _ = timezone.Init("UTC") }) + gin.SetMode(gin.TestMode) + + h := NewSettingHandler(service.NewSettingService(&settingHandlerPublicRepoStub{}, &config.Config{}), "test-version") + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/settings/public", nil) + + h.GetPublicSettings(c) + + require.Equal(t, http.StatusOK, recorder.Code) + + var resp struct { + Code int `json:"code"` + Data struct { + ServerTimezone string `json:"server_timezone"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.Equal(t, 0, resp.Code) + require.Equal(t, "Asia/Shanghai", resp.Data.ServerTimezone) +} + func TestSettingHandler_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t *testing.T) { gin.SetMode(gin.TestMode) h := NewSettingHandler(service.NewSettingService(&settingHandlerPublicRepoStub{ diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 98acdb80f56..53ee615413f 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -20,6 +20,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" 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" ) @@ -842,6 +843,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings DocURL: settings[SettingKeyDocURL], HomeContent: settings[SettingKeyHomeContent], HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", + ServerTimezone: timezone.Name(), PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), TableDefaultPageSize: tableDefaultPageSize, @@ -1143,6 +1145,7 @@ type PublicSettingsInjectionPayload struct { DocURL string `json:"doc_url"` HomeContent string `json:"home_content"` HideCcsImportButton bool `json:"hide_ccs_import_button"` + ServerTimezone string `json:"server_timezone"` PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` PurchaseSubscriptionURL string `json:"purchase_subscription_url"` TableDefaultPageSize int `json:"table_default_page_size"` @@ -1208,6 +1211,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any DocURL: settings.DocURL, HomeContent: settings.HomeContent, HideCcsImportButton: settings.HideCcsImportButton, + ServerTimezone: settings.ServerTimezone, PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, TableDefaultPageSize: settings.TableDefaultPageSize, diff --git a/backend/internal/service/setting_service_public_test.go b/backend/internal/service/setting_service_public_test.go index 2faa4d82ff7..9977061be50 100644 --- a/backend/internal/service/setting_service_public_test.go +++ b/backend/internal/service/setting_service_public_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/stretchr/testify/require" ) @@ -91,6 +92,17 @@ func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t require.True(t, settings.ForceEmailOnThirdPartySignup) } +func TestSettingService_GetPublicSettings_ExposesServerTimezone(t *testing.T) { + require.NoError(t, timezone.Init("Asia/Shanghai")) + t.Cleanup(func() { _ = timezone.Init("UTC") }) + + svc := NewSettingService(&settingPublicRepoStub{}, &config.Config{}) + + settings, err := svc.GetPublicSettings(context.Background()) + require.NoError(t, err) + require.Equal(t, "Asia/Shanghai", settings.ServerTimezone) +} + func TestSettingService_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t *testing.T) { svc := NewSettingService(&settingPublicRepoStub{ values: map[string]string{ diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 7b45ef1a689..cad2edcfe08 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -254,6 +254,7 @@ type PublicSettings struct { DocURL string HomeContent string HideCcsImportButton bool + ServerTimezone string PurchaseSubscriptionEnabled bool PurchaseSubscriptionURL string diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index fd93ba03c9f..3fbe231d050 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2027,7 +2027,9 @@ {{ t('admin.accounts.tempUnschedulable.resetAtMidnightShortcut') }} -

{{ t('admin.accounts.tempUnschedulable.resetAtTimeHint') }}

+

+ {{ t('admin.accounts.tempUnschedulable.resetAtTimeHint', { timezone: effectiveServerTimezone }) }} +

@@ -3326,6 +3328,9 @@ const emit = defineEmits<{ }>() const appStore = useAppStore() +const effectiveServerTimezone = computed( + () => appStore.serverTimezone || t('admin.accounts.tempUnschedulable.serverTimezoneUnknown') +) // OAuth composables const oauth = useAccountOAuth() // For Anthropic OAuth diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index e248f08c088..1fa15d691d2 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1226,7 +1226,9 @@ {{ t('admin.accounts.tempUnschedulable.resetAtMidnightShortcut') }}
-

{{ t('admin.accounts.tempUnschedulable.resetAtTimeHint') }}

+

+ {{ t('admin.accounts.tempUnschedulable.resetAtTimeHint', { timezone: effectiveServerTimezone }) }} +

@@ -2459,6 +2461,9 @@ const emit = defineEmits<{ const { t } = useI18n() const appStore = useAppStore() const authStore = useAuthStore() +const effectiveServerTimezone = computed( + () => appStore.serverTimezone || t('admin.accounts.tempUnschedulable.serverTimezoneUnknown') +) // Platform-specific hint for Base URL const baseUrlHint = computed(() => { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 337b55c4daf..34183b9f4c9 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3158,7 +3158,8 @@ export default { durationMinutesHint: 'Duration of temporary unschedulable state. Choose either this or "Reset at time".', resetAtTime: 'Reset at time', resetAtTimePlaceholder: 'Select time', - resetAtTimeHint: 'When set, the temp unschedulable state clears at the next occurrence of this server-time value. E.g., 00:00 means server midnight.', + resetAtTimeHint: 'Calculated in the server timezone. Current effective timezone: {timezone}. E.g., 00:00 means midnight in that timezone.', + serverTimezoneUnknown: 'Unknown', resetAtMidnightShortcut: 'Next 00:00', resetAtTimeInvalid: 'Reset time must be between 00:00 and 23:59.', keywords: 'Keywords', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 6d2ecdb9188..e7a86be8256 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -3287,7 +3287,8 @@ export default { durationMinutesHint: '临时不可调度状态的持续时长,与"按时间点重置"二选一。', resetAtTime: '按时间点重置', resetAtTimePlaceholder: '选择时间点', - resetAtTimeHint: '设置后,临时不可调度状态将在下一个服务器时区的该时间点自动解除。例如 00:00 表示服务器时区凌晨 0 点。', + resetAtTimeHint: '按服务器时区计算;当前生效时区:{timezone}。例如 00:00 表示该时区凌晨 0 点。', + serverTimezoneUnknown: '未知', resetAtMidnightShortcut: '次日0点', resetAtTimeInvalid: '重置时间必须在 00:00 到 23:59 之间。', keywords: '关键词', diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index 4d701b2efb9..a6e1eaefbe4 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -31,6 +31,7 @@ export const useAppStore = defineStore('app', () => { const contactInfo = ref('') const apiBaseUrl = ref('') const docUrl = ref('') + const serverTimezone = ref('') const cachedPublicSettings = ref(null) // Version cache state @@ -298,6 +299,7 @@ export const useAppStore = defineStore('app', () => { contactInfo.value = config.contact_info || '' apiBaseUrl.value = config.api_base_url || '' docUrl.value = config.doc_url || '' + serverTimezone.value = config.server_timezone || '' publicSettingsLoaded.value = true } @@ -335,6 +337,7 @@ export const useAppStore = defineStore('app', () => { doc_url: docUrl.value, home_content: '', hide_ccs_import_button: false, + server_timezone: serverTimezone.value, payment_enabled: false, table_default_page_size: 20, table_page_size_options: [10, 20, 50, 100], @@ -418,6 +421,7 @@ export const useAppStore = defineStore('app', () => { contactInfo, apiBaseUrl, docUrl, + serverTimezone, cachedPublicSettings, // Version state diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b2a178b97a3..719549b7584 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -208,6 +208,7 @@ export interface PublicSettings { doc_url: string home_content: string hide_ccs_import_button: boolean + server_timezone?: string payment_enabled: boolean risk_control_enabled: boolean table_default_page_size: number