Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,4 @@ db/
start.sh
start-full.sh
.claude/
.dev-data/
14 changes: 14 additions & 0 deletions internal/controller/topic_feed.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,14 @@ func CreateTopicFeed(c *gin.Context) {
c.JSON(http.StatusBadRequest, util.APIResponse[any]{Msg: err.Error()})
return
}
topicData.NormalizeInputs()
db := util.GetDatabase()

if err := dao.CreateTopicFeed(db, &topicData); err != nil {
c.JSON(http.StatusInternalServerError, util.APIResponse[any]{Msg: err.Error()})
return
}
topicData.NormalizeInputs()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

此处对 NormalizeInputs() 的调用是多余的,因为在第 69 行保存之前已经对输入源进行了归一化,且 GORM 的 Create 操作并不会修改它们。移除此调用可以避免不必要的内存分配和处理开销。


c.JSON(http.StatusCreated, util.APIResponse[any]{Data: topicData})
}
Expand All @@ -89,6 +91,7 @@ func GetTopicFeed(c *gin.Context) {
c.JSON(http.StatusInternalServerError, util.APIResponse[any]{Msg: err.Error()})
return
}
topicData.NormalizeInputs()

c.JSON(http.StatusOK, util.APIResponse[any]{Data: topicData})
}
Expand All @@ -101,6 +104,10 @@ func ListTopicFeeds(c *gin.Context) {
return
}

for _, topic := range topicList {
topic.NormalizeInputs()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): NormalizeInputs is called on a copy of each TopicFeed, so list responses never expose normalized Inputs

Ranging with for _, topic := range topicList passes each TopicFeed by value, so NormalizeInputs only updates a copy and topicList is unchanged. That means the /topic_feed list response won’t actually contain normalized Inputs that the frontend expects.

Iterate by index instead so you mutate the slice elements:

for i := range topicList {
    topicList[i].NormalizeInputs()
}

}

c.JSON(http.StatusOK, util.APIResponse[any]{Data: topicList})
}

Expand All @@ -116,6 +123,7 @@ func UpdateTopicFeed(c *gin.Context) {
if id != topicData.ID {
topicData.ID = id
}
topicData.NormalizeInputs()

db := util.GetDatabase()

Expand All @@ -133,6 +141,7 @@ func UpdateTopicFeed(c *gin.Context) {
c.JSON(http.StatusInternalServerError, util.APIResponse[any]{Msg: err.Error()})
return
}
topicData.NormalizeInputs()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

此处对 NormalizeInputs() 的调用是多余的,因为在第 126 行保存之前已经对输入源进行了归一化,且 GORM 的 Save 操作并不会修改它们。移除此调用可以避免不必要的内存分配和处理开销。


c.JSON(http.StatusOK, util.APIResponse[any]{Data: topicData})
}
Expand All @@ -159,6 +168,7 @@ func ValidateTopicFeed(c *gin.Context) {
c.JSON(http.StatusBadRequest, util.APIResponse[any]{Msg: err.Error()})
return
}
topicData.NormalizeInputs()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

此处对 NormalizeInputs() 的调用是多余的,因为 validateTopicConfig(在第 173 行被调用)内部已经在第 288 行对输入源进行了归一化。移除此调用可以避免重复处理。


result, err := validateTopicConfig(c.Request.Context(), util.GetDatabase(), &topicData)
if err != nil {
Expand All @@ -182,6 +192,7 @@ func GetTopicFeedDetail(c *gin.Context) {
c.JSON(http.StatusInternalServerError, util.APIResponse[any]{Msg: err.Error()})
return
}
topicData.NormalizeInputs()

health, err := dao.GetResourceHealth(db, dao.ResourceTypeTopic, id)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
Expand Down Expand Up @@ -273,6 +284,9 @@ func validateTopicConfig(ctx context.Context, db *gorm.DB, topicData *dao.TopicF
Errors: []TopicValidationIssue{},
Warnings: []TopicValidationIssue{},
}
if topicData != nil {
topicData.NormalizeInputs()
}
if topicData == nil {
result.Valid = false
result.Errors = append(result.Errors, TopicValidationIssue{
Expand Down
61 changes: 59 additions & 2 deletions internal/dao/topic.go
Original file line number Diff line number Diff line change
@@ -1,28 +1,85 @@
package dao

import (
"strings"

"gorm.io/gorm"
)

// TopicInput is a single upstream source with an optional admin label.
type TopicInput struct {
URI string `json:"uri"`
Description string `json:"description,omitempty"`
}

// TopicFeed represents the persistence model for a multi-source aggregation node.
type TopicFeed struct {
BaseModelWithoutPK
ID string `gorm:"primaryKey" json:"id" binding:"required"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`

// List of URIs representing inputs.
// Inputs carries URI plus optional description for admin display.
Inputs []TopicInput `json:"inputs,omitempty" gorm:"serializer:json"`

// List of URIs representing inputs (derived from Inputs for runtime).
// Uses a custom protocol for internal resources to make routing elegant and standard.
// Examples:
// - "feedcraft://recipe/my-tech-recipe" (Internal RecipeFeed)
// - "feedcraft://topic/sub-topic-id" (Nested internal TopicFeed)
// - "https://external.com/rss.xml" (External raw feed)
InputURIs []string `json:"input_uris" binding:"required" gorm:"serializer:json"`
InputURIs []string `json:"input_uris" gorm:"serializer:json"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Inputs not validated on save 🐞 Bug ≡ Correctness

CreateTopicFeed/UpdateTopicFeed 在写入数据库前未强制校验“至少一个启用输入源”,且 InputURIs 不再是 binding required,导致可保存
inputs/input_uris 为空的 TopicFeed,运行时构建 Topic 会得到 0 个输入或直接失败。
Agent Prompt
## Issue description
Create/Update API can persist a TopicFeed with no enabled inputs because `input_uris` is no longer `binding:"required"` and the controllers don’t validate `len(topicData.InputURIs) > 0` after `NormalizeInputs()`.

## Issue Context
The codebase already defines the business rule in `validateTopicConfig`: at least one enabled input is required. Runtime topic building iterates `topic.InputURIs` to construct providers.

## Fix Focus Areas
- internal/controller/topic_feed.go[63-79]
- internal/controller/topic_feed.go[114-147]
- internal/controller/topic_feed.go[281-351]

## Suggested fix
1. In `CreateTopicFeed` and `UpdateTopicFeed`, after `topicData.NormalizeInputs()`:
   - If `len(topicData.InputURIs) == 0`, return `400 BadRequest` with a clear message (e.g. "At least one enabled input source is required").
2. (Optional but stronger) Reuse/extend `validateTopicConfig` to provide richer errors (runtime build errors) for Create/Update as well, or add a lightweight URI validation step for internal URIs.
3. Ensure responses consistently return `input_uris` as an empty array (not null) when invalid input is rejected.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

// Configuration for the aggregator pipeline
AggregatorConfig []AggregatorStep `json:"aggregator_config" gorm:"serializer:json"`
}

// NormalizeInputs keeps Inputs and InputURIs in sync.
// When Inputs is provided it becomes the source of truth; otherwise legacy InputURIs are upgraded.
func (t *TopicFeed) NormalizeInputs() {

Check warning on line 39 in internal/dao/topic.go

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

internal/dao/topic.go#L39

Method NormalizeInputs has a cyclomatic complexity of 9 (limit is 8)
if t == nil {
return
}

if len(t.Inputs) > 0 {
uris := make([]string, 0, len(t.Inputs))
normalized := make([]TopicInput, 0, len(t.Inputs))
for _, item := range t.Inputs {
uri := strings.TrimSpace(item.URI)
if uri == "" {
continue
}
uris = append(uris, uri)
normalized = append(normalized, TopicInput{
URI: uri,
Description: strings.TrimSpace(item.Description),
})
}
t.InputURIs = uris
t.Inputs = normalized
return
}

if len(t.InputURIs) == 0 {
t.Inputs = nil
return
}

inputs := make([]TopicInput, 0, len(t.InputURIs))
for _, uri := range t.InputURIs {
uri = strings.TrimSpace(uri)
if uri == "" {
continue
}
inputs = append(inputs, TopicInput{URI: uri})
}
t.Inputs = inputs
t.InputURIs = make([]string, 0, len(inputs))
for _, item := range inputs {
t.InputURIs = append(t.InputURIs, item.URI)
}
}

// AggregatorStep defines a single processing step in an Aggregator pipeline.
type AggregatorStep struct {
Type string `json:"type" binding:"required"` // e.g., "deduplicate", "sort", "limit"
Expand Down
42 changes: 42 additions & 0 deletions internal/dao/topic_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package dao

import "testing"

func TestTopicFeed_NormalizeInputs_FromInputs(t *testing.T) {
topic := &TopicFeed{
Inputs: []TopicInput{
{URI: "https://example.com/a.xml", Description: "Tech news"},
{URI: " feedcraft://recipe/foo ", Description: ""},
},
}

topic.NormalizeInputs()

if len(topic.InputURIs) != 2 {
t.Fatalf("expected 2 input URIs, got %d", len(topic.InputURIs))
}
if topic.InputURIs[0] != "https://example.com/a.xml" {
t.Fatalf("unexpected first URI: %q", topic.InputURIs[0])
}
if topic.InputURIs[1] != "feedcraft://recipe/foo" {
t.Fatalf("unexpected second URI: %q", topic.InputURIs[1])
}
if topic.Inputs[0].Description != "Tech news" {
t.Fatalf("unexpected description: %q", topic.Inputs[0].Description)
}
}

func TestTopicFeed_NormalizeInputs_FromLegacyURIs(t *testing.T) {
topic := &TopicFeed{
InputURIs: []string{"https://example.com/a.xml", "https://example.com/b.xml"},
}

topic.NormalizeInputs()

if len(topic.Inputs) != 2 {
t.Fatalf("expected 2 inputs, got %d", len(topic.Inputs))
}
if topic.Inputs[0].URI != "https://example.com/a.xml" {
t.Fatalf("unexpected first input URI: %q", topic.Inputs[0].URI)
}
}
6 changes: 6 additions & 0 deletions web/admin/src/api/topic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,16 @@ export interface AggregatorStep {
option: Record<string, string>;
}

export interface TopicInput {
uri: string;
description?: string;
}

export interface TopicFeed {
id: string;
title?: string;
description?: string;
inputs?: TopicInput[];
input_uris: string[];
aggregator_config: AggregatorStep[];
}
Expand Down
101 changes: 101 additions & 0 deletions web/admin/src/components/feed-preview/FeedPreviewPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<template>
<div class="feed-preview-panel">
<a-spin :loading="loading" style="width: 100%">
<a-alert v-if="errorMessage" type="error" class="mb-4" show-icon>
{{ errorMessage }}
</a-alert>
<FeedViewContainer v-if="feedContent" :feed-data="feedContent" />
<a-empty
v-else-if="!loading && !errorMessage"
:description="emptyDescription || t('feedViewer.resultPreview')"
/>
</a-spin>
</div>
</template>

<script lang="ts" setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { previewFeed, type FeedViewerPreview } from '@/api/feed_viewer';
import FeedViewContainer from '@/views/dashboard/feed_viewer/feed_view_container.vue';

const props = withDefaults(
defineProps<{
inputUri?: string;
autoLoad?: boolean;
emptyDescription?: string;
}>(),
{
inputUri: '',
autoLoad: false,
emptyDescription: '',
}

Check warning on line 32 in web/admin/src/components/feed-preview/FeedPreviewPanel.vue

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

web/admin/src/components/feed-preview/FeedPreviewPanel.vue#L32

Method loadPreview has a cyclomatic complexity of 11 (limit is 8)
);

const { t } = useI18n();
const loading = ref(false);
const errorMessage = ref('');
const feedContent = ref<FeedViewerPreview | null>(null);
let requestSeq = 0;

const resetState = () => {
requestSeq += 1;
feedContent.value = null;
errorMessage.value = '';
};

const loadPreview = async (inputUri = props.inputUri) => {
const uri = inputUri?.trim();
if (!uri) {
resetState();
return;
}

const currentSeq = requestSeq + 1;
requestSeq = currentSeq;
loading.value = true;
errorMessage.value = '';

try {
const response = await previewFeed(uri);
if (currentSeq !== requestSeq) return;
feedContent.value = response.data ?? null;
if (!feedContent.value) {
errorMessage.value = response.msg || t('topic.inputPreview.failed');
}
} catch (err: any) {
if (currentSeq !== requestSeq) return;
feedContent.value = null;
errorMessage.value = err.message || t('topic.inputPreview.failed');
} finally {
if (currentSeq === requestSeq) {
loading.value = false;
}
}
};

watch(
() => props.inputUri,
(uri) => {
if (!props.autoLoad) {
resetState();
return;
}
loadPreview(uri);
}
);

defineExpose({
loadPreview,
resetState,
loading,
feedContent,
errorMessage,
});
</script>

<script lang="ts">
export default {
name: 'FeedPreviewPanel',
};
</script>
21 changes: 21 additions & 0 deletions web/admin/src/locale/en-US/topic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,27 @@ export default {
'For External RSS, enter an http/https URL. For Recipe or Topic, pick from the dropdown to use internal FeedCraft resources.',
'topic.inputPlaceholder': 'For example: feedcraft://recipe/my-recipe',
'topic.addInput': 'Add Input',
'topic.inputDescription.placeholder':
'Label for this input (optional, helps you recognize it later)',
'topic.inputPreview': 'Preview',
'topic.inputPreview.title': 'Input #{index}',
'topic.inputPreview.currentUri': 'Preview URI',
'topic.inputPreview.empty':
'Click Preview to load feed content for this input',
'topic.inputPreview.failed': 'Preview failed',
'topic.wizard.description':
'Configure topic basics, input sources, and aggregation rules step by step.',
'topic.wizard.step.basic': 'Basics',
'topic.wizard.step.basic.desc': 'ID, title, and description',
'topic.wizard.step.inputs': 'Inputs',
'topic.wizard.step.inputs.desc': 'Add sub-feeds and preview when needed',
'topic.wizard.step.aggregator': 'Aggregation',
'topic.wizard.step.aggregator.desc': 'Deduplicate, sort, and limit items',
'topic.wizard.step.review': 'Review & Save',
'topic.wizard.step.review.desc': 'Validate and submit',
'topic.wizard.prev': 'Previous',
'topic.wizard.next': 'Next',
'topic.wizard.inputsRequired': 'Add at least one valid input source',
'topic.removeInput': 'Remove',
'topic.removeStep': 'Remove Rule',
'topic.sectionInputs': 'Input Sources',
Expand Down
18 changes: 18 additions & 0 deletions web/admin/src/locale/zh-CN/topic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,24 @@ export default {
'外部 RSS 直接填入 http/https 地址;选择 Recipe 或 Topic 可从下拉列表中选取 FeedCraft 内部资源。',
'topic.inputPlaceholder': '例如 feedcraft://recipe/my-recipe',
'topic.addInput': '添加输入源',
'topic.inputDescription.placeholder': '备注名称,便于识别此输入源(可选)',
'topic.inputPreview': '预览',
'topic.inputPreview.title': '输入源 #{index}',
'topic.inputPreview.currentUri': '当前预览地址',
'topic.inputPreview.empty': '点击「预览」查看该输入源的 Feed 内容',
'topic.inputPreview.failed': '预览失败',
'topic.wizard.description': '按步骤配置主题基本信息、输入源与聚合规则。',
'topic.wizard.step.basic': '基本信息',
'topic.wizard.step.basic.desc': '设置 ID、标题与描述',
'topic.wizard.step.inputs': '输入源',
'topic.wizard.step.inputs.desc': '添加子 Feed 并可选预览',
'topic.wizard.step.aggregator': '聚合规则',
'topic.wizard.step.aggregator.desc': '去重、排序与数量限制',
'topic.wizard.step.review': '确认保存',
'topic.wizard.step.review.desc': '校验配置并提交',
'topic.wizard.prev': '上一步',
'topic.wizard.next': '下一步',
'topic.wizard.inputsRequired': '请至少添加一个有效的输入源',
'topic.removeInput': '移除',
'topic.removeStep': '移除规则',
'topic.sectionInputs': '输入源',
Expand Down
Loading
Loading