diff --git a/internal/developer/settings.go b/internal/developer/settings.go index a98225c..b5365e5 100644 --- a/internal/developer/settings.go +++ b/internal/developer/settings.go @@ -27,9 +27,9 @@ const ( DeviceLimitSettingKey = "developer.device_limit" SMSHourlyLimitKey = "developer.sms_hourly_limit" DefaultDeviceLimit = 5 - MaxDeviceLimit = 128 + MaxDeviceLimit = 10 DefaultSMSHourlyLimit = 10 - MaxSMSHourlyLimit = 1000 + MaxSMSHourlyLimit = 20 ) func DeviceLimit(ctx context.Context, database *store.Store, enabled bool) int { @@ -43,9 +43,12 @@ func DeviceLimit(ctx context.Context, database *store.Store, enabled bool) int { var document struct { Limit int `json:"limit"` } - if json.Unmarshal(setting.Value, &document) != nil || document.Limit < 1 || document.Limit > MaxDeviceLimit { + if json.Unmarshal(setting.Value, &document) != nil || document.Limit < 1 { return DefaultDeviceLimit } + if document.Limit > MaxDeviceLimit { + return MaxDeviceLimit + } return document.Limit } @@ -70,9 +73,12 @@ func SMSHourlyLimit(ctx context.Context, database *store.Store) int { var document struct { Limit int `json:"limit"` } - if json.Unmarshal(setting.Value, &document) != nil || document.Limit < 1 || document.Limit > MaxSMSHourlyLimit { + if json.Unmarshal(setting.Value, &document) != nil || document.Limit < 1 { return DefaultSMSHourlyLimit } + if document.Limit > MaxSMSHourlyLimit { + return MaxSMSHourlyLimit + } return document.Limit } diff --git a/internal/developer/settings_test.go b/internal/developer/settings_test.go index ac3576f..71963f4 100644 --- a/internal/developer/settings_test.go +++ b/internal/developer/settings_test.go @@ -19,10 +19,10 @@ func TestResetExperimentalRestoresDefaults(t *testing.T) { t.Fatal(err) } defer database.Close() - if err := SetDeviceLimit(ctx, database, 24); err != nil { + if err := SetDeviceLimit(ctx, database, 8); err != nil { t.Fatal(err) } - if err := SetSMSHourlyLimit(ctx, database, 42); err != nil { + if err := SetSMSHourlyLimit(ctx, database, 18); err != nil { t.Fatal(err) } enabled, _ := json.Marshal(map[string]bool{"enabled": true}) @@ -92,10 +92,34 @@ func TestSetSMSHourlyLimitValidatesRange(t *testing.T) { if SetSMSHourlyLimit(ctx, database, 0) == nil || SetSMSHourlyLimit(ctx, database, MaxSMSHourlyLimit+1) == nil { t.Fatal("out-of-range SMS hourly limit was accepted") } - if err := SetSMSHourlyLimit(ctx, database, 25); err != nil { + if err := SetSMSHourlyLimit(ctx, database, 15); err != nil { t.Fatal(err) } - if got := SMSHourlyLimit(ctx, database); got != 25 { - t.Fatalf("SMS hourly limit = %d, want 25", got) + if got := SMSHourlyLimit(ctx, database); got != 15 { + t.Fatalf("SMS hourly limit = %d, want 15", got) + } +} + +func TestStoredLimitsAboveHardMaximumAreClamped(t *testing.T) { + ctx := context.Background() + database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + for key, limit := range map[string]int{ + DeviceLimitSettingKey: 99, + SMSHourlyLimitKey: 99, + } { + value, _ := json.Marshal(map[string]int{"limit": limit}) + if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: key, Value: value}); err != nil { + t.Fatal(err) + } + } + if got := DeviceLimit(ctx, database, true); got != MaxDeviceLimit { + t.Fatalf("device limit = %d, want %d", got, MaxDeviceLimit) + } + if got := SMSHourlyLimit(ctx, database); got != MaxSMSHourlyLimit { + t.Fatalf("SMS hourly limit = %d, want %d", got, MaxSMSHourlyLimit) } } diff --git a/internal/server/automatic_tasks.go b/internal/server/automatic_tasks.go index e9d6b13..e0babee 100644 --- a/internal/server/automatic_tasks.go +++ b/internal/server/automatic_tasks.go @@ -83,7 +83,13 @@ func (scheduler *automaticTaskScheduler) run() { } func (scheduler *automaticTaskScheduler) claim() { - runs, err := scheduler.server.store.ClaimDueAutomaticTasks(scheduler.ctx, time.Now().UTC(), 50) + var runs []store.AutomaticTaskRun + var err error + if scheduler.server.developerActive(scheduler.ctx) { + runs, err = scheduler.server.store.ClaimDueAutomaticTasks(scheduler.ctx, time.Now().UTC(), 50) + } else { + runs, err = scheduler.server.store.ClaimDueAvailableAutomaticTasks(scheduler.ctx, time.Now().UTC(), 50) + } if err != nil { scheduler.server.logger.Warn("claim automatic tasks", "error", err) return @@ -127,6 +133,11 @@ func (scheduler *automaticTaskScheduler) execute(run store.AutomaticTaskRun) { _ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run) return } + if err := validateAutomaticTaskAvailability(scheduler.server.developerActive(scheduler.ctx), task.TaskType, task.Environment); err != nil { + run.Status, run.Error, run.FinishedAt = "failed", err.Error(), time.Now().UTC() + _ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run) + return + } run.Status, run.StartedAt = "running", time.Now().UTC() _ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run) var output string @@ -175,6 +186,9 @@ func (scheduler *automaticTaskScheduler) execute(run store.AutomaticTaskRun) { } func (s *Server) executeAutomaticTask(ctx context.Context, task store.AutomaticTask, progress automaticTaskProgress) (output string, err error) { + if err := validateAutomaticTaskAvailability(s.developerActive(ctx), task.TaskType, task.Environment); err != nil { + return "", automaticTaskExecutionError{err: err, retryable: false} + } progress("正在检查设备和 eSIM Profile") config, entry, physicalID, err := s.ensureAutomaticTaskProfile(ctx, task, progress) if err != nil { @@ -359,9 +373,6 @@ func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *st return err } if task.TaskType == "public_ip" { - if !s.developerActive(ctx) { - return errors.New("roaming public IP tasks require developer mode") - } progress("已注册蜂窝网络,正在建立数据连接") if _, err := s.devices.SetNetwork(ctx, physicalID, s.cardNetworkRequest(ctx, physicalID, *config, policy, true)); err != nil { return fmt.Errorf("start roaming data: %w", err) @@ -659,6 +670,15 @@ func (s *Server) handleAutomaticTasks(w http.ResponseWriter, r *http.Request) { s.writeStoreError(w, err) return } + if !s.developerActive(r.Context()) { + visible := tasks[:0] + for _, task := range tasks { + if validateAutomaticTaskAvailability(false, task.TaskType, task.Environment) == nil { + visible = append(visible, task) + } + } + tasks = visible + } writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"tasks": tasks}}) case http.MethodPost: task, err := s.decodeAutomaticTask(r, 0) @@ -711,7 +731,14 @@ func (s *Server) handleAutomaticTaskRuns(w http.ResponseWriter, r *http.Request) query := r.URL.Query() limit, _ := strconv.Atoi(query.Get("limit")) offset, _ := strconv.Atoi(query.Get("offset")) - runs, total, err := s.store.ListAutomaticTaskRunsPaginated(r.Context(), limit, offset) + var runs []store.AutomaticTaskRun + var total int + var err error + if s.developerActive(r.Context()) { + runs, total, err = s.store.ListAutomaticTaskRunsPaginated(r.Context(), limit, offset) + } else { + runs, total, err = s.store.ListAvailableAutomaticTaskRunsPaginated(r.Context(), limit, offset) + } if err != nil { s.writeStoreError(w, err) return @@ -741,6 +768,10 @@ func (s *Server) handleAutomaticTaskRunNow(w http.ResponseWriter, r *http.Reques writeError(w, http.StatusConflict, "wifi_calling_only_device", err.Error()) return } + if err := validateAutomaticTaskAvailability(s.developerActive(r.Context()), task.TaskType, task.Environment); err != nil { + writeError(w, http.StatusNotFound, "task_unavailable", err.Error()) + return + } run, err := s.store.QueueAutomaticTaskNow(r.Context(), task) if err != nil { s.writeStoreError(w, err) @@ -789,6 +820,9 @@ func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.Automatic if request.TaskType == "public_ip" && request.Environment != "cellular" { return store.AutomaticTask{}, errors.New("public IP tasks must use cellular direct mode") } + if err := validateAutomaticTaskAvailability(s.developerActive(r.Context()), request.TaskType, request.Environment); err != nil { + return store.AutomaticTask{}, err + } if err := validateAutomaticTaskDeviceCapabilities(selectedDevice, request.TaskType, request.Environment); err != nil { return store.AutomaticTask{}, err } @@ -831,6 +865,13 @@ func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.Automatic return task, nil } +func validateAutomaticTaskAvailability(available bool, taskType, environment string) error { + if !available && (taskType == "public_ip" || environment == "cellular") { + return errors.New("unsupported task type or environment") + } + return nil +} + func validateAutomaticTaskDeviceCapabilities(config store.Device, taskType, environment string) error { if config.DeviceType != store.DeviceTypeUSBSIMReader { return nil diff --git a/internal/server/automatic_tasks_test.go b/internal/server/automatic_tasks_test.go index 230fa69..ece9321 100644 --- a/internal/server/automatic_tasks_test.go +++ b/internal/server/automatic_tasks_test.go @@ -39,6 +39,26 @@ func TestUSBSIMReaderAutomaticTasksRequireVoWiFi(t *testing.T) { } } +func TestAutomaticTaskAvailabilityHidesRestrictedPaths(t *testing.T) { + for _, test := range []struct { + available bool + taskType string + environment string + wantError bool + }{ + {false, "sms", "vowifi", false}, + {false, "call", "vowifi", false}, + {false, "sms", "cellular", true}, + {false, "public_ip", "cellular", true}, + {true, "public_ip", "cellular", false}, + } { + err := validateAutomaticTaskAvailability(test.available, test.taskType, test.environment) + if (err != nil) != test.wantError { + t.Fatalf("availability(%v, %q, %q) = %v", test.available, test.taskType, test.environment, err) + } + } +} + func TestAutomaticSMSRetrySafetyPreventsDuplicateSubmission(t *testing.T) { unsafe := []byte(`{"data":{"parts_attempted":1,"parts_accepted":1,"retry_safe":false}}`) if automaticSMSRetrySafe(unsafe) { diff --git a/internal/server/developer_settings_test.go b/internal/server/developer_settings_test.go index c5b198e..7eb1851 100644 --- a/internal/server/developer_settings_test.go +++ b/internal/server/developer_settings_test.go @@ -39,14 +39,14 @@ func TestDeveloperSettingsUpdatesGlobalSMSLimit(t *testing.T) { t.Fatal(err) } server := &Server{store: database, developerEnabled: true, logger: regionTestLogger(), maxRequestBodyBytes: 4096} - request := httptest.NewRequest(http.MethodPut, "/api/settings/developer", strings.NewReader(`{"sms_hourly_limit":25}`)) + request := httptest.NewRequest(http.MethodPut, "/api/settings/developer", strings.NewReader(`{"sms_hourly_limit":17}`)) request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() server.handleDeveloperSettings(response, request) if response.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", response.Code, response.Body.String()) } - if got := developer.SMSHourlyLimit(ctx, database); got != 25 { - t.Fatalf("SMS hourly limit = %d, want 25", got) + if got := developer.SMSHourlyLimit(ctx, database); got != 17 { + t.Fatalf("SMS hourly limit = %d, want 17", got) } } diff --git a/internal/store/automatic_tasks.go b/internal/store/automatic_tasks.go index e052e72..a046f0f 100644 --- a/internal/store/automatic_tasks.go +++ b/internal/store/automatic_tasks.go @@ -99,6 +99,16 @@ func (s *Store) DeleteAutomaticTask(ctx context.Context, id int64) error { } func (s *Store) ClaimDueAutomaticTasks(ctx context.Context, now time.Time, limit int) ([]AutomaticTaskRun, error) { + return s.claimDueAutomaticTasks(ctx, now, limit, false) +} + +// ClaimDueAvailableAutomaticTasks excludes task types and environments that +// are not exposed in the standard product surface. +func (s *Store) ClaimDueAvailableAutomaticTasks(ctx context.Context, now time.Time, limit int) ([]AutomaticTaskRun, error) { + return s.claimDueAutomaticTasks(ctx, now, limit, true) +} + +func (s *Store) claimDueAutomaticTasks(ctx context.Context, now time.Time, limit int, availableOnly bool) ([]AutomaticTaskRun, error) { if limit <= 0 || limit > 100 { limit = 50 } @@ -107,8 +117,12 @@ func (s *Store) ClaimDueAutomaticTasks(ctx context.Context, now time.Time, limit return nil, err } defer tx.Rollback() + availability := "" + if availableOnly { + availability = " AND task_type <> 'public_ip' AND environment <> 'cellular'" + } rows, err := tx.QueryContext(ctx, automaticTaskSelect+` - WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at, id LIMIT ?`, now.Unix(), limit) + WHERE enabled = 1 AND next_run_at <= ?`+availability+` ORDER BY next_run_at, id LIMIT ?`, now.Unix(), limit) if err != nil { return nil, err } @@ -236,6 +250,19 @@ func (s *Store) ListAutomaticTaskRuns(ctx context.Context, limit int) ([]Automat // the total run count, so the UI can page through the full history instead of // a fixed recent window. func (s *Store) ListAutomaticTaskRunsPaginated(ctx context.Context, limit, offset int) ([]AutomaticTaskRun, int, error) { + return s.listAutomaticTaskRunsPaginated(ctx, limit, offset, "") +} + +// ListAvailableAutomaticTaskRunsPaginated omits history belonging to task +// types and environments that are not exposed in the standard product surface. +func (s *Store) ListAvailableAutomaticTaskRunsPaginated(ctx context.Context, limit, offset int) ([]AutomaticTaskRun, int, error) { + const where = ` WHERE task_id IN ( + SELECT id FROM automatic_tasks WHERE task_type <> 'public_ip' AND environment <> 'cellular' + )` + return s.listAutomaticTaskRunsPaginated(ctx, limit, offset, where) +} + +func (s *Store) listAutomaticTaskRunsPaginated(ctx context.Context, limit, offset int, where string) ([]AutomaticTaskRun, int, error) { if limit <= 0 { limit = 20 } @@ -246,10 +273,10 @@ func (s *Store) ListAutomaticTaskRunsPaginated(ctx context.Context, limit, offse offset = 0 } total := 0 - if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM automatic_task_runs`).Scan(&total); err != nil { + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM automatic_task_runs`+where).Scan(&total); err != nil { return nil, 0, fmt.Errorf("count automatic task runs: %w", err) } - rows, err := s.db.QueryContext(ctx, automaticTaskRunSelect+` ORDER BY id DESC LIMIT ? OFFSET ?`, limit, offset) + rows, err := s.db.QueryContext(ctx, automaticTaskRunSelect+where+` ORDER BY id DESC LIMIT ? OFFSET ?`, limit, offset) if err != nil { return nil, 0, err } diff --git a/internal/store/automatic_tasks_test.go b/internal/store/automatic_tasks_test.go index 7b75500..b5030f6 100644 --- a/internal/store/automatic_tasks_test.go +++ b/internal/store/automatic_tasks_test.go @@ -120,6 +120,54 @@ func TestListAutomaticTaskRunsPaginated(t *testing.T) { } } +func TestAvailableAutomaticTasksExcludeRestrictedTaskAndRunHistory(t *testing.T) { + ctx := context.Background() + database := openTestStore(t, filepath.Join(t.TempDir(), "automatic-task-availability.db")) + mustSaveDevice(t, database, "ec20", "EC20") + now := time.Now().UTC().Truncate(time.Second) + save := func(name, taskType, environment string) AutomaticTask { + t.Helper() + task, err := database.SaveAutomaticTask(ctx, AutomaticTask{ + Name: name, Enabled: true, DeviceID: "ec20", ProfileICCID: "one", + TaskType: taskType, Environment: environment, IntervalDays: 1, + StartDate: "2026-08-10", RunTime: "12:00", Timezone: "Asia/Shanghai", + Payload: []byte(`{"phone":"10086","message":"test"}`), NextRunAt: now.Add(-time.Minute), + }) + if err != nil { + t.Fatal(err) + } + return task + } + visible := save("visible", "sms", "vowifi") + hidden := save("hidden", "public_ip", "cellular") + for _, task := range []AutomaticTask{visible, hidden} { + if _, err := database.QueueAutomaticTaskNow(ctx, task); err != nil { + t.Fatal(err) + } + } + runs, total, err := database.ListAvailableAutomaticTaskRunsPaginated(ctx, 20, 0) + if err != nil { + t.Fatal(err) + } + if total != 1 || len(runs) != 1 || runs[0].TaskID != visible.ID { + t.Fatalf("available history total=%d runs=%+v", total, runs) + } + claimed, err := database.ClaimDueAvailableAutomaticTasks(ctx, now, 10) + if err != nil { + t.Fatal(err) + } + if len(claimed) != 1 || claimed[0].TaskID != visible.ID { + t.Fatalf("available claims = %+v", claimed) + } + storedHidden, err := database.AutomaticTask(ctx, hidden.ID) + if err != nil { + t.Fatal(err) + } + if storedHidden.NextRunAt.After(now) { + t.Fatalf("restricted task schedule advanced unexpectedly: %v", storedHidden.NextRunAt) + } +} + func TestRecoverAutomaticTaskRunsFailsRunningAndReturnsQueued(t *testing.T) { ctx := context.Background() database := openTestStore(t, filepath.Join(t.TempDir(), "automatic-task-recovery.db")) diff --git a/web/src/components/devices/DeviceOverviewTab.tsx b/web/src/components/devices/DeviceOverviewTab.tsx index 1e9ea3f..664963a 100644 --- a/web/src/components/devices/DeviceOverviewTab.tsx +++ b/web/src/components/devices/DeviceOverviewTab.tsx @@ -27,9 +27,10 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) { const [operatorOpen, setOperatorOpen] = useState(false); const { device } = props; const wifiCallingOnly = device.deviceType === "usb_sim_reader"; + const showNetworkDetails = !!device.developerEnabled && !wifiCallingOnly; return (
{zh - ? `采用滚动一小时窗口,网页、TG Bot、自动任务、API、VoWiFi 与基站发送全部计入;接收短信不受限制。关闭开发者模式后恢复为 ${value?.defaultSmsHourlyLimit ?? 10} 条/小时。` - : `Uses a rolling one-hour window across the web UI, Telegram bot, automatic tasks, API, VoWiFi, and cellular sending. Receiving is unlimited. Disabling developer mode restores ${value?.defaultSmsHourlyLimit ?? 10} messages/hour.`} + ? "采用滚动一小时窗口,网页、TG Bot、自动任务、API、VoWiFi 与基站发送全部计入;接收短信不受限制。" + : "Uses a rolling one-hour window across the web UI, Telegram bot, automatic tasks, API, VoWiFi, and cellular sending. Receiving is unlimited."}