From 4f6f7395df76ed9707e3861fe654d8acc8c8f17f Mon Sep 17 00:00:00 2001 From: ppanphper Date: Wed, 8 Apr 2026 09:54:41 +0800 Subject: [PATCH 1/3] feat(subscription): support batch create with urls array --- internal/api/contract_test.go | 51 ++++-- internal/api/major_flow_e2e_test.go | 4 +- ...subscription_batch_create_contract_test.go | 116 ++++++++++++ internal/api/subscription_cleanup_e2e_test.go | 4 +- .../api/subscription_local_contract_test.go | 4 +- internal/api/subscription_refresh_e2e_test.go | 6 +- .../service/control_plane_subscription.go | 170 +++++++++++++----- internal/state/repo_state.go | 103 ++++++++--- .../subscriptions/SubscriptionPage.tsx | 86 +++++++-- webui/src/features/subscriptions/api.ts | 15 +- webui/src/features/subscriptions/types.ts | 7 +- webui/src/i18n/translations.ts | 4 + 12 files changed, 465 insertions(+), 105 deletions(-) create mode 100644 internal/api/subscription_batch_create_contract_test.go diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 749ccf09..eda82434 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -163,6 +163,33 @@ func assertErrorCode(t *testing.T, rec *httptest.ResponseRecorder, code string) } } +func decodeCreatedSubscriptionItems(t *testing.T, rec *httptest.ResponseRecorder) []map[string]any { + t.Helper() + body := decodeJSONMap(t, rec) + rawItems, ok := body["items"].([]any) + if !ok { + t.Fatalf("create subscription response items type: got %T, body=%s", body["items"], rec.Body.String()) + } + items := make([]map[string]any, 0, len(rawItems)) + for i := range rawItems { + item, ok := rawItems[i].(map[string]any) + if !ok { + t.Fatalf("create subscription response items[%d] type: got %T", i, rawItems[i]) + } + items = append(items, item) + } + return items +} + +func decodeCreatedSubscriptionFirstItem(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { + t.Helper() + items := decodeCreatedSubscriptionItems(t, rec) + if len(items) == 0 { + t.Fatalf("create subscription response items is empty, body=%s", rec.Body.String()) + } + return items[0] +} + func mustCreatePlatform(t *testing.T, srv *Server, name string) string { t.Helper() @@ -803,14 +830,14 @@ func TestAPIContract_KeywordFilteringOnListEndpoints(t *testing.T) { rec = doJSONRequest(t, srv, http.MethodPost, "/api/v1/subscriptions", map[string]any{ "name": "Apple Feed", - "url": "https://example.com/apple", + "urls": []string{"https://example.com/apple"}, }, true) if rec.Code != http.StatusCreated { t.Fatalf("create subscription apple status: got %d, want %d, body=%s", rec.Code, http.StatusCreated, rec.Body.String()) } rec = doJSONRequest(t, srv, http.MethodPost, "/api/v1/subscriptions", map[string]any{ "name": "Banana Feed", - "url": "https://example.com/banana", + "urls": []string{"https://example.com/banana"}, }, true) if rec.Code != http.StatusCreated { t.Fatalf("create subscription banana status: got %d, want %d, body=%s", rec.Code, http.StatusCreated, rec.Body.String()) @@ -1179,7 +1206,7 @@ func TestAPIContract_ModuleAndActionEndpoints(t *testing.T) { // subscriptions rec = doJSONRequest(t, srv, http.MethodPost, "/api/v1/subscriptions", map[string]any{ "name": "sub-a", - "url": "https://example.com/sub", + "urls": []string{"https://example.com/sub"}, }, true) if rec.Code != http.StatusCreated { t.Fatalf("create subscription status: got %d, want %d, body=%s", rec.Code, http.StatusCreated, rec.Body.String()) @@ -1319,7 +1346,7 @@ func TestAPIContract_SubscriptionUpdateIntervalMinimum(t *testing.T) { rec := doJSONRequest(t, srv, http.MethodPost, "/api/v1/subscriptions", map[string]any{ "name": "too-fast", - "url": "https://example.com/sub-fast", + "urls": []string{"https://example.com/sub-fast"}, "update_interval": "10s", }, true) if rec.Code != http.StatusBadRequest { @@ -1329,12 +1356,12 @@ func TestAPIContract_SubscriptionUpdateIntervalMinimum(t *testing.T) { rec = doJSONRequest(t, srv, http.MethodPost, "/api/v1/subscriptions", map[string]any{ "name": "normal-sub", - "url": "https://example.com/sub-normal", + "urls": []string{"https://example.com/sub-normal"}, }, true) if rec.Code != http.StatusCreated { t.Fatalf("create subscription status: got %d, want %d, body=%s", rec.Code, http.StatusCreated, rec.Body.String()) } - body := decodeJSONMap(t, rec) + body := decodeCreatedSubscriptionFirstItem(t, rec) subID, _ := body["id"].(string) if subID == "" { t.Fatalf("create subscription missing id: body=%s", rec.Body.String()) @@ -1354,12 +1381,12 @@ func TestAPIContract_SubscriptionEphemeralEvictDelay_DefaultAndCustom(t *testing defaultRec := doJSONRequest(t, srv, http.MethodPost, "/api/v1/subscriptions", map[string]any{ "name": "default-evict-delay-sub", - "url": "https://example.com/sub-default-evict-delay", + "urls": []string{"https://example.com/sub-default-evict-delay"}, }, true) if defaultRec.Code != http.StatusCreated { t.Fatalf("create default delay subscription status: got %d, want %d, body=%s", defaultRec.Code, http.StatusCreated, defaultRec.Body.String()) } - defaultBody := decodeJSONMap(t, defaultRec) + defaultBody := decodeCreatedSubscriptionFirstItem(t, defaultRec) if defaultBody["ephemeral_node_evict_delay"] != "72h0m0s" { t.Fatalf( "default ephemeral_node_evict_delay: got %v, want %q", @@ -1370,13 +1397,13 @@ func TestAPIContract_SubscriptionEphemeralEvictDelay_DefaultAndCustom(t *testing customRec := doJSONRequest(t, srv, http.MethodPost, "/api/v1/subscriptions", map[string]any{ "name": "custom-evict-delay-sub", - "url": "https://example.com/sub-custom-evict-delay", + "urls": []string{"https://example.com/sub-custom-evict-delay"}, "ephemeral_node_evict_delay": "30m", }, true) if customRec.Code != http.StatusCreated { t.Fatalf("create custom delay subscription status: got %d, want %d, body=%s", customRec.Code, http.StatusCreated, customRec.Body.String()) } - customBody := decodeJSONMap(t, customRec) + customBody := decodeCreatedSubscriptionFirstItem(t, customRec) if customBody["ephemeral_node_evict_delay"] != "30m0s" { t.Fatalf( "custom ephemeral_node_evict_delay: got %v, want %q", @@ -1391,12 +1418,12 @@ func TestAPIContract_SubscriptionEphemeralEvictDelayPatchValidation(t *testing.T createRec := doJSONRequest(t, srv, http.MethodPost, "/api/v1/subscriptions", map[string]any{ "name": "patch-evict-delay-sub", - "url": "https://example.com/sub-patch-evict-delay", + "urls": []string{"https://example.com/sub-patch-evict-delay"}, }, true) if createRec.Code != http.StatusCreated { t.Fatalf("create subscription status: got %d, want %d, body=%s", createRec.Code, http.StatusCreated, createRec.Body.String()) } - createBody := decodeJSONMap(t, createRec) + createBody := decodeCreatedSubscriptionFirstItem(t, createRec) subID, _ := createBody["id"].(string) if subID == "" { t.Fatalf("create subscription missing id: body=%s", createRec.Body.String()) diff --git a/internal/api/major_flow_e2e_test.go b/internal/api/major_flow_e2e_test.go index bd1c65e3..caf2ecf7 100644 --- a/internal/api/major_flow_e2e_test.go +++ b/internal/api/major_flow_e2e_test.go @@ -227,12 +227,12 @@ func TestMajorFlow_E2E_LocalProxyAndSubscriptionProvider(t *testing.T) { createSubRec := doJSONRequest(t, h.apiServer, http.MethodPost, "/api/v1/subscriptions", map[string]any{ "name": "sub-major", - "url": subSource.URL + "/sub", + "urls": []string{subSource.URL + "/sub"}, }, true) if createSubRec.Code != http.StatusCreated { t.Fatalf("create subscription status: got %d, want %d, body=%s", createSubRec.Code, http.StatusCreated, createSubRec.Body.String()) } - subBody := decodeJSONMap(t, createSubRec) + subBody := decodeCreatedSubscriptionFirstItem(t, createSubRec) subID, _ := subBody["id"].(string) if subID == "" { t.Fatalf("create subscription missing id: body=%s", createSubRec.Body.String()) diff --git a/internal/api/subscription_batch_create_contract_test.go b/internal/api/subscription_batch_create_contract_test.go new file mode 100644 index 00000000..0a6b314c --- /dev/null +++ b/internal/api/subscription_batch_create_contract_test.go @@ -0,0 +1,116 @@ +package api + +import ( + "net/http" + "strings" + "testing" +) + +func TestAPIContract_SubscriptionCreateRemote_UsesURLsArrayAndKeepsBaseNameForSingle(t *testing.T) { + srv, _, _ := newControlPlaneTestServer(t) + + rec := doJSONRequest(t, srv, http.MethodPost, "/api/v1/subscriptions", map[string]any{ + "name": "single-sub", + "urls": []string{"https://api.example.com/sub"}, + }, true) + if rec.Code != http.StatusCreated { + t.Fatalf("create single remote by urls status: got %d, want %d, body=%s", rec.Code, http.StatusCreated, rec.Body.String()) + } + + body := decodeJSONMap(t, rec) + itemsRaw, ok := body["items"].([]any) + if !ok { + t.Fatalf("create response items type: got %T, body=%s", body["items"], rec.Body.String()) + } + if len(itemsRaw) != 1 { + t.Fatalf("create response items len: got %d, want %d, body=%s", len(itemsRaw), 1, rec.Body.String()) + } + item, ok := itemsRaw[0].(map[string]any) + if !ok { + t.Fatalf("create response item type: got %T", itemsRaw[0]) + } + if got, _ := item["name"].(string); got != "single-sub" { + t.Fatalf("single create name: got %q, want %q", got, "single-sub") + } + if got, _ := item["url"].(string); got != "https://api.example.com/sub" { + t.Fatalf("single create url: got %q, want %q", got, "https://api.example.com/sub") + } +} + +func TestAPIContract_SubscriptionCreateRemote_MultipleURLsAppendDomainSuffix(t *testing.T) { + srv, _, _ := newControlPlaneTestServer(t) + + rec := doJSONRequest(t, srv, http.MethodPost, "/api/v1/subscriptions", map[string]any{ + "name": "batch-sub", + "urls": []string{ + "https://a.example.com/sub-1", + "https://api.google.co.uk/sub-2", + "https://b.example.com/sub-3", + }, + }, true) + if rec.Code != http.StatusCreated { + t.Fatalf("create multiple remote by urls status: got %d, want %d, body=%s", rec.Code, http.StatusCreated, rec.Body.String()) + } + + body := decodeJSONMap(t, rec) + itemsRaw, ok := body["items"].([]any) + if !ok { + t.Fatalf("create response items type: got %T, body=%s", body["items"], rec.Body.String()) + } + if len(itemsRaw) != 3 { + t.Fatalf("create response items len: got %d, want %d, body=%s", len(itemsRaw), 3, rec.Body.String()) + } + + wantNames := []string{"batch-sub-example.com", "batch-sub-google.co.uk", "batch-sub-example.com-2"} + for i := range itemsRaw { + item, ok := itemsRaw[i].(map[string]any) + if !ok { + t.Fatalf("create response items[%d] type: got %T", i, itemsRaw[i]) + } + if got, _ := item["name"].(string); got != wantNames[i] { + t.Fatalf("create response items[%d].name: got %q, want %q", i, got, wantNames[i]) + } + } +} + +func TestAPIContract_SubscriptionCreateRemote_URLFieldRejectedAndInvalidBatchIsAtomic(t *testing.T) { + srv, _, _ := newControlPlaneTestServer(t) + + urlFieldRec := doJSONRequest(t, srv, http.MethodPost, "/api/v1/subscriptions", map[string]any{ + "name": "legacy-url", + "url": "https://example.com/sub", + }, true) + if urlFieldRec.Code != http.StatusBadRequest { + t.Fatalf("create remote with url field status: got %d, want %d, body=%s", urlFieldRec.Code, http.StatusBadRequest, urlFieldRec.Body.String()) + } + assertErrorCode(t, urlFieldRec, "INVALID_ARGUMENT") + + invalidBatchRec := doJSONRequest(t, srv, http.MethodPost, "/api/v1/subscriptions", map[string]any{ + "name": "atomic-batch", + "urls": []string{ + "https://ok.example.com/sub-1", + "not-a-url", + "https://ok2.example.com/sub-2", + }, + }, true) + if invalidBatchRec.Code != http.StatusBadRequest { + t.Fatalf("create invalid batch status: got %d, want %d, body=%s", invalidBatchRec.Code, http.StatusBadRequest, invalidBatchRec.Body.String()) + } + assertErrorCode(t, invalidBatchRec, "INVALID_ARGUMENT") + if !strings.Contains(invalidBatchRec.Body.String(), "urls[1]") { + t.Fatalf("invalid batch error should contain urls index, body=%s", invalidBatchRec.Body.String()) + } + + listRec := doJSONRequest(t, srv, http.MethodGet, "/api/v1/subscriptions", nil, true) + if listRec.Code != http.StatusOK { + t.Fatalf("list subscriptions status: got %d, want %d, body=%s", listRec.Code, http.StatusOK, listRec.Body.String()) + } + listBody := decodeJSONMap(t, listRec) + itemsRaw, ok := listBody["items"].([]any) + if !ok { + t.Fatalf("list items type: got %T, body=%s", listBody["items"], listRec.Body.String()) + } + if len(itemsRaw) != 0 { + t.Fatalf("invalid batch should be atomic and create nothing, got items=%d body=%s", len(itemsRaw), listRec.Body.String()) + } +} diff --git a/internal/api/subscription_cleanup_e2e_test.go b/internal/api/subscription_cleanup_e2e_test.go index 7f2463cc..f6defd7a 100644 --- a/internal/api/subscription_cleanup_e2e_test.go +++ b/internal/api/subscription_cleanup_e2e_test.go @@ -15,12 +15,12 @@ func TestAPIContract_SubscriptionCleanupAction_E2E(t *testing.T) { createRec := doJSONRequest(t, srv, http.MethodPost, "/api/v1/subscriptions", map[string]any{ "name": "sub-cleanup-e2e", - "url": "https://example.com/sub", + "urls": []string{"https://example.com/sub"}, }, true) if createRec.Code != http.StatusCreated { t.Fatalf("create subscription status: got %d, want %d, body=%s", createRec.Code, http.StatusCreated, createRec.Body.String()) } - createBody := decodeJSONMap(t, createRec) + createBody := decodeCreatedSubscriptionFirstItem(t, createRec) subID, _ := createBody["id"].(string) if subID == "" { t.Fatalf("create subscription missing id: body=%s", createRec.Body.String()) diff --git a/internal/api/subscription_local_contract_test.go b/internal/api/subscription_local_contract_test.go index 29fd87e2..c8e506df 100644 --- a/internal/api/subscription_local_contract_test.go +++ b/internal/api/subscription_local_contract_test.go @@ -34,12 +34,12 @@ func TestAPIContract_SubscriptionSourceTypeReadOnlyOnPatch(t *testing.T) { createRec := doJSONRequest(t, srv, http.MethodPost, "/api/v1/subscriptions", map[string]any{ "name": "sub-remote", - "url": "https://example.com/sub", + "urls": []string{"https://example.com/sub"}, }, true) if createRec.Code != http.StatusCreated { t.Fatalf("create remote subscription status: got %d, want %d, body=%s", createRec.Code, http.StatusCreated, createRec.Body.String()) } - body := decodeJSONMap(t, createRec) + body := decodeCreatedSubscriptionFirstItem(t, createRec) subID, _ := body["id"].(string) if subID == "" { t.Fatalf("create remote subscription missing id: body=%s", createRec.Body.String()) diff --git a/internal/api/subscription_refresh_e2e_test.go b/internal/api/subscription_refresh_e2e_test.go index 499e7441..6a2f577a 100644 --- a/internal/api/subscription_refresh_e2e_test.go +++ b/internal/api/subscription_refresh_e2e_test.go @@ -44,12 +44,12 @@ func TestAPIContract_SubscriptionRefreshAction_E2EHTTPSource(t *testing.T) { createRec := doJSONRequest(t, srv, http.MethodPost, "/api/v1/subscriptions", map[string]any{ "name": "sub-e2e", - "url": subscriptionSource.URL + "/sub", + "urls": []string{subscriptionSource.URL + "/sub"}, }, true) if createRec.Code != http.StatusCreated { t.Fatalf("create subscription status: got %d, want %d, body=%s", createRec.Code, http.StatusCreated, createRec.Body.String()) } - createBody := decodeJSONMap(t, createRec) + createBody := decodeCreatedSubscriptionFirstItem(t, createRec) subID, _ := createBody["id"].(string) if subID == "" { t.Fatalf("create subscription missing id: body=%s", createRec.Body.String()) @@ -146,7 +146,7 @@ func TestAPIContract_SubscriptionRefreshAction_E2ELocalSource(t *testing.T) { if createRec.Code != http.StatusCreated { t.Fatalf("create local subscription status: got %d, want %d, body=%s", createRec.Code, http.StatusCreated, createRec.Body.String()) } - createBody := decodeJSONMap(t, createRec) + createBody := decodeCreatedSubscriptionFirstItem(t, createRec) subID, _ := createBody["id"].(string) if subID == "" { t.Fatalf("create local subscription missing id: body=%s", createRec.Body.String()) diff --git a/internal/service/control_plane_subscription.go b/internal/service/control_plane_subscription.go index d13d7940..e354285a 100644 --- a/internal/service/control_plane_subscription.go +++ b/internal/service/control_plane_subscription.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" "github.com/Resinat/Resin/internal/model" + "github.com/Resinat/Resin/internal/netutil" "github.com/Resinat/Resin/internal/node" "github.com/Resinat/Resin/internal/state" "github.com/Resinat/Resin/internal/subscription" @@ -114,14 +115,19 @@ func (s *ControlPlaneService) GetSubscription(id string) (*SubscriptionResponse, // CreateSubscriptionRequest holds create subscription parameters. type CreateSubscriptionRequest struct { - Name *string `json:"name"` - SourceType *string `json:"source_type"` - URL *string `json:"url"` - Content *string `json:"content"` - UpdateInterval *string `json:"update_interval"` - Enabled *bool `json:"enabled"` - Ephemeral *bool `json:"ephemeral"` - EphemeralNodeEvictDelay *string `json:"ephemeral_node_evict_delay"` + Name *string `json:"name"` + SourceType *string `json:"source_type"` + URLs []string `json:"urls"` + Content *string `json:"content"` + UpdateInterval *string `json:"update_interval"` + Enabled *bool `json:"enabled"` + Ephemeral *bool `json:"ephemeral"` + EphemeralNodeEvictDelay *string `json:"ephemeral_node_evict_delay"` +} + +type CreateSubscriptionResponse struct { + Items []SubscriptionResponse `json:"items"` + CreatedCount int `json:"created_count"` } const minSubscriptionUpdateInterval = 30 * time.Second @@ -140,8 +146,45 @@ func parseSubscriptionSourceType(raw *string) (string, *ServiceError) { } } +func normalizeRemoteURLs(raw []string) ([]string, *ServiceError) { + if len(raw) == 0 { + return nil, invalidArg("urls is required for remote subscription") + } + normalized := make([]string, 0, len(raw)) + for i := range raw { + urlStr := strings.TrimSpace(raw[i]) + if urlStr == "" { + return nil, invalidArg(fmt.Sprintf("urls[%d]: must be a non-empty string", i)) + } + if _, verr := parseHTTPAbsoluteURL(fmt.Sprintf("urls[%d]", i), urlStr); verr != nil { + return nil, verr + } + normalized = append(normalized, urlStr) + } + return normalized, nil +} + +func buildRemoteSubscriptionNames(baseName string, urls []string) []string { + if len(urls) <= 1 { + return []string{baseName} + } + result := make([]string, 0, len(urls)) + seen := make(map[string]int, len(urls)) + for i := range urls { + domain := netutil.ExtractDomain(urls[i]) + candidate := baseName + "-" + domain + seen[candidate]++ + if seen[candidate] == 1 { + result = append(result, candidate) + continue + } + result = append(result, fmt.Sprintf("%s-%d", candidate, seen[candidate])) + } + return result +} + // CreateSubscription creates a new subscription. -func (s *ControlPlaneService) CreateSubscription(req CreateSubscriptionRequest) (*SubscriptionResponse, error) { +func (s *ControlPlaneService) CreateSubscription(req CreateSubscriptionRequest) (*CreateSubscriptionResponse, error) { if req.Name == nil || strings.TrimSpace(*req.Name) == "" { return nil, invalidArg("name is required") } @@ -152,27 +195,25 @@ func (s *ControlPlaneService) CreateSubscription(req CreateSubscriptionRequest) return nil, verr } - subURL := "" + remoteURLs := []string{} content := "" switch sourceType { case subscription.SourceTypeRemote: - if req.URL == nil || strings.TrimSpace(*req.URL) == "" { - return nil, invalidArg("url is required for remote subscription") - } - subURL = strings.TrimSpace(*req.URL) - if _, verr := parseHTTPAbsoluteURL("url", subURL); verr != nil { - return nil, verr - } if req.Content != nil && strings.TrimSpace(*req.Content) != "" { return nil, invalidArg("content is not allowed for remote subscription") } + urls, verr := normalizeRemoteURLs(req.URLs) + if verr != nil { + return nil, verr + } + remoteURLs = urls case subscription.SourceTypeLocal: if req.Content == nil || strings.TrimSpace(*req.Content) == "" { return nil, invalidArg("content is required for local subscription") } content = *req.Content - if req.URL != nil && strings.TrimSpace(*req.URL) != "" { - return nil, invalidArg("url is not allowed for local subscription") + if len(req.URLs) > 0 { + return nil, invalidArg("urls is not allowed for local subscription") } default: return nil, invalidArg("source_type: must be remote or local") @@ -210,37 +251,76 @@ func (s *ControlPlaneService) CreateSubscription(req CreateSubscriptionRequest) ephemeralNodeEvictDelay = d } - id := uuid.New().String() + createCount := 1 + if sourceType == subscription.SourceTypeRemote { + createCount = len(remoteURLs) + } + names := []string{name} + if sourceType == subscription.SourceTypeRemote { + names = buildRemoteSubscriptionNames(name, remoteURLs) + } + now := time.Now().UnixNano() + modelSubs := make([]model.Subscription, 0, createCount) + runtimeSubs := make([]*subscription.Subscription, 0, createCount) + + for i := 0; i < createCount; i++ { + subURL := "" + subName := name + subContent := content + if sourceType == subscription.SourceTypeRemote { + subURL = remoteURLs[i] + subName = names[i] + subContent = "" + } - ms := model.Subscription{ - ID: id, - Name: name, - SourceType: sourceType, - URL: subURL, - Content: content, - UpdateIntervalNs: int64(updateInterval), - Enabled: enabled, - Ephemeral: ephemeral, - EphemeralNodeEvictDelayNs: int64(ephemeralNodeEvictDelay), - CreatedAtNs: now, - UpdatedAtNs: now, - } - if err := s.Engine.UpsertSubscription(ms); err != nil { - return nil, internal("persist subscription", err) + id := uuid.New().String() + subNow := now + int64(i) + + modelSubs = append(modelSubs, model.Subscription{ + ID: id, + Name: subName, + SourceType: sourceType, + URL: subURL, + Content: subContent, + UpdateIntervalNs: int64(updateInterval), + Enabled: enabled, + Ephemeral: ephemeral, + EphemeralNodeEvictDelayNs: int64(ephemeralNodeEvictDelay), + CreatedAtNs: subNow, + UpdatedAtNs: subNow, + }) + + sub := subscription.NewSubscription(id, subName, subURL, enabled, ephemeral) + sub.SetFetchConfig(subURL, int64(updateInterval)) + sub.SetSourceType(sourceType) + sub.SetContent(subContent) + sub.SetEphemeralNodeEvictDelayNs(int64(ephemeralNodeEvictDelay)) + sub.CreatedAtNs = subNow + sub.UpdatedAtNs = subNow + runtimeSubs = append(runtimeSubs, sub) } - sub := subscription.NewSubscription(id, name, subURL, enabled, ephemeral) - sub.SetFetchConfig(subURL, int64(updateInterval)) - sub.SetSourceType(sourceType) - sub.SetContent(content) - sub.SetEphemeralNodeEvictDelayNs(int64(ephemeralNodeEvictDelay)) - sub.CreatedAtNs = now - sub.UpdatedAtNs = now - s.SubMgr.Register(sub) + if len(modelSubs) == 1 { + if err := s.Engine.UpsertSubscription(modelSubs[0]); err != nil { + return nil, internal("persist subscription", err) + } + } else { + if err := s.Engine.UpsertSubscriptions(modelSubs); err != nil { + return nil, internal("persist subscriptions", err) + } + } - r := s.subToResponse(sub) - return &r, nil + items := make([]SubscriptionResponse, 0, len(runtimeSubs)) + for i := range runtimeSubs { + sub := runtimeSubs[i] + s.SubMgr.Register(sub) + items = append(items, s.subToResponse(sub)) + } + return &CreateSubscriptionResponse{ + Items: items, + CreatedCount: len(items), + }, nil } // UpdateSubscription applies a constrained partial patch to a subscription. diff --git a/internal/state/repo_state.go b/internal/state/repo_state.go index f684df97..7a8e6115 100644 --- a/internal/state/repo_state.go +++ b/internal/state/repo_state.go @@ -280,9 +280,23 @@ func (r *StateRepo) ListPlatforms() ([]model.Platform, error) { // --- subscriptions --- -// UpsertSubscription inserts or updates a subscription by ID. -// On update, created_at_ns is preserved (not overwritten). -func (r *StateRepo) UpsertSubscription(s model.Subscription) error { +const upsertSubscriptionSQL = ` + INSERT INTO subscriptions (id, name, source_type, url, content, update_interval_ns, enabled, + ephemeral, ephemeral_node_evict_delay_ns, created_at_ns, updated_at_ns) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + source_type = excluded.source_type, + url = excluded.url, + content = excluded.content, + update_interval_ns = excluded.update_interval_ns, + enabled = excluded.enabled, + ephemeral = excluded.ephemeral, + ephemeral_node_evict_delay_ns = excluded.ephemeral_node_evict_delay_ns, + updated_at_ns = excluded.updated_at_ns +` + +func normalizeSubscriptionForPersist(s *model.Subscription) error { // Validate minimum update interval (30 seconds). const minInterval = int64(30 * time.Second) if s.UpdateIntervalNs < minInterval { @@ -294,27 +308,76 @@ func (r *StateRepo) UpsertSubscription(s model.Subscription) error { if s.SourceType != "remote" && s.SourceType != "local" { return fmt.Errorf("source_type: must be remote or local, got %q", s.SourceType) } + return nil +} + +func execUpsertSubscription( + execer interface { + Exec(query string, args ...any) (sql.Result, error) + }, + s model.Subscription, +) error { + _, err := execer.Exec( + upsertSubscriptionSQL, + s.ID, + s.Name, + s.SourceType, + s.URL, + s.Content, + s.UpdateIntervalNs, + s.Enabled, + s.Ephemeral, + s.EphemeralNodeEvictDelayNs, + s.CreatedAtNs, + s.UpdatedAtNs, + ) + return err +} + +// UpsertSubscription inserts or updates a subscription by ID. +// On update, created_at_ns is preserved (not overwritten). +func (r *StateRepo) UpsertSubscription(s model.Subscription) error { + if err := normalizeSubscriptionForPersist(&s); err != nil { + return err + } r.mu.Lock() defer r.mu.Unlock() - _, err := r.db.Exec(` - INSERT INTO subscriptions (id, name, source_type, url, content, update_interval_ns, enabled, - ephemeral, ephemeral_node_evict_delay_ns, created_at_ns, updated_at_ns) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - name = excluded.name, - source_type = excluded.source_type, - url = excluded.url, - content = excluded.content, - update_interval_ns = excluded.update_interval_ns, - enabled = excluded.enabled, - ephemeral = excluded.ephemeral, - ephemeral_node_evict_delay_ns = excluded.ephemeral_node_evict_delay_ns, - updated_at_ns = excluded.updated_at_ns - `, s.ID, s.Name, s.SourceType, s.URL, s.Content, s.UpdateIntervalNs, s.Enabled, - s.Ephemeral, s.EphemeralNodeEvictDelayNs, s.CreatedAtNs, s.UpdatedAtNs) - return err + return execUpsertSubscription(r.db, s) +} + +// UpsertSubscriptions inserts or updates multiple subscriptions in one +// transaction. Either all rows are persisted, or none are. +func (r *StateRepo) UpsertSubscriptions(subs []model.Subscription) error { + if len(subs) == 0 { + return nil + } + + normalized := make([]model.Subscription, len(subs)) + for i := range subs { + normalized[i] = subs[i] + if err := normalizeSubscriptionForPersist(&normalized[i]); err != nil { + return err + } + } + + r.mu.Lock() + defer r.mu.Unlock() + + tx, err := r.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + for i := range normalized { + if err := execUpsertSubscription(tx, normalized[i]); err != nil { + return err + } + } + + return tx.Commit() } // DeleteSubscription removes a subscription by ID. diff --git a/webui/src/features/subscriptions/SubscriptionPage.tsx b/webui/src/features/subscriptions/SubscriptionPage.tsx index 9e775682..d5882bbb 100644 --- a/webui/src/features/subscriptions/SubscriptionPage.tsx +++ b/webui/src/features/subscriptions/SubscriptionPage.tsx @@ -39,6 +39,40 @@ const SUBSCRIPTION_SOURCE_TABS: Array<{ key: SubscriptionSourceType; label: stri ]; const subscriptionCreateSchema = z.object({ + name: z.string().trim().min(1, "订阅名称不能为空"), + source_type: z.enum(["remote", "local"]), + urls_text: z.string(), + content: z.string(), + update_interval: z.string().trim().min(1, "更新间隔不能为空"), + ephemeral_node_evict_delay: z.string().trim().min(1, "临时节点驱逐延迟不能为空"), + enabled: z.boolean(), + ephemeral: z.boolean(), +}).superRefine((value, ctx) => { + const content = value.content.trim(); + if (value.source_type === "remote") { + const urls = splitRemoteURLs(value.urls_text); + if (!urls.length) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["urls_text"], message: "至少填写一个 URL" }); + return; + } + for (let i = 0; i < urls.length; i += 1) { + if (!isValidHTTPURL(urls[i])) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["urls_text"], + message: `第 ${i + 1} 行 URL 必须是 http/https 地址`, + }); + return; + } + } + return; + } + if (!content) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["content"], message: "订阅内容不能为空" }); + } +}); + +const subscriptionEditSchema = z.object({ name: z.string().trim().min(1, "订阅名称不能为空"), source_type: z.enum(["remote", "local"]), url: z.string(), @@ -55,7 +89,7 @@ const subscriptionCreateSchema = z.object({ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["url"], message: "URL 不能为空" }); return; } - if (!(url.startsWith("http://") || url.startsWith("https://"))) { + if (!isValidHTTPURL(url)) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["url"], message: "URL 必须是 http/https 地址" }); } return; @@ -65,8 +99,6 @@ const subscriptionCreateSchema = z.object({ } }); -const subscriptionEditSchema = subscriptionCreateSchema; - type SubscriptionCreateForm = z.infer; type SubscriptionEditForm = z.infer; const EMPTY_SUBSCRIPTIONS: Subscription[] = []; @@ -75,6 +107,22 @@ const LOCAL_SOURCE_UPDATE_INTERVAL = "12h"; const SUBSCRIPTION_DISABLE_HINT = "禁用订阅后,相关节点不会参与平台路由、健康统计或自动探测。"; const SUBSCRIPTION_EPHEMERAL_HINT = "临时订阅的非健康节点会在一段时间后被自动删除。订阅本身不会被删除。"; +function splitRemoteURLs(raw: string): string[] { + return raw + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +function isValidHTTPURL(raw: string): boolean { + try { + const parsed = new URL(raw); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + function extractHostname(url: string): string { try { return new URL(url).hostname; @@ -177,7 +225,7 @@ export function SubscriptionPage() { defaultValues: { name: "", source_type: "remote", - url: "", + urls_text: "", content: "", update_interval: "12h", ephemeral_node_evict_delay: "72h", @@ -248,14 +296,19 @@ export function SubscriptionPage() { createForm.reset({ name: "", source_type: "remote", - url: "", + urls_text: "", content: "", update_interval: LOCAL_SOURCE_UPDATE_INTERVAL, ephemeral_node_evict_delay: "72h", enabled: true, ephemeral: false, }); - showToast("success", t("订阅 {{name}} 创建成功", { name: created.name })); + if (created.items.length <= 1) { + const createdName = created.items[0]?.name ?? ""; + showToast("success", t("订阅 {{name}} 创建成功", { name: createdName })); + return; + } + showToast("success", t("已批量创建 {{count}} 个订阅", { count: created.items.length })); }, onError: (error) => { showToast("error", formatApiErrorMessage(error, t)); @@ -367,6 +420,7 @@ export function SubscriptionPage() { }); const onCreateSubmit = createForm.handleSubmit(async (values) => { + const urls = values.source_type === "remote" ? splitRemoteURLs(values.urls_text) : []; const payload = { name: values.name.trim(), source_type: values.source_type, @@ -375,7 +429,7 @@ export function SubscriptionPage() { enabled: values.enabled, ephemeral: values.ephemeral, ...(values.source_type === "remote" - ? { url: values.url.trim() } + ? { urls } : { content: values.content }), }; await createMutation.mutateAsync(payload); @@ -952,16 +1006,18 @@ export function SubscriptionPage() {
-
diff --git a/webui/src/features/subscriptions/api.ts b/webui/src/features/subscriptions/api.ts index 4b883fb3..d22bba01 100644 --- a/webui/src/features/subscriptions/api.ts +++ b/webui/src/features/subscriptions/api.ts @@ -3,6 +3,7 @@ import type { PageResponse, Subscription, SubscriptionCreateInput, + SubscriptionCreateResult, SubscriptionUpdateInput, } from "./types"; @@ -34,6 +35,11 @@ function normalizeSubscriptionPage(raw: PageResponse): PageResp }; } +type ApiSubscriptionCreateResult = { + items: ApiSubscription[]; + created_count?: number; +}; + export type ListSubscriptionsInput = { enabled?: boolean; limit?: number; @@ -61,12 +67,15 @@ export async function listSubscriptions(input: ListSubscriptionsInput = {}): Pro return normalizeSubscriptionPage(data); } -export async function createSubscription(input: SubscriptionCreateInput): Promise { - const data = await apiRequest(basePath, { +export async function createSubscription(input: SubscriptionCreateInput): Promise { + const data = await apiRequest(basePath, { method: "POST", body: input, }); - return normalizeSubscription(data); + return { + items: data.items.map(normalizeSubscription), + created_count: data.created_count ?? data.items.length, + }; } export async function updateSubscription(id: string, input: SubscriptionUpdateInput): Promise { diff --git a/webui/src/features/subscriptions/types.ts b/webui/src/features/subscriptions/types.ts index 175361a3..2d4e9932 100644 --- a/webui/src/features/subscriptions/types.ts +++ b/webui/src/features/subscriptions/types.ts @@ -26,7 +26,7 @@ export type PageResponse = { export type SubscriptionCreateInput = { name: string; source_type?: "remote" | "local"; - url?: string; + urls?: string[]; content?: string; update_interval?: string; enabled?: boolean; @@ -34,6 +34,11 @@ export type SubscriptionCreateInput = { ephemeral_node_evict_delay?: string; }; +export type SubscriptionCreateResult = { + items: Subscription[]; + created_count: number; +}; + export type SubscriptionUpdateInput = { name?: string; url?: string; diff --git a/webui/src/i18n/translations.ts b/webui/src/i18n/translations.ts index 3d98f35e..e227e368 100644 --- a/webui/src/i18n/translations.ts +++ b/webui/src/i18n/translations.ts @@ -28,6 +28,7 @@ const EXACT_ZH_TO_EN: Record = { "系统配置": "System Config", "刷新": "Refresh", "刷新中...": "Refreshing...", + "已批量创建 {{count}} 个订阅": "Created {{count}} subscriptions in batch", "保存配置": "Save Config", "保存规则": "Save Rule", "保存中...": "Saving...", @@ -135,11 +136,14 @@ const EXACT_ZH_TO_EN: Record = { "更新间隔不能为空": "Update interval cannot be empty", "订阅名称": "Subscription Name", "订阅链接": "Subscription URL", + "订阅链接(每行一个)": "Subscription URLs (one per line)", "订阅来源": "Subscription source", "订阅来源类型": "Subscription source type", "订阅类型": "Subscription type", "订阅内容": "Subscription content", "订阅内容不能为空": "Subscription content cannot be empty", + "每行一个 http/https 订阅链接": "One http/https subscription URL per line", + "至少填写一个 URL": "At least one URL is required", "订阅源": "Subscription Source", "本地订阅": "Local subscription", "远程": "Remote", From faf99c923cf44b61def1312d968df2a464539cf1 Mon Sep 17 00:00:00 2001 From: ppanphper Date: Wed, 8 Apr 2026 14:11:17 +0800 Subject: [PATCH 2/3] chore(gitignore): update ignore rules Changelog:\n- replace previous repository-specific ignore entries\n- apply venv-style wildcard ignore template\n- keep this change isolated in feature/batch_add_sub_link before switching branches --- .gitignore | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 62dc1ff5..f514b74c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,2 @@ -/resin -sing-box-reference -.agents -.devcontainer -start-instance.sh \ No newline at end of file +# Created by venv; see https://docs.python.org/3/library/venv.html +* From 93db6d4236011685b1a4c19fde270a1529e74309 Mon Sep 17 00:00:00 2001 From: ppanphper Date: Wed, 8 Apr 2026 14:46:20 +0800 Subject: [PATCH 3/3] rollback --- .gitignore | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index f514b74c..62dc1ff5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ -# Created by venv; see https://docs.python.org/3/library/venv.html -* +/resin +sing-box-reference +.agents +.devcontainer +start-instance.sh \ No newline at end of file