mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
Enforce hard limits and hide unavailable task paths
This commit is contained in:
@@ -27,9 +27,9 @@ const (
|
|||||||
DeviceLimitSettingKey = "developer.device_limit"
|
DeviceLimitSettingKey = "developer.device_limit"
|
||||||
SMSHourlyLimitKey = "developer.sms_hourly_limit"
|
SMSHourlyLimitKey = "developer.sms_hourly_limit"
|
||||||
DefaultDeviceLimit = 5
|
DefaultDeviceLimit = 5
|
||||||
MaxDeviceLimit = 128
|
MaxDeviceLimit = 10
|
||||||
DefaultSMSHourlyLimit = 10
|
DefaultSMSHourlyLimit = 10
|
||||||
MaxSMSHourlyLimit = 1000
|
MaxSMSHourlyLimit = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
func DeviceLimit(ctx context.Context, database *store.Store, enabled bool) int {
|
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 {
|
var document struct {
|
||||||
Limit int `json:"limit"`
|
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
|
return DefaultDeviceLimit
|
||||||
}
|
}
|
||||||
|
if document.Limit > MaxDeviceLimit {
|
||||||
|
return MaxDeviceLimit
|
||||||
|
}
|
||||||
return document.Limit
|
return document.Limit
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,9 +73,12 @@ func SMSHourlyLimit(ctx context.Context, database *store.Store) int {
|
|||||||
var document struct {
|
var document struct {
|
||||||
Limit int `json:"limit"`
|
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
|
return DefaultSMSHourlyLimit
|
||||||
}
|
}
|
||||||
|
if document.Limit > MaxSMSHourlyLimit {
|
||||||
|
return MaxSMSHourlyLimit
|
||||||
|
}
|
||||||
return document.Limit
|
return document.Limit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ func TestResetExperimentalRestoresDefaults(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer database.Close()
|
defer database.Close()
|
||||||
if err := SetDeviceLimit(ctx, database, 24); err != nil {
|
if err := SetDeviceLimit(ctx, database, 8); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := SetSMSHourlyLimit(ctx, database, 42); err != nil {
|
if err := SetSMSHourlyLimit(ctx, database, 18); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
enabled, _ := json.Marshal(map[string]bool{"enabled": true})
|
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 {
|
if SetSMSHourlyLimit(ctx, database, 0) == nil || SetSMSHourlyLimit(ctx, database, MaxSMSHourlyLimit+1) == nil {
|
||||||
t.Fatal("out-of-range SMS hourly limit was accepted")
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := SMSHourlyLimit(ctx, database); got != 25 {
|
if got := SMSHourlyLimit(ctx, database); got != 15 {
|
||||||
t.Fatalf("SMS hourly limit = %d, want 25", got)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,13 @@ func (scheduler *automaticTaskScheduler) run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (scheduler *automaticTaskScheduler) claim() {
|
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 {
|
if err != nil {
|
||||||
scheduler.server.logger.Warn("claim automatic tasks", "error", err)
|
scheduler.server.logger.Warn("claim automatic tasks", "error", err)
|
||||||
return
|
return
|
||||||
@@ -127,6 +133,11 @@ func (scheduler *automaticTaskScheduler) execute(run store.AutomaticTaskRun) {
|
|||||||
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
|
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
|
||||||
return
|
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()
|
run.Status, run.StartedAt = "running", time.Now().UTC()
|
||||||
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
|
_ = scheduler.server.store.UpdateAutomaticTaskRun(context.Background(), run)
|
||||||
var output string
|
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) {
|
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")
|
progress("正在检查设备和 eSIM Profile")
|
||||||
config, entry, physicalID, err := s.ensureAutomaticTaskProfile(ctx, task, progress)
|
config, entry, physicalID, err := s.ensureAutomaticTaskProfile(ctx, task, progress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -359,9 +373,6 @@ func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *st
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if task.TaskType == "public_ip" {
|
if task.TaskType == "public_ip" {
|
||||||
if !s.developerActive(ctx) {
|
|
||||||
return errors.New("roaming public IP tasks require developer mode")
|
|
||||||
}
|
|
||||||
progress("已注册蜂窝网络,正在建立数据连接")
|
progress("已注册蜂窝网络,正在建立数据连接")
|
||||||
if _, err := s.devices.SetNetwork(ctx, physicalID, s.cardNetworkRequest(ctx, physicalID, *config, policy, true)); err != nil {
|
if _, err := s.devices.SetNetwork(ctx, physicalID, s.cardNetworkRequest(ctx, physicalID, *config, policy, true)); err != nil {
|
||||||
return fmt.Errorf("start roaming data: %w", err)
|
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)
|
s.writeStoreError(w, err)
|
||||||
return
|
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}})
|
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"tasks": tasks}})
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
task, err := s.decodeAutomaticTask(r, 0)
|
task, err := s.decodeAutomaticTask(r, 0)
|
||||||
@@ -711,7 +731,14 @@ func (s *Server) handleAutomaticTaskRuns(w http.ResponseWriter, r *http.Request)
|
|||||||
query := r.URL.Query()
|
query := r.URL.Query()
|
||||||
limit, _ := strconv.Atoi(query.Get("limit"))
|
limit, _ := strconv.Atoi(query.Get("limit"))
|
||||||
offset, _ := strconv.Atoi(query.Get("offset"))
|
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 {
|
if err != nil {
|
||||||
s.writeStoreError(w, err)
|
s.writeStoreError(w, err)
|
||||||
return
|
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())
|
writeError(w, http.StatusConflict, "wifi_calling_only_device", err.Error())
|
||||||
return
|
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)
|
run, err := s.store.QueueAutomaticTaskNow(r.Context(), task)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeStoreError(w, err)
|
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" {
|
if request.TaskType == "public_ip" && request.Environment != "cellular" {
|
||||||
return store.AutomaticTask{}, errors.New("public IP tasks must use cellular direct mode")
|
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 {
|
if err := validateAutomaticTaskDeviceCapabilities(selectedDevice, request.TaskType, request.Environment); err != nil {
|
||||||
return store.AutomaticTask{}, err
|
return store.AutomaticTask{}, err
|
||||||
}
|
}
|
||||||
@@ -831,6 +865,13 @@ func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.Automatic
|
|||||||
return task, nil
|
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 {
|
func validateAutomaticTaskDeviceCapabilities(config store.Device, taskType, environment string) error {
|
||||||
if config.DeviceType != store.DeviceTypeUSBSIMReader {
|
if config.DeviceType != store.DeviceTypeUSBSIMReader {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -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) {
|
func TestAutomaticSMSRetrySafetyPreventsDuplicateSubmission(t *testing.T) {
|
||||||
unsafe := []byte(`{"data":{"parts_attempted":1,"parts_accepted":1,"retry_safe":false}}`)
|
unsafe := []byte(`{"data":{"parts_attempted":1,"parts_accepted":1,"retry_safe":false}}`)
|
||||||
if automaticSMSRetrySafe(unsafe) {
|
if automaticSMSRetrySafe(unsafe) {
|
||||||
|
|||||||
@@ -39,14 +39,14 @@ func TestDeveloperSettingsUpdatesGlobalSMSLimit(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
server := &Server{store: database, developerEnabled: true, logger: regionTestLogger(), maxRequestBodyBytes: 4096}
|
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")
|
request.Header.Set("Content-Type", "application/json")
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
server.handleDeveloperSettings(response, request)
|
server.handleDeveloperSettings(response, request)
|
||||||
if response.Code != http.StatusOK {
|
if response.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, body=%s", response.Code, response.Body.String())
|
t.Fatalf("status = %d, body=%s", response.Code, response.Body.String())
|
||||||
}
|
}
|
||||||
if got := developer.SMSHourlyLimit(ctx, database); got != 25 {
|
if got := developer.SMSHourlyLimit(ctx, database); got != 17 {
|
||||||
t.Fatalf("SMS hourly limit = %d, want 25", got)
|
t.Fatalf("SMS hourly limit = %d, want 17", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
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 {
|
if limit <= 0 || limit > 100 {
|
||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
@@ -107,8 +117,12 @@ func (s *Store) ClaimDueAutomaticTasks(ctx context.Context, now time.Time, limit
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
availability := ""
|
||||||
|
if availableOnly {
|
||||||
|
availability = " AND task_type <> 'public_ip' AND environment <> 'cellular'"
|
||||||
|
}
|
||||||
rows, err := tx.QueryContext(ctx, automaticTaskSelect+`
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// the total run count, so the UI can page through the full history instead of
|
||||||
// a fixed recent window.
|
// a fixed recent window.
|
||||||
func (s *Store) ListAutomaticTaskRunsPaginated(ctx context.Context, limit, offset int) ([]AutomaticTaskRun, int, error) {
|
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 {
|
if limit <= 0 {
|
||||||
limit = 20
|
limit = 20
|
||||||
}
|
}
|
||||||
@@ -246,10 +273,10 @@ func (s *Store) ListAutomaticTaskRunsPaginated(ctx context.Context, limit, offse
|
|||||||
offset = 0
|
offset = 0
|
||||||
}
|
}
|
||||||
total := 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)
|
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 {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestRecoverAutomaticTaskRunsFailsRunningAndReturnsQueued(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
database := openTestStore(t, filepath.Join(t.TempDir(), "automatic-task-recovery.db"))
|
database := openTestStore(t, filepath.Join(t.TempDir(), "automatic-task-recovery.db"))
|
||||||
|
|||||||
@@ -27,9 +27,10 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
|
|||||||
const [operatorOpen, setOperatorOpen] = useState(false);
|
const [operatorOpen, setOperatorOpen] = useState(false);
|
||||||
const { device } = props;
|
const { device } = props;
|
||||||
const wifiCallingOnly = device.deviceType === "usb_sim_reader";
|
const wifiCallingOnly = device.deviceType === "usb_sim_reader";
|
||||||
|
const showNetworkDetails = !!device.developerEnabled && !wifiCallingOnly;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className={`grid grid-cols-1 gap-4 ${wifiCallingOnly ? "lg:grid-cols-2" : "lg:grid-cols-3"}`}>
|
<div className={`grid grid-cols-1 gap-4 ${showNetworkDetails ? "lg:grid-cols-3" : "lg:grid-cols-2"}`}>
|
||||||
<div className="ui-panel-muted p-4">
|
<div className="ui-panel-muted p-4">
|
||||||
<div className="mb-3 text-xs font-bold uppercase tracking-wider text-gray-500">{t("运行状态")}</div>
|
<div className="mb-3 text-xs font-bold uppercase tracking-wider text-gray-500">{t("运行状态")}</div>
|
||||||
{isVoWiFiInUse(device) && !(device.modem?.imei && device.modem?.simInserted === false) ? (
|
{isVoWiFiInUse(device) && !(device.modem?.imei && device.modem?.simInserted === false) ? (
|
||||||
@@ -45,7 +46,7 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
|
|||||||
e911Starting={props.e911Starting}
|
e911Starting={props.e911Starting}
|
||||||
onSetupE911={props.onSetupE911}
|
onSetupE911={props.onSetupE911}
|
||||||
/>
|
/>
|
||||||
{!wifiCallingOnly ? <OverviewNetworkPanel
|
{showNetworkDetails ? <OverviewNetworkPanel
|
||||||
device={device}
|
device={device}
|
||||||
trafficMinuteRx={props.trafficMinuteRx}
|
trafficMinuteRx={props.trafficMinuteRx}
|
||||||
trafficMinuteTx={props.trafficMinuteTx}
|
trafficMinuteTx={props.trafficMinuteTx}
|
||||||
|
|||||||
@@ -77,11 +77,7 @@ export function OverviewNetworkPanel({ device, trafficMinuteRx, trafficMinuteTx,
|
|||||||
: "";
|
: "";
|
||||||
|
|
||||||
if (!developerActive) {
|
if (!developerActive) {
|
||||||
return (
|
return null;
|
||||||
<div className="ui-panel-muted p-4">
|
|
||||||
<div className="text-xs font-bold uppercase tracking-wider text-gray-500">{t("网络")}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function DeviceQuotaCard({
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={value?.maxDeviceLimit ?? 128}
|
max={value?.maxDeviceLimit ?? 10}
|
||||||
value={Number.isFinite(limit) ? limit : ""}
|
value={Number.isFinite(limit) ? limit : ""}
|
||||||
disabled={loading || saving}
|
disabled={loading || saving}
|
||||||
onChange={(event) => onLimitChange(Number(event.target.value))}
|
onChange={(event) => onLimitChange(Number(event.target.value))}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function SMSRateLimitCard({
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={value?.maxSmsHourlyLimit ?? 1000}
|
max={value?.maxSmsHourlyLimit ?? 20}
|
||||||
value={Number.isFinite(limit) ? limit : ""}
|
value={Number.isFinite(limit) ? limit : ""}
|
||||||
disabled={loading || saving}
|
disabled={loading || saving}
|
||||||
onChange={(event) => onLimitChange(Number(event.target.value))}
|
onChange={(event) => onLimitChange(Number(event.target.value))}
|
||||||
@@ -46,8 +46,8 @@ export function SMSRateLimitCard({
|
|||||||
/>
|
/>
|
||||||
<p className="text-xs leading-5 text-gray-500 dark:text-gray-400">
|
<p className="text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||||
{zh
|
{zh
|
||||||
? `采用滚动一小时窗口,网页、TG Bot、自动任务、API、VoWiFi 与基站发送全部计入;接收短信不受限制。关闭开发者模式后恢复为 ${value?.defaultSmsHourlyLimit ?? 10} 条/小时。`
|
? "采用滚动一小时窗口,网页、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. Disabling developer mode restores ${value?.defaultSmsHourlyLimit ?? 10} messages/hour.`}
|
: "Uses a rolling one-hour window across the web UI, Telegram bot, automatic tasks, API, VoWiFi, and cellular sending. Receiving is unlimited."}
|
||||||
</p>
|
</p>
|
||||||
<Button variant="primary" loading={saving} disabled={loading} onClick={onSave} className="w-full !border-0">
|
<Button variant="primary" loading={saving} disabled={loading} onClick={onSave} className="w-full !border-0">
|
||||||
{zh ? "保存短信速率限制" : "Save SMS rate limit"}
|
{zh ? "保存短信速率限制" : "Save SMS rate limit"}
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ export const EN_DICT: Record<string, string> = {
|
|||||||
短信检测: "SMS Test",
|
短信检测: "SMS Test",
|
||||||
自动任务: "Automatic Tasks",
|
自动任务: "Automatic Tasks",
|
||||||
"按周期切换指定 eSIM Profile,并在设备串行队列中执行短信、通话或漫游公网 IP 任务": "Switch to a selected eSIM profile on schedule, then run SMS, call, or roaming public-IP jobs in a per-device queue",
|
"按周期切换指定 eSIM Profile,并在设备串行队列中执行短信、通话或漫游公网 IP 任务": "Switch to a selected eSIM profile on schedule, then run SMS, call, or roaming public-IP jobs in a per-device queue",
|
||||||
|
"按周期切换指定 eSIM Profile,并在设备串行队列中执行短信或通话任务": "Switch to a selected eSIM profile on schedule, then run SMS or call jobs in a per-device queue",
|
||||||
添加任务: "Add Task",
|
添加任务: "Add Task",
|
||||||
"设备 / Profile": "Device / Profile",
|
"设备 / Profile": "Device / Profile",
|
||||||
执行环境: "Environment",
|
执行环境: "Environment",
|
||||||
@@ -193,7 +194,7 @@ export const EN_DICT: Record<string, string> = {
|
|||||||
任务类型: "Task Type",
|
任务类型: "Task Type",
|
||||||
开启漫游流量并获取一次公网IP: "Enable roaming data and get the public IP once",
|
开启漫游流量并获取一次公网IP: "Enable roaming data and get the public IP once",
|
||||||
"基站直连(自动选网)": "Cellular (automatic network selection)",
|
"基站直连(自动选网)": "Cellular (automatic network selection)",
|
||||||
"该任务固定使用基站直连和自动选网;执行时会开启漫游数据,并通过模块接口访问 ipinfo.io。需要开启开发者模式。": "This task always uses cellular direct mode with automatic network selection. It enables roaming data and accesses ipinfo.io through the modem interface. Developer mode is required.",
|
"该任务固定使用基站直连和自动选网;执行时会开启漫游数据,并通过模块接口访问 ipinfo.io。": "This task always uses cellular direct mode with automatic network selection. It enables roaming data and accesses ipinfo.io through the modem interface.",
|
||||||
首次执行日期: "First Run Date",
|
首次执行日期: "First Run Date",
|
||||||
执行时间: "Run Time",
|
执行时间: "Run Time",
|
||||||
执行周期: "Interval",
|
执行周期: "Interval",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
SendClockRegular,
|
SendClockRegular,
|
||||||
} from "@fluentui/react-icons";
|
} from "@fluentui/react-icons";
|
||||||
import { api, apiMessage } from "../api";
|
import { api, apiMessage } from "../api";
|
||||||
import type { DeviceListItem, DevicesResponse } from "../types";
|
import type { DeviceListItem, DevicesResponse, SystemInfo } from "../types";
|
||||||
import type { EsimProfileGroup } from "../components/devices/types";
|
import type { EsimProfileGroup } from "../components/devices/types";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -140,6 +140,7 @@ export default function AutomaticTasksPage() {
|
|||||||
const [runsPage, setRunsPage] = useState(1);
|
const [runsPage, setRunsPage] = useState(1);
|
||||||
const [runsPageSize, setRunsPageSize] = useState(20);
|
const [runsPageSize, setRunsPageSize] = useState(20);
|
||||||
const [devices, setDevices] = useState<DeviceListItem[]>([]);
|
const [devices, setDevices] = useState<DeviceListItem[]>([]);
|
||||||
|
const [advancedTasksAvailable, setAdvancedTasksAvailable] = useState(false);
|
||||||
const [profiles, setProfiles] = useState<ProfileOption[]>([]);
|
const [profiles, setProfiles] = useState<ProfileOption[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [profileLoading, setProfileLoading] = useState(false);
|
const [profileLoading, setProfileLoading] = useState(false);
|
||||||
@@ -155,12 +156,14 @@ export default function AutomaticTasksPage() {
|
|||||||
const load = useCallback(async (initial = false) => {
|
const load = useCallback(async (initial = false) => {
|
||||||
if (initial) setLoading(true);
|
if (initial) setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [taskData, deviceData] = await Promise.all([
|
const [taskData, deviceData, systemInfo] = await Promise.all([
|
||||||
api<{ tasks?: AutomaticTask[] }>("/automatic-tasks"),
|
api<{ tasks?: AutomaticTask[] }>("/automatic-tasks"),
|
||||||
api<DevicesResponse>("/devices"),
|
api<DevicesResponse>("/devices"),
|
||||||
|
api<SystemInfo>("/system/info"),
|
||||||
]);
|
]);
|
||||||
setTasks(taskData.tasks || []);
|
setTasks(taskData.tasks || []);
|
||||||
setDevices(deviceData.devices || []);
|
setDevices(deviceData.devices || []);
|
||||||
|
setAdvancedTasksAvailable(!!systemInfo.developer);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(apiMessage(error));
|
message.error(apiMessage(error));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -272,6 +275,9 @@ export default function AutomaticTasksPage() {
|
|||||||
if (devices.find((device) => device.id === deviceId)?.deviceType === "usb_sim_reader") {
|
if (devices.find((device) => device.id === deviceId)?.deviceType === "usb_sim_reader") {
|
||||||
next = { ...next, taskType: next.taskType === "public_ip" ? "sms" : next.taskType, environment: "vowifi" };
|
next = { ...next, taskType: next.taskType === "public_ip" ? "sms" : next.taskType, environment: "vowifi" };
|
||||||
}
|
}
|
||||||
|
if (!advancedTasksAvailable && (next.taskType === "public_ip" || next.environment === "cellular")) {
|
||||||
|
next = { ...next, taskType: "sms", environment: "vowifi" };
|
||||||
|
}
|
||||||
setForm(next);
|
setForm(next);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
void loadProfiles(deviceId, next.profileIccid);
|
void loadProfiles(deviceId, next.profileIccid);
|
||||||
@@ -282,7 +288,7 @@ export default function AutomaticTasksPage() {
|
|||||||
setForm((current) => ({
|
setForm((current) => ({
|
||||||
...current, deviceId, profileIccid: "", profileAid: "",
|
...current, deviceId, profileIccid: "", profileAid: "",
|
||||||
taskType: reader && current.taskType === "public_ip" ? "sms" : current.taskType,
|
taskType: reader && current.taskType === "public_ip" ? "sms" : current.taskType,
|
||||||
environment: reader ? "vowifi" : current.environment,
|
environment: reader || !advancedTasksAvailable ? "vowifi" : current.environment,
|
||||||
}));
|
}));
|
||||||
void loadProfiles(deviceId);
|
void loadProfiles(deviceId);
|
||||||
}
|
}
|
||||||
@@ -293,7 +299,7 @@ export default function AutomaticTasksPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function chooseTaskType(taskType: TaskType) {
|
function chooseTaskType(taskType: TaskType) {
|
||||||
if (deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader" && taskType === "public_ip") return;
|
if ((!advancedTasksAvailable || deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader") && taskType === "public_ip") return;
|
||||||
setForm((current) => ({
|
setForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
taskType,
|
taskType,
|
||||||
@@ -308,6 +314,7 @@ export default function AutomaticTasksPage() {
|
|||||||
if (deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader" && (form.environment !== "vowifi" || form.taskType === "public_ip")) {
|
if (deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader" && (form.environment !== "vowifi" || form.taskType === "public_ip")) {
|
||||||
return message.warning(t("USB SIM读卡器仅支持VoWiFi短信和通话任务"));
|
return message.warning(t("USB SIM读卡器仅支持VoWiFi短信和通话任务"));
|
||||||
}
|
}
|
||||||
|
if (!advancedTasksAvailable && (form.environment !== "vowifi" || form.taskType === "public_ip")) return;
|
||||||
if (form.taskType !== "public_ip" && !form.phone.trim()) return message.warning(t("请输入号码"));
|
if (form.taskType !== "public_ip" && !form.phone.trim()) return message.warning(t("请输入号码"));
|
||||||
if (form.taskType === "sms" && !form.message.trim()) return message.warning(t("请输入短信内容"));
|
if (form.taskType === "sms" && !form.message.trim()) return message.warning(t("请输入短信内容"));
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -393,9 +400,9 @@ export default function AutomaticTasksPage() {
|
|||||||
const taskTypeOptions = [
|
const taskTypeOptions = [
|
||||||
{ value: "sms", label: t("发送短信") },
|
{ value: "sms", label: t("发送短信") },
|
||||||
{ value: "call", label: t("拨打电话并自动挂断") },
|
{ value: "call", label: t("拨打电话并自动挂断") },
|
||||||
...(!selectedTaskDeviceIsReader ? [{ value: "public_ip", label: t("开启漫游流量并获取一次公网 IP") }] : []),
|
...(advancedTasksAvailable && !selectedTaskDeviceIsReader ? [{ value: "public_ip", label: t("开启漫游流量并获取一次公网 IP") }] : []),
|
||||||
];
|
];
|
||||||
const environmentOptions = selectedTaskDeviceIsReader
|
const environmentOptions = selectedTaskDeviceIsReader || !advancedTasksAvailable
|
||||||
? [{ value: "vowifi", label: "VoWiFi" }]
|
? [{ value: "vowifi", label: "VoWiFi" }]
|
||||||
: [{ value: "vowifi", label: "VoWiFi" }, { value: "cellular", label: t("基站直连(自动选网)") }];
|
: [{ value: "vowifi", label: "VoWiFi" }, { value: "cellular", label: t("基站直连(自动选网)") }];
|
||||||
|
|
||||||
@@ -403,7 +410,9 @@ export default function AutomaticTasksPage() {
|
|||||||
<div className="mx-auto max-w-7xl">
|
<div className="mx-auto max-w-7xl">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={t("自动任务")}
|
title={t("自动任务")}
|
||||||
subtitle={t("按周期切换指定 eSIM Profile,并在设备串行队列中执行短信、通话或漫游公网 IP 任务")}
|
subtitle={advancedTasksAvailable
|
||||||
|
? t("按周期切换指定 eSIM Profile,并在设备串行队列中执行短信、通话或漫游公网 IP 任务")
|
||||||
|
: t("按周期切换指定 eSIM Profile,并在设备串行队列中执行短信或通话任务")}
|
||||||
actions={<Button variant="primary" icon={<AddRegular />} onClick={() => edit()} disabled={!devices.length}>{t("添加任务")}</Button>}
|
actions={<Button variant="primary" icon={<AddRegular />} onClick={() => edit()} disabled={!devices.length}>{t("添加任务")}</Button>}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -501,7 +510,7 @@ export default function AutomaticTasksPage() {
|
|||||||
{form.taskType !== "public_ip" ? <div><label className={fieldLabel}>{t("号码")}</label><Input value={form.phone} onChange={(event) => setForm({ ...form, phone: event.target.value })} placeholder="+447700900123" /></div> : null}
|
{form.taskType !== "public_ip" ? <div><label className={fieldLabel}>{t("号码")}</label><Input value={form.phone} onChange={(event) => setForm({ ...form, phone: event.target.value })} placeholder="+447700900123" /></div> : null}
|
||||||
{form.taskType === "call" ? <div><label className={fieldLabel}>{t("自动挂断")}</label><Input type="number" min={1} max={600} value={form.durationSeconds} suffix="s" onChange={(event) => setForm({ ...form, durationSeconds: Number(event.target.value) })} /></div> : null}
|
{form.taskType === "call" ? <div><label className={fieldLabel}>{t("自动挂断")}</label><Input type="number" min={1} max={600} value={form.durationSeconds} suffix="s" onChange={(event) => setForm({ ...form, durationSeconds: Number(event.target.value) })} /></div> : null}
|
||||||
{form.taskType === "sms" ? <div className="md:col-span-2"><label className={fieldLabel}>{t("短信内容")}</label><Textarea rows={4} value={form.message} onChange={(event) => setForm({ ...form, message: event.target.value })} /></div> : null}
|
{form.taskType === "sms" ? <div className="md:col-span-2"><label className={fieldLabel}>{t("短信内容")}</label><Textarea rows={4} value={form.message} onChange={(event) => setForm({ ...form, message: event.target.value })} /></div> : null}
|
||||||
{form.taskType === "public_ip" ? <div className="md:col-span-2 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-300">{t("该任务固定使用基站直连和自动选网;执行时会开启漫游数据,并通过模块接口访问 ipinfo.io。需要开启开发者模式。")}</div> : null}
|
{advancedTasksAvailable && form.taskType === "public_ip" ? <div className="md:col-span-2 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-300">{t("该任务固定使用基站直连和自动选网;执行时会开启漫游数据,并通过模块接口访问 ipinfo.io。")}</div> : null}
|
||||||
|
|
||||||
<div><label className={fieldLabel}>{t("首次执行日期")}</label><Input type="date" value={form.startDate} onChange={(event) => setForm({ ...form, startDate: event.target.value })} /></div>
|
<div><label className={fieldLabel}>{t("首次执行日期")}</label><Input type="date" value={form.startDate} onChange={(event) => setForm({ ...form, startDate: event.target.value })} /></div>
|
||||||
<div><label className={fieldLabel}>{t("执行时间")}</label><Input type="time" value={form.runTime} onChange={(event) => setForm({ ...form, runTime: event.target.value })} /></div>
|
<div><label className={fieldLabel}>{t("执行时间")}</label><Input type="time" value={form.runTime} onChange={(event) => setForm({ ...form, runTime: event.target.value })} /></div>
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export default function SettingsPage() {
|
|||||||
}, [lang]);
|
}, [lang]);
|
||||||
|
|
||||||
const onSaveDeviceLimit = useCallback(async () => {
|
const onSaveDeviceLimit = useCallback(async () => {
|
||||||
const maximum = developerSettings?.maxDeviceLimit ?? 128;
|
const maximum = developerSettings?.maxDeviceLimit ?? 10;
|
||||||
if (!Number.isInteger(deviceLimit) || deviceLimit < 1 || deviceLimit > maximum) {
|
if (!Number.isInteger(deviceLimit) || deviceLimit < 1 || deviceLimit > maximum) {
|
||||||
message.error(lang === "zh" ? `设备配额必须是 1 到 ${maximum} 的整数` : `Device quota must be an integer between 1 and ${maximum}`);
|
message.error(lang === "zh" ? `设备配额必须是 1 到 ${maximum} 的整数` : `Device quota must be an integer between 1 and ${maximum}`);
|
||||||
return;
|
return;
|
||||||
@@ -196,7 +196,7 @@ export default function SettingsPage() {
|
|||||||
}, [developerSettings, deviceLimit, lang]);
|
}, [developerSettings, deviceLimit, lang]);
|
||||||
|
|
||||||
const onSaveSMSHourlyLimit = useCallback(async () => {
|
const onSaveSMSHourlyLimit = useCallback(async () => {
|
||||||
const maximum = developerSettings?.maxSmsHourlyLimit ?? 1000;
|
const maximum = developerSettings?.maxSmsHourlyLimit ?? 20;
|
||||||
if (!Number.isInteger(smsHourlyLimit) || smsHourlyLimit < 1 || smsHourlyLimit > maximum) {
|
if (!Number.isInteger(smsHourlyLimit) || smsHourlyLimit < 1 || smsHourlyLimit > maximum) {
|
||||||
message.error(lang === "zh" ? `短信发送限制必须是 1 到 ${maximum} 的整数` : `SMS limit must be an integer between 1 and ${maximum}`);
|
message.error(lang === "zh" ? `短信发送限制必须是 1 到 ${maximum} 的整数` : `SMS limit must be an integer between 1 and ${maximum}`);
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user