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/account.go b/backend/internal/service/account.go index fb95201f200..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,6 +83,89 @@ 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"` +} + +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 { @@ -304,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 @@ -321,9 +407,23 @@ 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.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 } @@ -333,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 00205d1f792..14823a662ad 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -2456,6 +2456,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 // 如果没有指定分组,自动绑定对应平台的默认分组 @@ -2578,7 +2585,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:{},此时也必须落库。 @@ -2756,6 +2767,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 ecbd86d1ae6..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) @@ -1754,12 +1762,31 @@ 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 != "" { + 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 + if until.IsZero() { + if rule.DurationMinutes <= 0 { + return false + } + until = now.Add(time.Duration(rule.DurationMinutes) * time.Minute) + } state := &TempUnschedState{ UntilUnix: until.Unix(), @@ -1790,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/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/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 d1a7729a58d..343b00c6035 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2008,6 +2008,28 @@ class="input" :placeholder="t('admin.accounts.tempUnschedulable.durationPlaceholder')" /> +
{{ t('admin.accounts.tempUnschedulable.durationMinutesHint') }}
+ ++ {{ t('admin.accounts.tempUnschedulable.resetAtTimeHint', { timezone: effectiveServerTimezone }) }} +
{{ t('admin.accounts.tempUnschedulable.durationMinutesHint') }}
++ {{ t('admin.accounts.tempUnschedulable.resetAtTimeHint', { timezone: effectiveServerTimezone }) }} +