From 9d6509840c7ca4775c44607a1fb6f38935f9908e Mon Sep 17 00:00:00 2001 From: mateaix <57164338+mateaix@users.noreply.github.com> Date: Sun, 12 Jul 2026 13:08:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(content-studio):=20=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E6=97=A5=E5=8E=86=E5=8E=BB=E9=87=8D=20+=20=E6=AF=9B=E7=8E=BB?= =?UTF-8?q?=E7=92=83=E6=94=B9=E7=89=88=20+=20=E5=A4=96=E6=A1=86=E7=BA=BF?= =?UTF-8?q?=E5=8A=A0=E5=AE=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/service/ContentItemService.java | 44 +++- .../service/ContentItemServiceTest.java | 64 +++++ mateclaw-ui/src/views/ContentCalendar.vue | 230 ++++++++++++------ 3 files changed, 254 insertions(+), 84 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/content/service/ContentItemServiceTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/content/service/ContentItemService.java b/mateclaw-server/src/main/java/vip/mate/content/service/ContentItemService.java index b15d2c14..eb9dd5a7 100644 --- a/mateclaw-server/src/main/java/vip/mate/content/service/ContentItemService.java +++ b/mateclaw-server/src/main/java/vip/mate/content/service/ContentItemService.java @@ -31,6 +31,9 @@ public class ContentItemService { /** Ignore same-topic rows created within this window, so a record→check in one * run doesn't flag itself as a repeat. */ private static final long SELF_MATCH_GUARD_MINUTES = 2; + /** Re-packaging the same topic within this window updates the existing ledger + * row instead of inserting a duplicate (the agent may call a package tool twice). */ + private static final long RECORD_DEDUP_MINUTES = 10; private final ContentItemMapper contentItemMapper; @@ -50,16 +53,49 @@ public class ContentItemService { .orderByDesc(ContentItemEntity::getCreateTime)); } - /** Record a produced piece; returns the new item id. */ + /** + * Record a produced piece; returns its item id. Idempotent within a short + * window: re-packaging the same topic on the same platform (e.g. the agent + * called a package tool twice) updates the existing row instead of inserting + * a duplicate. A published row is never overwritten. + */ public Long record(Long workspaceId, String platform, String topic, String title, String status, String previewUrl, String externalRef) { + String plat = platform.trim().toLowerCase(); + String fp = fingerprint(topic != null ? topic : title); + String resolvedStatus = status == null || status.isBlank() ? "packaged" : status.trim().toLowerCase(); + + ContentItemEntity existing = contentItemMapper.selectOne(new LambdaQueryWrapper() + .eq(ContentItemEntity::getPlatform, plat) + .eq(ContentItemEntity::getTopicFingerprint, fp) + .ne(ContentItemEntity::getStatus, "published") + .ge(ContentItemEntity::getCreateTime, LocalDateTime.now().minusMinutes(RECORD_DEDUP_MINUTES)) + .orderByDesc(ContentItemEntity::getCreateTime) + .last("LIMIT 1")); + if (existing != null) { + if (title != null && !title.isBlank()) { + existing.setTitle(title.trim()); + } + if (previewUrl != null) { + existing.setPreviewUrl(previewUrl); + } + if (externalRef != null) { + existing.setExternalRef(externalRef); + } + existing.setStatus(resolvedStatus); + contentItemMapper.updateById(existing); + log.info("[ContentItem] re-package dedup: updated id={} platform={} topic='{}'", + existing.getId(), plat, topic); + return existing.getId(); + } + ContentItemEntity e = new ContentItemEntity(); e.setWorkspaceId(workspaceId); - e.setPlatform(platform.trim().toLowerCase()); + e.setPlatform(plat); e.setTopic(topic != null ? topic.trim() : null); - e.setTopicFingerprint(fingerprint(topic != null ? topic : title)); + e.setTopicFingerprint(fp); e.setTitle(title != null ? title.trim() : null); - e.setStatus(status == null || status.isBlank() ? "packaged" : status.trim().toLowerCase()); + e.setStatus(resolvedStatus); e.setPreviewUrl(previewUrl); e.setExternalRef(externalRef); contentItemMapper.insert(e); diff --git a/mateclaw-server/src/test/java/vip/mate/content/service/ContentItemServiceTest.java b/mateclaw-server/src/test/java/vip/mate/content/service/ContentItemServiceTest.java new file mode 100644 index 00000000..8fec3895 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/content/service/ContentItemServiceTest.java @@ -0,0 +1,64 @@ +package vip.mate.content.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.content.model.ContentItemEntity; +import vip.mate.content.repository.ContentItemMapper; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Pin {@link ContentItemService#record} idempotency: re-packaging the same topic + * on the same platform within the dedup window updates the existing ledger row + * instead of inserting a duplicate; a genuinely new topic inserts a fresh row. + */ +class ContentItemServiceTest { + + private ContentItemMapper mapper; + private ContentItemService service; + + @BeforeEach + void setUp() { + mapper = mock(ContentItemMapper.class); + service = new ContentItemService(mapper); + } + + @Test + @DisplayName("re-package of a recent same topic updates the existing row, no duplicate insert") + void rePackageUpdatesInsteadOfInserting() { + ContentItemEntity existing = new ContentItemEntity(); + existing.setId(555L); + existing.setStatus("packaged"); + when(mapper.selectOne(any())).thenReturn(existing); + + Long id = service.record(1L, "gzh", "科技数码选题", "新标题", "packaged", "http://p/2", null); + + assertEquals(555L, id, "should return the existing row's id"); + verify(mapper, never()).insert(any(ContentItemEntity.class)); + verify(mapper, times(1)).updateById(existing); + assertEquals("新标题", existing.getTitle(), "title refreshed on the existing row"); + assertEquals("http://p/2", existing.getPreviewUrl()); + } + + @Test + @DisplayName("a new topic (no recent match) inserts a fresh row") + void newTopicInserts() { + when(mapper.selectOne(any())).thenReturn(null); + + service.record(1L, "xhs", "全新选题", "标题", "packaged", "http://p/1", null); + + verify(mapper, times(1)).insert(any(ContentItemEntity.class)); + verify(mapper, never()).updateById(any(ContentItemEntity.class)); + } + + @Test + @DisplayName("fingerprint falls back to title when topic is null") + void fingerprintFallsBackToTitle() { + assertEquals(ContentItemService.fingerprint("我的标题"), + ContentItemService.fingerprint("我的标题")); + assertNotEquals(ContentItemService.fingerprint("甲"), ContentItemService.fingerprint("乙")); + } +} diff --git a/mateclaw-ui/src/views/ContentCalendar.vue b/mateclaw-ui/src/views/ContentCalendar.vue index 9f88bc35..645bdd0f 100644 --- a/mateclaw-ui/src/views/ContentCalendar.vue +++ b/mateclaw-ui/src/views/ContentCalendar.vue @@ -5,86 +5,88 @@

{{ t('contentCalendar.subtitle') }}

- +
-
+
{{ card.count }}
{{ card.label }}
-
+
- -
- + +
+ - {{ t('contentCalendar.refresh') }} +
- - - - - - - - - - - - - - - - - - - + +
+ + + + + + + + + + + + + + + + + + + + -
- +
+ +