Enforce hard limits and hide unavailable task paths

This commit is contained in:
MengMengCode
2026-08-12 17:24:39 +08:00
parent 1380ffb419
commit 400f08c6c7
14 changed files with 217 additions and 44 deletions
+10 -4
View File
@@ -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
} }
+29 -5
View File
@@ -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)
} }
} }
+46 -5
View File
@@ -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
+20
View File
@@ -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) {
+3 -3
View File
@@ -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)
} }
} }
+30 -3
View File
@@ -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
} }
+48
View File
@@ -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"}
+2 -1
View File
@@ -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",
+17 -8
View File
@@ -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>
+2 -2
View File
@@ -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;