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
new file mode 100644
index 0000000..4b4baca
--- /dev/null
+++ b/fruitshorts-module-content/src/test/java/com/fruitshorts/content/service/DramaServiceTest.java
@@ -0,0 +1,190 @@
+// 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);
+ EpisodePriceSync episodePriceSync = new EpisodePriceSync(dramaEpisodeMapper);
+ dramaService = new DramaService(
+ 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 -> {
+ 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;
+ }
+}