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