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"
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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 (
|
||||
<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="mb-3 text-xs font-bold uppercase tracking-wider text-gray-500">{t("运行状态")}</div>
|
||||
{isVoWiFiInUse(device) && !(device.modem?.imei && device.modem?.simInserted === false) ? (
|
||||
@@ -45,7 +46,7 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
|
||||
e911Starting={props.e911Starting}
|
||||
onSetupE911={props.onSetupE911}
|
||||
/>
|
||||
{!wifiCallingOnly ? <OverviewNetworkPanel
|
||||
{showNetworkDetails ? <OverviewNetworkPanel
|
||||
device={device}
|
||||
trafficMinuteRx={props.trafficMinuteRx}
|
||||
trafficMinuteTx={props.trafficMinuteTx}
|
||||
|
||||
@@ -77,11 +77,7 @@ export function OverviewNetworkPanel({ device, trafficMinuteRx, trafficMinuteTx,
|
||||
: "";
|
||||
|
||||
if (!developerActive) {
|
||||
return (
|
||||
<div className="ui-panel-muted p-4">
|
||||
<div className="text-xs font-bold uppercase tracking-wider text-gray-500">{t("网络")}</div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -38,7 +38,7 @@ export function DeviceQuotaCard({
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={value?.maxDeviceLimit ?? 128}
|
||||
max={value?.maxDeviceLimit ?? 10}
|
||||
value={Number.isFinite(limit) ? limit : ""}
|
||||
disabled={loading || saving}
|
||||
onChange={(event) => onLimitChange(Number(event.target.value))}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function SMSRateLimitCard({
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={value?.maxSmsHourlyLimit ?? 1000}
|
||||
max={value?.maxSmsHourlyLimit ?? 20}
|
||||
value={Number.isFinite(limit) ? limit : ""}
|
||||
disabled={loading || saving}
|
||||
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">
|
||||
{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."}
|
||||
</p>
|
||||
<Button variant="primary" loading={saving} disabled={loading} onClick={onSave} className="w-full !border-0">
|
||||
{zh ? "保存短信速率限制" : "Save SMS rate limit"}
|
||||
|
||||
@@ -157,6 +157,7 @@ export const EN_DICT: Record<string, string> = {
|
||||
短信检测: "SMS Test",
|
||||
自动任务: "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,并在设备串行队列中执行短信或通话任务": "Switch to a selected eSIM profile on schedule, then run SMS or call jobs in a per-device queue",
|
||||
添加任务: "Add Task",
|
||||
"设备 / Profile": "Device / Profile",
|
||||
执行环境: "Environment",
|
||||
@@ -193,7 +194,7 @@ export const EN_DICT: Record<string, string> = {
|
||||
任务类型: "Task Type",
|
||||
开启漫游流量并获取一次公网IP: "Enable roaming data and get the public IP once",
|
||||
"基站直连(自动选网)": "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",
|
||||
执行时间: "Run Time",
|
||||
执行周期: "Interval",
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
SendClockRegular,
|
||||
} from "@fluentui/react-icons";
|
||||
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 {
|
||||
Button,
|
||||
@@ -140,6 +140,7 @@ export default function AutomaticTasksPage() {
|
||||
const [runsPage, setRunsPage] = useState(1);
|
||||
const [runsPageSize, setRunsPageSize] = useState(20);
|
||||
const [devices, setDevices] = useState<DeviceListItem[]>([]);
|
||||
const [advancedTasksAvailable, setAdvancedTasksAvailable] = useState(false);
|
||||
const [profiles, setProfiles] = useState<ProfileOption[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [profileLoading, setProfileLoading] = useState(false);
|
||||
@@ -155,12 +156,14 @@ export default function AutomaticTasksPage() {
|
||||
const load = useCallback(async (initial = false) => {
|
||||
if (initial) setLoading(true);
|
||||
try {
|
||||
const [taskData, deviceData] = await Promise.all([
|
||||
const [taskData, deviceData, systemInfo] = await Promise.all([
|
||||
api<{ tasks?: AutomaticTask[] }>("/automatic-tasks"),
|
||||
api<DevicesResponse>("/devices"),
|
||||
api<SystemInfo>("/system/info"),
|
||||
]);
|
||||
setTasks(taskData.tasks || []);
|
||||
setDevices(deviceData.devices || []);
|
||||
setAdvancedTasksAvailable(!!systemInfo.developer);
|
||||
} catch (error) {
|
||||
message.error(apiMessage(error));
|
||||
} finally {
|
||||
@@ -272,6 +275,9 @@ export default function AutomaticTasksPage() {
|
||||
if (devices.find((device) => device.id === deviceId)?.deviceType === "usb_sim_reader") {
|
||||
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);
|
||||
setOpen(true);
|
||||
void loadProfiles(deviceId, next.profileIccid);
|
||||
@@ -282,7 +288,7 @@ export default function AutomaticTasksPage() {
|
||||
setForm((current) => ({
|
||||
...current, deviceId, profileIccid: "", profileAid: "",
|
||||
taskType: reader && current.taskType === "public_ip" ? "sms" : current.taskType,
|
||||
environment: reader ? "vowifi" : current.environment,
|
||||
environment: reader || !advancedTasksAvailable ? "vowifi" : current.environment,
|
||||
}));
|
||||
void loadProfiles(deviceId);
|
||||
}
|
||||
@@ -293,7 +299,7 @@ export default function AutomaticTasksPage() {
|
||||
}
|
||||
|
||||
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) => ({
|
||||
...current,
|
||||
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")) {
|
||||
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 === "sms" && !form.message.trim()) return message.warning(t("请输入短信内容"));
|
||||
setSaving(true);
|
||||
@@ -393,9 +400,9 @@ export default function AutomaticTasksPage() {
|
||||
const taskTypeOptions = [
|
||||
{ value: "sms", 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: "cellular", label: t("基站直连(自动选网)") }];
|
||||
|
||||
@@ -403,7 +410,9 @@ export default function AutomaticTasksPage() {
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<PageHeader
|
||||
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>}
|
||||
/>
|
||||
|
||||
@@ -475,8 +484,8 @@ export default function AutomaticTasksPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!runs.length ? <div className="p-8 text-center text-sm text-gray-400">{t("暂无执行记录")}</div> : null}
|
||||
{runsTotal > 0 ? (
|
||||
{!runs.length ? <div className="p-8 text-center text-sm text-gray-400">{t("暂无执行记录")}</div> : null}
|
||||
{runsTotal > 0 ? (
|
||||
<div className="border-t border-gray-100 px-5 py-3 dark:border-white/10">
|
||||
<Pagination
|
||||
page={runsPage}
|
||||
@@ -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 === "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 === "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="time" value={form.runTime} onChange={(event) => setForm({ ...form, runTime: event.target.value })} /></div>
|
||||
|
||||
@@ -177,7 +177,7 @@ export default function SettingsPage() {
|
||||
}, [lang]);
|
||||
|
||||
const onSaveDeviceLimit = useCallback(async () => {
|
||||
const maximum = developerSettings?.maxDeviceLimit ?? 128;
|
||||
const maximum = developerSettings?.maxDeviceLimit ?? 10;
|
||||
if (!Number.isInteger(deviceLimit) || deviceLimit < 1 || deviceLimit > maximum) {
|
||||
message.error(lang === "zh" ? `设备配额必须是 1 到 ${maximum} 的整数` : `Device quota must be an integer between 1 and ${maximum}`);
|
||||
return;
|
||||
@@ -196,7 +196,7 @@ export default function SettingsPage() {
|
||||
}, [developerSettings, deviceLimit, lang]);
|
||||
|
||||
const onSaveSMSHourlyLimit = useCallback(async () => {
|
||||
const maximum = developerSettings?.maxSmsHourlyLimit ?? 1000;
|
||||
const maximum = developerSettings?.maxSmsHourlyLimit ?? 20;
|
||||
if (!Number.isInteger(smsHourlyLimit) || smsHourlyLimit < 1 || smsHourlyLimit > maximum) {
|
||||
message.error(lang === "zh" ? `短信发送限制必须是 1 到 ${maximum} 的整数` : `SMS limit must be an integer between 1 and ${maximum}`);
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user