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; + } +}