diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/playback/service/WatchProgressServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/playback/service/WatchProgressServiceTest.java index 513f712..d72060f 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/playback/service/WatchProgressServiceTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/playback/service/WatchProgressServiceTest.java @@ -15,6 +15,7 @@ import com.fruitshorts.playback.dal.entity.WatchProgress; import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper; import com.fruitshorts.playback.dal.mapper.WatchProgressMapper; import com.fruitshorts.playback.vo.ProgressRespVO; +import com.fruitshorts.playback.vo.ProgressSaveReqVO; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; @@ -28,6 +29,7 @@ import static org.assertj.core.api.Assertions.assertThat; 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; @@ -108,6 +110,30 @@ class WatchProgressServiceTest { return progress; } + @Test + void should_revive_deleted_progress_when_same_drama_is_reported_again() { + DramaEpisode episode = episode(88L, 1); + episode.setDramaId(5L); + given(episodeService.getPublished(88L)).willReturn(episode); + given(dramaService.getPublished(5L)).willReturn(drama(5L, "剧名", 10)); + WatchProgress hidden = progress(5L, 88L); + hidden.setId(9L); + hidden.setUserId(7L); + hidden.setDeleted(1); + given(progressMapper.selectAny(7L, 5L)).willReturn(hidden); + given(progressMapper.selectById(9L)).willReturn(hidden); + ProgressSaveReqVO req = new ProgressSaveReqVO(); + req.setEpisodeId(88L); + req.setPositionMs(1_000L); + req.setDurationMs(2_000L); + + progressService.save(7L, req); + + verify(progressMapper, never()).insert(any(WatchProgress.class)); + verify(progressMapper).updateAny(hidden); + assertThat(hidden.getDeleted()).isZero(); + } + private Drama drama(Long id, String title, int totalEpisodes) { Drama drama = new Drama(); drama.setId(id); diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/WatchProgressMapper.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/WatchProgressMapper.java index a0368f8..3a7c23d 100644 --- a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/WatchProgressMapper.java +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/WatchProgressMapper.java @@ -4,6 +4,32 @@ package com.fruitshorts.playback.dal.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.fruitshorts.playback.dal.entity.WatchProgress; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; public interface WatchProgressMapper extends BaseMapper { + + /** 含逻辑删除行。唯一索引不区分 deleted,再看同一部剧必须能找回旧行。 */ + @Select(""" + SELECT * FROM watch_progress + WHERE user_id = #{userId} AND drama_id = #{dramaId} + LIMIT 1 + """) + WatchProgress selectAny(@Param("userId") Long userId, @Param("dramaId") Long dramaId); + + /** 恢复并更新。手写 SQL 不带 deleted=0 条件,逻辑删除行也能写回。 */ + @Update(""" + UPDATE watch_progress SET + deleted = 0, + episode_id = #{episodeId}, + position_ms = #{positionMs}, + duration_ms = #{durationMs}, + finished = #{finished}, + play_count = #{playCount}, + device_id = #{deviceId}, + source = #{source} + WHERE id = #{id} + """) + int updateAny(WatchProgress progress); } diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/WatchProgressService.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/WatchProgressService.java index 343b56a..29a98a1 100644 --- a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/WatchProgressService.java +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/WatchProgressService.java @@ -17,6 +17,7 @@ import com.fruitshorts.playback.dal.mapper.WatchProgressMapper; import com.fruitshorts.playback.vo.ProgressRespVO; import com.fruitshorts.playback.vo.ProgressSaveReqVO; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -58,30 +59,23 @@ public class WatchProgressService { boolean finished = Boolean.TRUE.equals(req.getFinished()) || (duration > 0 && position + FINISH_THRESHOLD_MS >= duration); - WatchProgress existing = progressMapper.selectOne(Wrappers.lambdaQuery() - .eq(WatchProgress::getUserId, userId) - .eq(WatchProgress::getDramaId, drama.getId()) - .last("limit 1")); - if (existing == null) { - WatchProgress progress = new WatchProgress(); - progress.setUserId(userId); - progress.setDramaId(drama.getId()); - progress.setEpisodeId(episode.getId()); - progress.setPositionMs(position); - progress.setDurationMs(duration); - progress.setFinished(finished ? 1 : 0); - progress.setPlayCount(1); - progress.setDeviceId(req.getDeviceId() == null ? "" : req.getDeviceId()); - progress.setSource(req.getSource() == null ? "" : req.getSource()); - progressMapper.insert(progress); + LoadedProgress loaded = loadForSave(userId, drama.getId(), episode, position, duration, + finished, req); + WatchProgress existing = loaded.progress(); + if (loaded.created()) { appendHistory(userId, drama.getId(), episode.getId(), position, duration, - progress.getSource(), progress.getDeviceId()); - // updated_at 由触发器维护,回读一次,保证响应与后续 recent 列表口径一致 - return toResp(progressMapper.selectById(progress.getId())); + existing.getSource(), existing.getDeviceId()); + return toResp(progressMapper.selectById(existing.getId())); } + boolean hidden = existing.getDeleted() != null && existing.getDeleted() != 0; boolean sameEpisode = existing.getEpisodeId() != null && existing.getEpisodeId().equals(episode.getId()); + if (hidden && sameEpisode) { + existing.setPlayCount((existing.getPlayCount() == null ? 0 : existing.getPlayCount()) + 1); + appendHistory(userId, drama.getId(), episode.getId(), position, duration, + req.getSource(), existing.getDeviceId()); + } if (!sameEpisode) { // 换集:直接采纳新集与新的播放位置 existing.setEpisodeId(episode.getId()); @@ -103,10 +97,52 @@ public class WatchProgressService { } existing.setDeviceId(req.getDeviceId() == null ? "" : req.getDeviceId()); existing.setSource(req.getSource() == null ? "" : req.getSource()); - progressMapper.updateById(existing); + if (hidden) { + existing.setDeleted(0); + progressMapper.updateAny(existing); + } else { + progressMapper.updateById(existing); + } return toResp(progressMapper.selectById(existing.getId())); } + private LoadedProgress loadForSave(Long userId, Long dramaId, DramaEpisode episode, long position, + long duration, boolean finished, ProgressSaveReqVO req) { + WatchProgress existing = progressMapper.selectAny(userId, dramaId); + if (existing != null) { + return new LoadedProgress(existing, false); + } + WatchProgress created = newProgress(userId, dramaId, episode, position, duration, finished, req); + try { + progressMapper.insert(created); + return new LoadedProgress(created, true); + } catch (DuplicateKeyException ex) { + WatchProgress raced = progressMapper.selectAny(userId, dramaId); + if (raced == null) { + throw ex; + } + return new LoadedProgress(raced, false); + } + } + + private WatchProgress newProgress(Long userId, Long dramaId, DramaEpisode episode, long position, + long duration, boolean finished, ProgressSaveReqVO req) { + WatchProgress progress = new WatchProgress(); + progress.setUserId(userId); + progress.setDramaId(dramaId); + progress.setEpisodeId(episode.getId()); + progress.setPositionMs(position); + progress.setDurationMs(duration); + progress.setFinished(finished ? 1 : 0); + progress.setPlayCount(1); + progress.setDeviceId(req.getDeviceId() == null ? "" : req.getDeviceId()); + progress.setSource(req.getSource() == null ? "" : req.getSource()); + return progress; + } + + private record LoadedProgress(WatchProgress progress, boolean created) { + } + public ProgressRespVO ofDrama(Long userId, Long dramaId) { WatchProgress progress = progressMapper.selectOne(Wrappers.lambdaQuery() .eq(WatchProgress::getUserId, userId) @@ -185,6 +221,7 @@ public class WatchProgressService { vo.setDramaTitle(drama.getTitle()); vo.setDramaCover(drama.getCoverUrl()); vo.setTotalEpisodes(drama.getTotalEpisodes()); + vo.setViewCount(drama.getViewCount()); } DramaEpisode episode = episodeById.get(progress.getEpisodeId()); if (episode != null) { diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/ProgressRespVO.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/ProgressRespVO.java index efb7da5..4dd00ea 100644 --- a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/ProgressRespVO.java +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/ProgressRespVO.java @@ -20,6 +20,8 @@ public class ProgressRespVO { private Integer episodeNumber; /** 短剧总集数;短剧不存在时为空。 */ private Integer totalEpisodes; + /** 短剧热度,取 drama.view_count。不是用户自己的播放次数。 */ + private Long viewCount; private Long positionMs; private Long durationMs; private Boolean finished;