From d7f226659c9d42fa800b612e1a2fde31df88302e Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 22:34:07 +0800 Subject: [PATCH 1/2] =?UTF-8?q?test(content):=20=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E7=9F=AD=E5=89=A7=E5=85=8D=E8=B4=B9=E9=9B=86=E6=95=B0=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=88=86=E9=9B=86=E4=BB=B7=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-10-21-drama-free-episodes-sync.md | 30 +++ fruitshorts-module-content/pom.xml | 5 + .../content/service/DramaServiceTest.java | 189 ++++++++++++++++++ 3 files changed, 224 insertions(+) create mode 100644 docs/plans/2026-10-21-drama-free-episodes-sync.md create mode 100644 fruitshorts-module-content/src/test/java/com/fruitshorts/content/service/DramaServiceTest.java diff --git a/docs/plans/2026-10-21-drama-free-episodes-sync.md b/docs/plans/2026-10-21-drama-free-episodes-sync.md new file mode 100644 index 0000000..327a9f8 --- /dev/null +++ b/docs/plans/2026-10-21-drama-free-episodes-sync.md @@ -0,0 +1,30 @@ +# 2026-10-21 | 短剧保存免费集数同步分集 + +## 目标 + +后台短剧编辑改 `freeEpisodes` / `unlockCoin` 后,同步 `drama_episode.is_free` / `unlock_coin`,与定价页 `PriceService.save()` 规则一致。 + +## 方案 + +1. 抽出 `EpisodePriceSync.syncByDramaRule(dramaId, freeEpisodes, unlockCoin)`,供 `DramaService.update` 与 `PriceService.save` 共用。 +2. `DramaService.update` 在 `updateById` 后,仅当免费集数或解锁金币相对更新前有变化时调用同步。 +3. TDD:先写 `DramaServiceTest` 复现失败,再改实现转绿。 + +## 范围 + +- 仅 `fruitshorts-module-content` +- 不改 Controller / VO / 契约 / 前端 + +## 门禁 + +```bash +mvn -pl fruitshorts-module-content test -Dtest=DramaServiceTest +mvn -pl fruitshorts-module-content test +``` + +## 分支 / 提交 + +- `feature/drama-free-episodes-sync` ← `develop` +- `test(content): 覆盖短剧免费集数同步分集价格` +- `fix(content): 短剧保存免费集数时同步分集免费状态` +- `merge --no-ff` 回 develop 并 push diff --git a/fruitshorts-module-content/pom.xml b/fruitshorts-module-content/pom.xml index e16989e..7782008 100644 --- a/fruitshorts-module-content/pom.xml +++ b/fruitshorts-module-content/pom.xml @@ -25,5 +25,10 @@ com.fruitshorts fruitshorts-module-system + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/fruitshorts-module-content/src/test/java/com/fruitshorts/content/service/DramaServiceTest.java b/fruitshorts-module-content/src/test/java/com/fruitshorts/content/service/DramaServiceTest.java new file mode 100644 index 0000000..22e6fac --- /dev/null +++ b/fruitshorts-module-content/src/test/java/com/fruitshorts/content/service/DramaServiceTest.java @@ -0,0 +1,189 @@ +// Author: fruit.com.cn + +package com.fruitshorts.content.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.fruitshorts.content.dal.entity.Drama; +import com.fruitshorts.content.dal.entity.DramaEpisode; +import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper; +import com.fruitshorts.content.dal.mapper.DramaMapper; +import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper; +import com.fruitshorts.content.vo.DramaSaveReqVO; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * 短剧保存时免费集数 / 解锁金币须同步分集价格。 + */ +class DramaServiceTest { + + private static final long DRAMA_ID = 1001L; + private static final int UNLOCK_COIN = 60; + + private DramaMapper dramaMapper; + private DramaEpisodeMapper dramaEpisodeMapper; + private DramaService dramaService; + private List episodes; + + @BeforeAll + static void initTableInfoCache() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, Drama.class); + TableInfoHelper.initTableInfo(assistant, DramaEpisode.class); + } + + @BeforeEach + void setUp() { + dramaMapper = mock(DramaMapper.class); + dramaEpisodeMapper = mock(DramaEpisodeMapper.class); + DramaReviewRecordMapper reviewRecordMapper = mock(DramaReviewRecordMapper.class); + DramaCategoryService categoryService = mock(DramaCategoryService.class); + dramaService = new DramaService( + dramaMapper, dramaEpisodeMapper, reviewRecordMapper, categoryService); + episodes = new ArrayList<>(); + given(dramaEpisodeMapper.selectList(any())).willAnswer(invocation -> List.copyOf(episodes)); + given(dramaEpisodeMapper.updateById(any(DramaEpisode.class))).willAnswer(invocation -> { + DramaEpisode updated = invocation.getArgument(0); + for (int i = 0; i < episodes.size(); i++) { + if (episodes.get(i).getId().equals(updated.getId())) { + episodes.set(i, updated); + break; + } + } + return 1; + }); + } + + @Test + void should_mark_episodes_4_and_5_free_when_free_episodes_increase_from_3_to_5() { + givenExistingDrama(3, UNLOCK_COIN); + seedEpisodesAlignedToFree(3, 6); + + dramaService.update(DRAMA_ID, saveReq(5, UNLOCK_COIN)); + + Map byEpNo = episodeMap(); + assertThat(byEpNo.get(4).getIsFree()).isEqualTo(1); + assertThat(byEpNo.get(4).getUnlockCoin()).isZero(); + assertThat(byEpNo.get(5).getIsFree()).isEqualTo(1); + assertThat(byEpNo.get(5).getUnlockCoin()).isZero(); + assertThat(byEpNo.get(6).getIsFree()).isZero(); + assertThat(byEpNo.get(6).getUnlockCoin()).isEqualTo(UNLOCK_COIN); + verify(dramaEpisodeMapper, times(2)).updateById(any(DramaEpisode.class)); + } + + @Test + void should_mark_episodes_3_to_5_paid_when_free_episodes_decrease_from_5_to_2() { + givenExistingDrama(5, UNLOCK_COIN); + seedEpisodesAlignedToFree(5, 5); + + dramaService.update(DRAMA_ID, saveReq(2, UNLOCK_COIN)); + + Map byEpNo = episodeMap(); + assertThat(byEpNo.get(3).getIsFree()).isZero(); + assertThat(byEpNo.get(3).getUnlockCoin()).isEqualTo(UNLOCK_COIN); + assertThat(byEpNo.get(4).getIsFree()).isZero(); + assertThat(byEpNo.get(4).getUnlockCoin()).isEqualTo(UNLOCK_COIN); + assertThat(byEpNo.get(5).getIsFree()).isZero(); + assertThat(byEpNo.get(5).getUnlockCoin()).isEqualTo(UNLOCK_COIN); + verify(dramaEpisodeMapper, times(3)).updateById(any(DramaEpisode.class)); + } + + @Test + void should_skip_episode_updates_when_free_episodes_unchanged() { + givenExistingDrama(3, UNLOCK_COIN); + seedEpisodesAlignedToFree(3, 4); + AtomicInteger episodeUpdates = new AtomicInteger(); + given(dramaEpisodeMapper.updateById(any(DramaEpisode.class))).willAnswer(invocation -> { + episodeUpdates.incrementAndGet(); + return 1; + }); + + dramaService.update(DRAMA_ID, saveReq(3, UNLOCK_COIN)); + + assertThat(episodeUpdates.get()).isZero(); + verify(dramaEpisodeMapper, never()).selectList(any()); + verify(dramaEpisodeMapper, never()).updateById(any(DramaEpisode.class)); + } + + @Test + void should_not_fail_when_drama_has_no_episodes() { + givenExistingDrama(3, UNLOCK_COIN); + episodes.clear(); + + assertThatCode(() -> dramaService.update(DRAMA_ID, saveReq(5, UNLOCK_COIN))) + .doesNotThrowAnyException(); + verify(dramaEpisodeMapper, never()).updateById(any(DramaEpisode.class)); + } + + private void givenExistingDrama(int freeEpisodes, int unlockCoin) { + Drama drama = new Drama(); + drama.setId(DRAMA_ID); + drama.setTitle("测试短剧"); + drama.setFreeEpisodes(freeEpisodes); + drama.setUnlockCoin(unlockCoin); + drama.setSubtitle(""); + drama.setGenre(""); + drama.setSynopsis(""); + drama.setCoverUrl(""); + drama.setTrailerUrl(""); + drama.setLanguage("id-ID"); + drama.setIsHot(0); + drama.setSort(0); + given(dramaMapper.selectById(DRAMA_ID)).willReturn(drama); + given(dramaMapper.updateById(any(Drama.class))).willReturn(1); + } + + private void seedEpisodesAlignedToFree(int freeEpisodes, int total) { + episodes.clear(); + for (int epNo = 1; epNo <= total; epNo++) { + DramaEpisode episode = new DramaEpisode(); + episode.setId((long) epNo); + episode.setDramaId(DRAMA_ID); + episode.setEpNo(epNo); + episode.setTitle("第" + epNo + "集"); + boolean free = epNo <= freeEpisodes; + episode.setIsFree(free ? 1 : 0); + episode.setUnlockCoin(free ? 0 : UNLOCK_COIN); + episodes.add(episode); + } + } + + private DramaSaveReqVO saveReq(int freeEpisodes, int unlockCoin) { + DramaSaveReqVO req = new DramaSaveReqVO(); + req.setTitle("测试短剧"); + req.setFreeEpisodes(freeEpisodes); + req.setUnlockCoin(unlockCoin); + return req; + } + + private Map episodeMap() { + ArgumentCaptor captor = ArgumentCaptor.forClass(DramaEpisode.class); + verify(dramaEpisodeMapper, org.mockito.Mockito.atLeastOnce()).updateById(captor.capture()); + Map updated = captor.getAllValues().stream() + .collect(Collectors.toMap(DramaEpisode::getEpNo, Function.identity(), (a, b) -> b)); + for (DramaEpisode episode : episodes) { + updated.putIfAbsent(episode.getEpNo(), episode); + } + return updated; + } +} From 4c4b53cd69918865e7ced91c5d34b1fe15221561 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 22:34:07 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(content):=20=E7=9F=AD=E5=89=A7=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E5=85=8D=E8=B4=B9=E9=9B=86=E6=95=B0=E6=97=B6=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=88=86=E9=9B=86=E5=85=8D=E8=B4=B9=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/service/DramaService.java | 16 +++ .../content/service/EpisodePriceSync.java | 62 +++++++++++ .../content/service/PriceService.java | 101 ++++++++++-------- .../content/service/DramaServiceTest.java | 3 +- 4 files changed, 136 insertions(+), 46 deletions(-) create mode 100644 fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/EpisodePriceSync.java diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java index 24e17b2..8734c64 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java @@ -30,6 +30,7 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; @Service @@ -42,6 +43,7 @@ public class DramaService { private final DramaEpisodeMapper dramaEpisodeMapper; private final DramaReviewRecordMapper dramaReviewRecordMapper; private final DramaCategoryService dramaCategoryService; + private final EpisodePriceSync episodePriceSync; public PageResult page(DramaPageReqVO req) { List dramaTypes = DramaMultiTagSupport.normalizeDramaTypes(req.getDramaTypes()); @@ -109,6 +111,8 @@ public class DramaService { @Transactional(rollbackFor = Exception.class) public void update(Long id, DramaSaveReqVO req) { Drama drama = getExisting(id); + Integer oldFreeEpisodes = drama.getFreeEpisodes(); + Integer oldUnlockCoin = drama.getUnlockCoin(); apply(drama, req, false); if (req.getFavoriteCount() != null) { drama.setFavoriteCount(nonNegative(req.getFavoriteCount(), "收藏数")); @@ -120,6 +124,18 @@ public class DramaService { drama.setViewCount(nonNegative(req.getHeat(), "热度")); } dramaMapper.updateById(drama); + syncEpisodePricesIfNeeded(drama, oldFreeEpisodes, oldUnlockCoin); + } + + /** 免费集数或解锁金币变化时,按定价规则回写分集。 */ + private void syncEpisodePricesIfNeeded(Drama drama, Integer oldFreeEpisodes, Integer oldUnlockCoin) { + if (Objects.equals(oldFreeEpisodes, drama.getFreeEpisodes()) + && Objects.equals(oldUnlockCoin, drama.getUnlockCoin())) { + return; + } + int freeEpisodes = drama.getFreeEpisodes() == null ? 0 : Math.max(0, drama.getFreeEpisodes()); + int unlockCoin = drama.getUnlockCoin() == null ? 0 : Math.max(0, drama.getUnlockCoin()); + episodePriceSync.syncByDramaRule(drama.getId(), freeEpisodes, unlockCoin); } @Transactional(rollbackFor = Exception.class) diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/EpisodePriceSync.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/EpisodePriceSync.java new file mode 100644 index 0000000..9e2bd1e --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/EpisodePriceSync.java @@ -0,0 +1,62 @@ +// Author: fruit.com.cn + +package com.fruitshorts.content.service; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fruitshorts.content.dal.entity.DramaEpisode; +import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Objects; + +/** + * 按短剧免费集数规则同步分集 {@code is_free} / {@code unlock_coin}。 + * + *

定价页与短剧编辑共用同一套规则,避免两处口径漂移。

+ */ +@Component +@RequiredArgsConstructor +public class EpisodePriceSync { + + private final DramaEpisodeMapper dramaEpisodeMapper; + + /** + * 全量同步某剧全部分集价格。 + * + *

规则:{@code epNo > 0 && epNo <= freeEpisodes} 为免费且 coin=0, + * 否则收费且 coin={@code unlockCoin}。

+ * + * @return 实际写库的分集数 + */ + public int syncByDramaRule(Long dramaId, int freeEpisodes, int unlockCoin) { + List episodes = dramaEpisodeMapper.selectList(Wrappers.lambdaQuery() + .eq(DramaEpisode::getDramaId, dramaId) + .orderByAsc(DramaEpisode::getEpNo)); + int changed = 0; + for (DramaEpisode episode : episodes) { + int epNo = episode.getEpNo() == null ? 0 : episode.getEpNo(); + int isFree = resolveIsFree(epNo, freeEpisodes); + int coin = resolveUnlockCoin(isFree, unlockCoin); + if (!Objects.equals(episode.getIsFree(), isFree) + || !Objects.equals(episode.getUnlockCoin(), coin)) { + episode.setIsFree(isFree); + episode.setUnlockCoin(coin); + dramaEpisodeMapper.updateById(episode); + changed++; + } + } + return changed; + } + + /** 集号落在免费区间内则为免费。 */ + public static int resolveIsFree(int epNo, int freeEpisodes) { + return epNo > 0 && epNo <= freeEpisodes ? 1 : 0; + } + + /** 免费集 coin 置 0,收费集用短剧解锁金币。 */ + public static int resolveUnlockCoin(int isFree, int unlockCoin) { + return isFree == 1 ? 0 : unlockCoin; + } +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/PriceService.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/PriceService.java index 3bb5c49..af65333 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/PriceService.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/PriceService.java @@ -32,6 +32,7 @@ public class PriceService { private final DramaService dramaService; private final DramaMapper dramaMapper; private final DramaEpisodeMapper dramaEpisodeMapper; + private final EpisodePriceSync episodePriceSync; /** 读取当前价格配置:后台定价页进入时先看现状再改。 */ public PriceRespVO detail(Long dramaId) { @@ -72,55 +73,13 @@ public class PriceService { drama.setUnlockCoin(unlockCoin); dramaMapper.updateById(drama); + int changed = episodePriceSync.syncByDramaRule(drama.getId(), freeEpisodes, unlockCoin); + changed += applyEpisodeOverrides(drama.getId(), unlockCoin, req); + List episodes = dramaEpisodeMapper.selectList(Wrappers.lambdaQuery() .eq(DramaEpisode::getDramaId, drama.getId()) .orderByAsc(DramaEpisode::getEpNo)); - Map overrides = new LinkedHashMap<>(); - if (req.getEpisodes() != null) { - for (PriceSaveReqVO.EpisodePrice item : req.getEpisodes()) { - if (item != null && item.getEpisodeId() != null) { - overrides.put(item.getEpisodeId(), item); - } - } - } - - int changed = 0; - for (DramaEpisode episode : episodes) { - int epNo = episode.getEpNo() == null ? 0 : episode.getEpNo(); - int isFree = epNo > 0 && epNo <= freeEpisodes ? 1 : 0; - int coin = isFree == 1 ? 0 : unlockCoin; - - PriceSaveReqVO.EpisodePrice override = overrides.get(episode.getId()); - Integer previewSeconds = null; - if (override != null) { - if (override.getIsFree() != null) { - isFree = override.getIsFree() == 1 ? 1 : 0; - coin = isFree == 1 ? 0 : unlockCoin; - } - if (isFree == 0 && override.getUnlockCoin() != null) { - coin = Math.max(0, override.getUnlockCoin()); - } - if (override.getPreviewSeconds() != null) { - previewSeconds = Math.max(0, override.getPreviewSeconds()); - } - } - - boolean priceChanged = !Objects.equals(episode.getIsFree(), isFree) - || !Objects.equals(episode.getUnlockCoin(), coin); - boolean previewChanged = previewSeconds != null - && !Objects.equals(episode.getPreviewSeconds(), previewSeconds); - if (priceChanged || previewChanged) { - episode.setIsFree(isFree); - episode.setUnlockCoin(coin); - if (previewSeconds != null) { - episode.setPreviewSeconds(previewSeconds); - } - dramaEpisodeMapper.updateById(episode); - changed++; - } - } - PriceRespVO vo = new PriceRespVO(); vo.setDramaId(drama.getId()); vo.setFreeEpisodes(freeEpisodes); @@ -139,4 +98,56 @@ public class PriceService { }).toList()); return vo; } + + /** 在整剧规则同步后再落单集覆盖(含试看秒数)。 */ + private int applyEpisodeOverrides(Long dramaId, int unlockCoin, PriceSaveReqVO req) { + Map overrides = new LinkedHashMap<>(); + if (req.getEpisodes() != null) { + for (PriceSaveReqVO.EpisodePrice item : req.getEpisodes()) { + if (item != null && item.getEpisodeId() != null) { + overrides.put(item.getEpisodeId(), item); + } + } + } + if (overrides.isEmpty()) { + return 0; + } + List episodes = dramaEpisodeMapper.selectList(Wrappers.lambdaQuery() + .eq(DramaEpisode::getDramaId, dramaId) + .orderByAsc(DramaEpisode::getEpNo)); + int changed = 0; + for (DramaEpisode episode : episodes) { + PriceSaveReqVO.EpisodePrice override = overrides.get(episode.getId()); + if (override == null) { + continue; + } + int isFree = episode.getIsFree() == null ? 0 : episode.getIsFree(); + int coin = episode.getUnlockCoin() == null ? 0 : episode.getUnlockCoin(); + Integer previewSeconds = null; + if (override.getIsFree() != null) { + isFree = override.getIsFree() == 1 ? 1 : 0; + coin = EpisodePriceSync.resolveUnlockCoin(isFree, unlockCoin); + } + if (isFree == 0 && override.getUnlockCoin() != null) { + coin = Math.max(0, override.getUnlockCoin()); + } + if (override.getPreviewSeconds() != null) { + previewSeconds = Math.max(0, override.getPreviewSeconds()); + } + boolean priceChanged = !Objects.equals(episode.getIsFree(), isFree) + || !Objects.equals(episode.getUnlockCoin(), coin); + boolean previewChanged = previewSeconds != null + && !Objects.equals(episode.getPreviewSeconds(), previewSeconds); + if (priceChanged || previewChanged) { + episode.setIsFree(isFree); + episode.setUnlockCoin(coin); + if (previewSeconds != null) { + episode.setPreviewSeconds(previewSeconds); + } + dramaEpisodeMapper.updateById(episode); + changed++; + } + } + return changed; + } } diff --git a/fruitshorts-module-content/src/test/java/com/fruitshorts/content/service/DramaServiceTest.java b/fruitshorts-module-content/src/test/java/com/fruitshorts/content/service/DramaServiceTest.java index 22e6fac..4b4baca 100644 --- a/fruitshorts-module-content/src/test/java/com/fruitshorts/content/service/DramaServiceTest.java +++ b/fruitshorts-module-content/src/test/java/com/fruitshorts/content/service/DramaServiceTest.java @@ -58,8 +58,9 @@ class DramaServiceTest { dramaEpisodeMapper = mock(DramaEpisodeMapper.class); DramaReviewRecordMapper reviewRecordMapper = mock(DramaReviewRecordMapper.class); DramaCategoryService categoryService = mock(DramaCategoryService.class); + EpisodePriceSync episodePriceSync = new EpisodePriceSync(dramaEpisodeMapper); dramaService = new DramaService( - dramaMapper, dramaEpisodeMapper, reviewRecordMapper, categoryService); + dramaMapper, dramaEpisodeMapper, reviewRecordMapper, categoryService, episodePriceSync); episodes = new ArrayList<>(); given(dramaEpisodeMapper.selectList(any())).willAnswer(invocation -> List.copyOf(episodes)); given(dramaEpisodeMapper.updateById(any(DramaEpisode.class))).willAnswer(invocation -> {