From 5da343cc71e65cad5b64e7db94f37a3150b07955 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Thu, 23 Jul 2026 20:47:54 +0800 Subject: [PATCH] feat: collect JSON and XML feeds --- apps/web_v1/scripts/visual-check.mjs | 12 +- apps/web_v1/src/pages/workspace-explore.tsx | 14 +- backend/internal/logic/dataset/feed.go | 347 ++++++++++++++++++ backend/internal/logic/dataset/feed_test.go | 126 +++++++ backend/internal/logic/dataset/handlers.go | 4 +- .../internal/logic/dataset/handlers_test.go | 30 +- backend/internal/logic/dataset/service.go | 135 +++++-- backend/internal/models/dataset_item.go | 1 + 8 files changed, 627 insertions(+), 42 deletions(-) create mode 100644 backend/internal/logic/dataset/feed.go create mode 100644 backend/internal/logic/dataset/feed_test.go diff --git a/apps/web_v1/scripts/visual-check.mjs b/apps/web_v1/scripts/visual-check.mjs index 8f604f0..8098751 100644 --- a/apps/web_v1/scripts/visual-check.mjs +++ b/apps/web_v1/scripts/visual-check.mjs @@ -265,16 +265,16 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => { } if (url.pathname === '/api/v1/dataset-crons/sync') { await route.fulfill({ - status: 202, + status: 200, json: visualDatasetSources.map((source, index) => ({ id: `019b0000-0000-7000-8000-${String(70 + index).padStart(12, '0')}`, sourceId: source.id, schedule: '@once', - status: 'pending', - enabled: true, - nextRunAt: new Date().toISOString(), - lastRunAt: null, - lastResult: '', + status: 'completed', + enabled: false, + nextRunAt: null, + lastRunAt: new Date().toISOString(), + lastResult: 'format=rss fetched=2 inserted=0', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), })), diff --git a/apps/web_v1/src/pages/workspace-explore.tsx b/apps/web_v1/src/pages/workspace-explore.tsx index 4e92f84..468a1ca 100644 --- a/apps/web_v1/src/pages/workspace-explore.tsx +++ b/apps/web_v1/src/pages/workspace-explore.tsx @@ -231,12 +231,20 @@ export function WorkspaceExplorePage({ try { const crons = await onQueueSync() if (crons.length === 0) { - Message.info('暂无已启用的数据源') + Message.info('暂无已启用的 RSS 数据源') } else { - Message.success(`已提交 ${crons.length} 个采集任务`) + const failed = crons.filter((cron) => cron.status === 'failed').length + const [nextSources, nextItems] = await Promise.all([onListSources(), onListItems()]) + setSourceRecords(nextSources) + setItems(nextItems) + if (failed > 0) { + Message.warning(`${crons.length - failed} 个数据源采集完成,${failed} 个失败`) + } else { + Message.success(`已完成 ${crons.length} 个数据源采集任务`) + } } } catch (error) { - Message.error(error instanceof Error ? error.message : '同步任务提交失败') + Message.error(error instanceof Error ? error.message : '数据源采集失败') } finally { setSyncing(false) } diff --git a/backend/internal/logic/dataset/feed.go b/backend/internal/logic/dataset/feed.go new file mode 100644 index 0000000..9a0dd72 --- /dev/null +++ b/backend/internal/logic/dataset/feed.go @@ -0,0 +1,347 @@ +package dataset + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "html" + "io" + "net" + "net/http" + "net/url" + "regexp" + "strings" + "time" +) + +const maxFeedBytes = 5 << 20 + +var ( + ErrFeedFormat = errors.New("unsupported feed format") + ErrFeedTooLarge = errors.New("feed response is too large") + ErrUnsafeFeedURL = errors.New("feed url resolves to a private or local address") + htmlTagPattern = regexp.MustCompile(`<[^>]*>`) +) + +type FeedItem struct { + ExternalID string + Title string + Summary string + Content string + URL string + PublishedAt *time.Time +} + +type ParsedFeed struct { + Format string + Items []FeedItem +} + +type FeedFetcher interface { + Fetch(context.Context, string) (ParsedFeed, error) +} + +type HTTPFeedFetcher struct { + client *http.Client +} + +func NewHTTPFeedFetcher() *HTTPFeedFetcher { + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + dialer := &net.Dialer{Timeout: 10 * time.Second} + var lastErr error + for _, candidate := range ips { + if !isPublicIP(candidate.IP) { + continue + } + connection, err := dialer.DialContext(ctx, network, net.JoinHostPort(candidate.IP.String(), port)) + if err == nil { + return connection, nil + } + lastErr = err + } + if lastErr != nil { + return nil, lastErr + } + return nil, ErrUnsafeFeedURL + }, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 15 * time.Second, + } + fetcher := &HTTPFeedFetcher{} + fetcher.client = &http.Client{ + Transport: transport, + Timeout: 30 * time.Second, + CheckRedirect: func(request *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return errors.New("too many feed redirects") + } + return validateFeedURL(request.URL) + }, + } + return fetcher +} + +func (f *HTTPFeedFetcher) Fetch(ctx context.Context, value string) (ParsedFeed, error) { + parsedURL, err := url.Parse(value) + if err != nil { + return ParsedFeed{}, err + } + if err := validateFeedURL(parsedURL); err != nil { + return ParsedFeed{}, err + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsedURL.String(), nil) + if err != nil { + return ParsedFeed{}, err + } + request.Header.Set("Accept", "application/feed+json, application/json, application/rss+xml, application/atom+xml, application/xml, text/xml;q=0.9") + request.Header.Set("User-Agent", "SenlinAI-Agent/1.0") + response, err := f.client.Do(request) + if err != nil { + return ParsedFeed{}, err + } + defer response.Body.Close() + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return ParsedFeed{}, fmt.Errorf("feed request returned %s", response.Status) + } + body, err := io.ReadAll(io.LimitReader(response.Body, maxFeedBytes+1)) + if err != nil { + return ParsedFeed{}, err + } + if len(body) > maxFeedBytes { + return ParsedFeed{}, ErrFeedTooLarge + } + return ParseFeed(body, response.Header.Get("Content-Type")) +} + +func ParseFeed(body []byte, contentType string) (ParsedFeed, error) { + body = bytes.TrimSpace(bytes.TrimPrefix(body, []byte{0xef, 0xbb, 0xbf})) + if len(body) == 0 { + return ParsedFeed{}, ErrFeedFormat + } + lowerContentType := strings.ToLower(contentType) + if body[0] == '{' || strings.Contains(lowerContentType, "json") { + if feed, err := parseJSONFeed(body); err == nil { + return feed, nil + } + } + if body[0] == '<' || strings.Contains(lowerContentType, "xml") || strings.Contains(lowerContentType, "rss") || strings.Contains(lowerContentType, "atom") { + return parseXMLFeed(body) + } + return ParsedFeed{}, ErrFeedFormat +} + +type jsonFeedDocument struct { + Version string `json:"version"` + Items []jsonFeedItem `json:"items"` +} + +type jsonFeedItem struct { + ID string `json:"id"` + URL string `json:"url"` + Title string `json:"title"` + Summary string `json:"summary"` + ContentText string `json:"content_text"` + ContentHTML string `json:"content_html"` + DatePublished string `json:"date_published"` +} + +func parseJSONFeed(body []byte) (ParsedFeed, error) { + var document jsonFeedDocument + if err := json.Unmarshal(body, &document); err != nil { + return ParsedFeed{}, err + } + if document.Version == "" { + return ParsedFeed{}, ErrFeedFormat + } + items := make([]FeedItem, 0, len(document.Items)) + for _, input := range document.Items { + content := strings.TrimSpace(input.ContentText) + if content == "" { + content = plainText(input.ContentHTML) + } + item := normalizeFeedItem(FeedItem{ + ExternalID: input.ID, Title: plainText(input.Title), Summary: plainText(input.Summary), + Content: content, URL: input.URL, PublishedAt: parseFeedTime(input.DatePublished), + }) + if item.Title != "" { + items = append(items, item) + } + } + return ParsedFeed{Format: "json_feed", Items: items}, nil +} + +type xmlFeedDocument struct { + XMLName xml.Name + Channel struct { + Items []xmlFeedItem `xml:"item"` + } `xml:"channel"` + Items []xmlFeedItem `xml:"item"` + Entries []xmlAtomEntry `xml:"entry"` +} + +type xmlFeedItem struct { + Title string `xml:"title"` + Link string `xml:"link"` + GUID string `xml:"guid"` + Description string `xml:"description"` + Content string `xml:"encoded"` + PubDate string `xml:"pubDate"` + Date string `xml:"date"` +} + +type xmlAtomEntry struct { + Title string `xml:"title"` + ID string `xml:"id"` + Links []xmlAtomLink `xml:"link"` + Summary string `xml:"summary"` + Content string `xml:"content"` + Published string `xml:"published"` + Updated string `xml:"updated"` +} + +type xmlAtomLink struct { + Href string `xml:"href,attr"` + Rel string `xml:"rel,attr"` +} + +func parseXMLFeed(body []byte) (ParsedFeed, error) { + var document xmlFeedDocument + if err := xml.Unmarshal(body, &document); err != nil { + return ParsedFeed{}, err + } + switch strings.ToLower(document.XMLName.Local) { + case "rss", "rdf": + inputs := document.Channel.Items + if len(inputs) == 0 { + inputs = document.Items + } + items := make([]FeedItem, 0, len(inputs)) + for _, input := range inputs { + content := plainText(input.Content) + if content == "" { + content = plainText(input.Description) + } + item := normalizeFeedItem(FeedItem{ + ExternalID: input.GUID, Title: plainText(input.Title), + Summary: plainText(input.Description), Content: content, + URL: strings.TrimSpace(input.Link), PublishedAt: parseFeedTime(firstNonEmpty(input.PubDate, input.Date)), + }) + if item.Title != "" { + items = append(items, item) + } + } + return ParsedFeed{Format: "rss", Items: items}, nil + case "feed": + items := make([]FeedItem, 0, len(document.Entries)) + for _, input := range document.Entries { + item := normalizeFeedItem(FeedItem{ + ExternalID: input.ID, Title: plainText(input.Title), + Summary: plainText(input.Summary), Content: plainText(input.Content), + URL: atomLink(input.Links), PublishedAt: parseFeedTime(firstNonEmpty(input.Published, input.Updated)), + }) + if item.Title != "" { + items = append(items, item) + } + } + return ParsedFeed{Format: "atom", Items: items}, nil + default: + return ParsedFeed{}, ErrFeedFormat + } +} + +func normalizeFeedItem(item FeedItem) FeedItem { + item.Title = truncateRunes(strings.TrimSpace(item.Title), 500) + item.Summary = strings.TrimSpace(item.Summary) + item.Content = strings.TrimSpace(item.Content) + item.URL = truncateRunes(strings.TrimSpace(item.URL), 2048) + key := firstNonEmpty(strings.TrimSpace(item.ExternalID), item.URL) + if key == "" { + published := "" + if item.PublishedAt != nil { + published = item.PublishedAt.UTC().Format(time.RFC3339Nano) + } + key = item.Title + "\x00" + published + } + sum := sha256.Sum256([]byte(key)) + item.ExternalID = hex.EncodeToString(sum[:]) + return item +} + +func parseFeedTime(value string) *time.Time { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + layouts := []string{ + time.RFC3339Nano, time.RFC3339, time.RFC1123Z, time.RFC1123, + time.RFC822Z, time.RFC822, time.RFC850, time.ANSIC, + "Mon, 2 Jan 2006 15:04:05 -0700", + } + for _, layout := range layouts { + if parsed, err := time.Parse(layout, value); err == nil { + utc := parsed.UTC() + return &utc + } + } + return nil +} + +func plainText(value string) string { + value = htmlTagPattern.ReplaceAllString(value, " ") + return strings.Join(strings.Fields(html.UnescapeString(value)), " ") +} + +func atomLink(links []xmlAtomLink) string { + for _, link := range links { + if link.Rel == "" || link.Rel == "alternate" { + return strings.TrimSpace(link.Href) + } + } + if len(links) > 0 { + return strings.TrimSpace(links[0].Href) + } + return "" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func validateFeedURL(value *url.URL) error { + if value == nil || (value.Scheme != "http" && value.Scheme != "https") || value.Hostname() == "" || value.User != nil { + return ErrURLInvalid + } + if ip := net.ParseIP(value.Hostname()); ip != nil && !isPublicIP(ip) { + return ErrUnsafeFeedURL + } + return nil +} + +func isPublicIP(ip net.IP) bool { + return ip != nil && + !ip.IsLoopback() && + !ip.IsPrivate() && + !ip.IsLinkLocalUnicast() && + !ip.IsLinkLocalMulticast() && + !ip.IsUnspecified() && + !ip.IsMulticast() +} diff --git a/backend/internal/logic/dataset/feed_test.go b/backend/internal/logic/dataset/feed_test.go new file mode 100644 index 0000000..7e72ec2 --- /dev/null +++ b/backend/internal/logic/dataset/feed_test.go @@ -0,0 +1,126 @@ +package dataset + +import ( + "context" + "errors" + "net/url" + "testing" + + "github.com/stretchr/testify/require" + "senlinai-agent/backend/internal/models" +) + +func TestParseFeedDetectsJSONFeedFromContent(t *testing.T) { + feed, err := ParseFeed([]byte(`{ + "version": "https://jsonfeed.org/version/1.1", + "items": [{ + "id": "json-1", + "url": "https://example.com/json-1", + "title": "JSON item", + "summary": "JSON summary", + "content_html": "

JSON content

", + "date_published": "2026-07-23T08:00:00Z" + }] + }`), "text/plain") + + require.NoError(t, err) + require.Equal(t, "json_feed", feed.Format) + require.Len(t, feed.Items, 1) + require.Equal(t, "JSON item", feed.Items[0].Title) + require.Equal(t, "JSON content", feed.Items[0].Content) + require.Equal(t, "https://example.com/json-1", feed.Items[0].URL) + require.NotEmpty(t, feed.Items[0].ExternalID) + require.NotNil(t, feed.Items[0].PublishedAt) +} + +func TestParseFeedDetectsRSSXML(t *testing.T) { + feed, err := ParseFeed([]byte(` + + + + RSS item + https://example.com/rss-1 + rss-1 + RSS summary

]]>
+ RSS content

]]>
+ Thu, 23 Jul 2026 08:00:00 +0000 +
+
+
`), "application/xml") + + require.NoError(t, err) + require.Equal(t, "rss", feed.Format) + require.Len(t, feed.Items, 1) + require.Equal(t, "RSS summary", feed.Items[0].Summary) + require.Equal(t, "RSS content", feed.Items[0].Content) + require.NotNil(t, feed.Items[0].PublishedAt) +} + +func TestParseFeedDetectsAtomXML(t *testing.T) { + feed, err := ParseFeed([]byte(` + + + Atom item + atom-1 + + Atom summary + Atom content + 2026-07-23T08:00:00Z + + `), "application/atom+xml") + + require.NoError(t, err) + require.Equal(t, "atom", feed.Format) + require.Len(t, feed.Items, 1) + require.Equal(t, "https://example.com/atom-1", feed.Items[0].URL) + require.Equal(t, "Atom content", feed.Items[0].Content) +} + +func TestParseFeedRejectsUnknownContent(t *testing.T) { + _, err := ParseFeed([]byte("not a feed"), "text/plain") + require.ErrorIs(t, err, ErrFeedFormat) +} + +func TestValidateFeedURLRejectsLocalAddressesAndCredentials(t *testing.T) { + for _, value := range []string{ + "http://127.0.0.1/feed", + "http://[::1]/feed", + "http://169.254.169.254/latest/meta-data", + "https://user:password@example.com/feed", + } { + parsed, err := url.Parse(value) + require.NoError(t, err) + require.Error(t, validateFeedURL(parsed), value) + } + + parsed, err := url.Parse("https://rsshub.app/cls/hot") + require.NoError(t, err) + require.NoError(t, validateFeedURL(parsed)) +} + +func TestSyncSourcesRecordsFeedFailure(t *testing.T) { + database := newDatasetTestDatabase(t) + user := createDatasetTestUser(t, database, "feed-owner@example.com") + source := models.SaDatasetSource{ + CreatedBy: user.ID, + Name: "Broken feed", + Kind: "rss", + URL: "https://example.com/feed", + Enabled: true, + } + require.NoError(t, database.Create(&source).Error) + + crons, err := newServiceWithFetcher(database, failingFeedFetcher{}).SyncSources(context.Background(), user.ID) + + require.NoError(t, err) + require.Len(t, crons, 1) + require.Equal(t, "failed", crons[0].Status) + require.False(t, crons[0].Enabled) + require.Contains(t, crons[0].LastResult, "feed unavailable") +} + +type failingFeedFetcher struct{} + +func (failingFeedFetcher) Fetch(context.Context, string) (ParsedFeed, error) { + return ParsedFeed{}, errors.New("feed unavailable") +} diff --git a/backend/internal/logic/dataset/handlers.go b/backend/internal/logic/dataset/handlers.go index 72130f5..7bc8f66 100644 --- a/backend/internal/logic/dataset/handlers.go +++ b/backend/internal/logic/dataset/handlers.go @@ -272,7 +272,7 @@ func (h *Handler) queueSync(c *gin.Context) { if !ok { return } - crons, err := h.service.QueueSync(userID) + crons, err := h.service.SyncSources(c.Request.Context(), userID) if err != nil { writeError(c, err) return @@ -281,7 +281,7 @@ func (h *Handler) queueSync(c *gin.Context) { for _, cron := range crons { result = append(result, cronDTO(cron)) } - c.JSON(http.StatusAccepted, result) + c.JSON(http.StatusOK, result) } func currentUser(c *gin.Context) (uint, bool) { diff --git a/backend/internal/logic/dataset/handlers_test.go b/backend/internal/logic/dataset/handlers_test.go index f384e29..dec7674 100644 --- a/backend/internal/logic/dataset/handlers_test.go +++ b/backend/internal/logic/dataset/handlers_test.go @@ -2,6 +2,7 @@ package dataset import ( "bytes" + "context" "encoding/json" "fmt" "net/http" @@ -63,17 +64,21 @@ func TestDatasetAPIConnectsSourcesItemsAndSyncTasks(t *testing.T) { require.True(t, item.Starred) firstSync := performDatasetRequest(t, ownerRouter, http.MethodPost, "/api/v1/dataset-crons/sync", map[string]any{}) - require.Equal(t, http.StatusAccepted, firstSync.Code) + require.Equal(t, http.StatusOK, firstSync.Code) var firstCrons []CronDTO require.NoError(t, json.Unmarshal(firstSync.Body.Bytes(), &firstCrons)) require.Len(t, firstCrons, 1) - require.Equal(t, "pending", firstCrons[0].Status) + require.Equal(t, "completed", firstCrons[0].Status) + require.Contains(t, firstCrons[0].LastResult, "format=rss") secondSync := performDatasetRequest(t, ownerRouter, http.MethodPost, "/api/v1/dataset-crons/sync", map[string]any{}) - require.Equal(t, http.StatusAccepted, secondSync.Code) + require.Equal(t, http.StatusOK, secondSync.Code) var secondCrons []CronDTO require.NoError(t, json.Unmarshal(secondSync.Body.Bytes(), &secondCrons)) - require.Equal(t, firstCrons[0].ID, secondCrons[0].ID) + require.NotEqual(t, firstCrons[0].ID, secondCrons[0].ID) + var collectedItems []models.SaDatasetItem + require.NoError(t, database.Where("source_id = ?", 1).Find(&collectedItems).Error) + require.Len(t, collectedItems, 2) otherRouter := datasetTestRouter(database, other.ID) otherSources := performDatasetRequest(t, otherRouter, http.MethodGet, "/api/v1/dataset-sources", nil) @@ -117,10 +122,25 @@ func datasetTestRouter(database *gorm.DB, userID uint) http.Handler { return httpx.NewProtectedRouter( config.Config{Env: "test"}, func(string) (uint, error) { return userID, nil }, - NewHandler(NewService(database)), + NewHandler(newServiceWithFetcher(database, staticFeedFetcher{})), ) } +type staticFeedFetcher struct{} + +func (staticFeedFetcher) Fetch(context.Context, string) (ParsedFeed, error) { + return ParsedFeed{ + Format: "rss", + Items: []FeedItem{{ + ExternalID: "stable-feed-item", + Title: "Collected item", + Summary: "Collected summary", + Content: "Collected content", + URL: "https://example.com/collected", + }}, + }, nil +} + func performDatasetRequest(t *testing.T, router http.Handler, method, path string, body any) *httptest.ResponseRecorder { t.Helper() var encoded []byte diff --git a/backend/internal/logic/dataset/service.go b/backend/internal/logic/dataset/service.go index fef6b54..2a84415 100644 --- a/backend/internal/logic/dataset/service.go +++ b/backend/internal/logic/dataset/service.go @@ -1,7 +1,9 @@ package dataset import ( + "context" "errors" + "fmt" "net/url" "strings" "time" @@ -19,7 +21,8 @@ var ( ) type Service struct { - db *gorm.DB + db *gorm.DB + fetcher FeedFetcher } type SourceInput struct { @@ -51,7 +54,11 @@ type SourceRecord struct { } func NewService(database *gorm.DB) *Service { - return &Service{db: database} + return &Service{db: database, fetcher: NewHTTPFeedFetcher()} +} + +func newServiceWithFetcher(database *gorm.DB, fetcher FeedFetcher) *Service { + return &Service{db: database, fetcher: fetcher} } func (s *Service) ListSources(userID uint) ([]SourceRecord, error) { @@ -208,36 +215,111 @@ func (s *Service) ListCrons(userID uint) ([]models.SaDatasetCron, error) { return crons, nil } -func (s *Service) QueueSync(userID uint) ([]models.SaDatasetCron, error) { +func (s *Service) SyncSources(ctx context.Context, userID uint) ([]models.SaDatasetCron, error) { result := make([]models.SaDatasetCron, 0) - err := s.db.Transaction(func(tx *gorm.DB) error { - var sources []models.SaDatasetSource - if err := tx.Where("created_by = ? AND enabled = ?", userID, true).Order("id asc").Find(&sources).Error; err != nil { - return err - } + var sources []models.SaDatasetSource + if err := s.db.Where("created_by = ? AND enabled = ? AND kind = ?", userID, true, "rss"). + Order("id asc").Find(&sources).Error; err != nil { + return nil, err + } + for _, source := range sources { now := time.Now().UTC() - for _, source := range sources { - var cron models.SaDatasetCron - err := tx.Where("source_id = ? AND created_by = ? AND status = ?", source.ID, userID, "pending").First(&cron).Error - if err == nil { - result = append(result, cron) - continue - } - if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - cron = models.SaDatasetCron{ - SourceID: source.ID, CreatedBy: userID, Schedule: "@once", - Status: "pending", Enabled: true, NextRunAt: &now, - } - if err := tx.Create(&cron).Error; err != nil { - return err + cron := models.SaDatasetCron{ + SourceID: source.ID, CreatedBy: userID, Schedule: "@once", + Status: "running", Enabled: true, NextRunAt: &now, LastRunAt: &now, + } + if err := s.db.Create(&cron).Error; err != nil { + return nil, err + } + + feed, fetchErr := s.fetcher.Fetch(ctx, source.URL) + if fetchErr != nil { + cron.Status = "failed" + cron.Enabled = false + cron.NextRunAt = nil + cron.LastResult = truncateResult(fetchErr.Error()) + if err := s.db.Model(&cron).Updates(map[string]any{ + "status": cron.Status, "last_result": cron.LastResult, "enabled": false, "next_run_at": nil, + }).Error; err != nil { + return nil, err } result = append(result, cron) + continue + } + + inserted, err := s.storeFeedItems(userID, source, feed.Items) + if err != nil { + cron.Status = "failed" + cron.Enabled = false + cron.NextRunAt = nil + cron.LastResult = truncateResult("store feed items: " + err.Error()) + if updateErr := s.db.Model(&cron).Updates(map[string]any{ + "status": cron.Status, "last_result": cron.LastResult, "enabled": false, "next_run_at": nil, + }).Error; updateErr != nil { + return nil, updateErr + } + result = append(result, cron) + continue + } + completedAt := time.Now().UTC() + cron.Status = "completed" + cron.Enabled = false + cron.NextRunAt = nil + cron.LastResult = fmt.Sprintf("format=%s fetched=%d inserted=%d", feed.Format, len(feed.Items), inserted) + if err := s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&cron).Updates(map[string]any{ + "status": cron.Status, "last_result": cron.LastResult, "enabled": false, "next_run_at": nil, + }).Error; err != nil { + return err + } + return tx.Model(&source).Update("last_synced_at", completedAt).Error + }); err != nil { + return nil, err + } + source.LastSyncedAt = &completedAt + result = append(result, cron) + } + return result, nil +} + +func (s *Service) storeFeedItems(userID uint, source models.SaDatasetSource, items []FeedItem) (int, error) { + inserted := 0 + err := s.db.Transaction(func(tx *gorm.DB) error { + for _, input := range items { + var count int64 + if err := tx.Model(&models.SaDatasetItem{}). + Where("source_id = ? AND external_id = ?", source.ID, input.ExternalID). + Count(&count).Error; err != nil { + return err + } + if count > 0 { + continue + } + item := models.SaDatasetItem{ + SourceID: source.ID, CreatedBy: userID, ExternalID: input.ExternalID, + Title: input.Title, Summary: input.Summary, Content: input.Content, + URL: input.URL, Status: "unread", PublishedAt: input.PublishedAt, + } + if err := tx.Create(&item).Error; err != nil { + return err + } + inserted++ } return nil }) - return result, err + return inserted, err +} + +func truncateResult(value string) string { + return truncateRunes(value, 2000) +} + +func truncateRunes(value string, limit int) string { + runes := []rune(value) + if len(runes) <= limit { + return value + } + return string(runes[:limit]) } func (s *Service) findOwnedSource(userID uint, identity string) (*models.SaDatasetSource, error) { @@ -272,7 +354,8 @@ func normalizeSourceInput(input SourceInput) (SourceInput, error) { func validHTTPURL(value string) bool { parsed, err := url.ParseRequestURI(value) - return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != "" + return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") && + parsed.Host != "" && parsed.User == nil } func utcOptionalTime(value *time.Time) *time.Time { diff --git a/backend/internal/models/dataset_item.go b/backend/internal/models/dataset_item.go index a1dab9c..8cf28b0 100644 --- a/backend/internal/models/dataset_item.go +++ b/backend/internal/models/dataset_item.go @@ -9,6 +9,7 @@ type SaDatasetItem struct { SourceIdentity string `gorm:"type:char(36);index"` CreatedBy uint `gorm:"index;not null"` CreatedByIdentity string `gorm:"type:char(36);index"` + ExternalID string `gorm:"size:64;index"` Title string `gorm:"size:500;not null"` Summary string `gorm:"type:text"` Content string `gorm:"type:text"`