feat: 08 创作者与内容审核落地(入驻审核 / 内容提交审核 / 定时发布 / 分集定价)

为什么:
1) 内容是创作者供的,必须有一条可管控的供给链路:创作者入驻要审、内容提交要审、
   审核通过才能真正上架 —— 否则平台无法对内容和主体负责。
2) 审核不能绕过状态机。内容提交审核通过后,是顺着短剧状态机
   SUBMIT → APPROVE → PUBLISH 推上去,而不是直接写 status 字段,
   这样审核记录与操作日志都不会缺环。
3) 定时发布(publish_at 到点自动上线)同样走状态机,并且做成幂等任务:
   重复执行不会把已上线的剧再推一次。
4) 定价落在 is_free / unlock_coin 上,与权益表解耦:改价立即影响播放与下载判定,
   但已购用户的权益不受影响,不会因为改价把用户买过的东西收回去。
5) 补齐 PM 草稿未做的部分:5 个权限点 + 3 条后台菜单(含前端组件路径)+ 超管授权(幂等),
   以及后台定价页需要的价格读取接口 GET /admin/prices。
This commit is contained in:
Vicky committed 2026-10-15 12:51:40 +08:00
1 parent 7e730fbbca
commit 1fb8427f28
25 files changed
+935 -3

No files matched your search

@@ -0,0 +1,23 @@
package com.fruitshorts.content.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/** 定时发布:把已到 publish_at 的待发布短剧推上线。 */
@Slf4j
@Component
@RequiredArgsConstructor
public class DramaPublishJob {
private final DramaService dramaService;
@Scheduled(cron = "${fruitshorts.content.publish-cron:0 * * * * *}")
public void run() {
int published = dramaService.publishDue();
if (published > 0) {
log.info("定时发布:本轮上线 {} 部短剧", published);
}
}
}
@@ -143,6 +143,24 @@ public class DramaService {
}
/**
* 定时发布:把已到 publish_at 的待发布短剧推上线。
* 状态机不允许把 approved 直接写库,所以逐条走 PUBLISH 动作,顺带留下审核记录。
*/
@Transactional(rollbackFor = Exception.class)
public int publishDue() {
List<Drama> due = dramaMapper.selectList(Wrappers.<Drama>lambdaQuery()
.eq(Drama::getStatus, ContentStatus.APPROVED.getCode())
.isNotNull(Drama::getPublishAt)
.le(Drama::getPublishAt, LocalDateTime.now()));
int published = 0;
for (Drama drama : due) {
changeStatus(drama.getId(), "PUBLISH", "定时发布到点", 0L, "system");
published++;
}
return published;
}
public List<DramaReviewRecord> listReviewRecords(Long dramaId) {
return dramaReviewRecordMapper.selectList(Wrappers.<DramaReviewRecord>lambdaQuery()
.eq(DramaReviewRecord::getBizType, "DRAMA")
@@ -0,0 +1,127 @@
package com.fruitshorts.content.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
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.vo.PriceRespVO;
import com.fruitshorts.content.vo.PriceSaveReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 分集价格与免费集配置。
*
* <p>价格直接落在 {@code drama_episode.is_free / unlock_coin}(未单独指定时回落到 {@code drama.unlock_coin}),
* 播放与下载的付费判定读的就是这两个字段,因此改价**立即生效**;已购用户的权益在 {@code entitlement},
* 与价格解耦,改价不会回收或重算已购权益。</p>
*/
@Service
@RequiredArgsConstructor
public class PriceService {
private final DramaService dramaService;
private final DramaMapper dramaMapper;
private final DramaEpisodeMapper dramaEpisodeMapper;
/** 读取当前价格配置:后台定价页进入时先看现状再改。 */
public PriceRespVO detail(Long dramaId) {
Drama drama = dramaService.getExisting(dramaId);
List<DramaEpisode> episodes = dramaEpisodeMapper.selectList(Wrappers.<DramaEpisode>lambdaQuery()
.eq(DramaEpisode::getDramaId, drama.getId())
.orderByAsc(DramaEpisode::getEpNo));
PriceRespVO vo = new PriceRespVO();
vo.setDramaId(drama.getId());
vo.setFreeEpisodes(drama.getFreeEpisodes());
vo.setUnlockCoin(drama.getUnlockCoin());
vo.setEpisodeCount(episodes.size());
vo.setChangedCount(0);
vo.setEpisodes(episodes.stream().map(episode -> {
PriceRespVO.DramaEpisodePriceVO item = new PriceRespVO.DramaEpisodePriceVO();
item.setEpisodeId(episode.getId());
item.setNumber(episode.getEpNo());
item.setTitle(episode.getTitle());
item.setFree(episode.getIsFree() != null && episode.getIsFree() == 1);
item.setUnlockCoin(episode.getUnlockCoin());
return item;
}).toList());
return vo;
}
@Transactional(rollbackFor = Exception.class)
public PriceRespVO save(PriceSaveReqVO req) {
Drama drama = dramaService.getExisting(req.getDramaId());
int freeEpisodes = req.getFreeEpisodes() != null
? Math.max(0, req.getFreeEpisodes())
: (drama.getFreeEpisodes() == null ? 0 : drama.getFreeEpisodes());
int unlockCoin = req.getUnlockCoin() != null
? Math.max(0, req.getUnlockCoin())
: (drama.getUnlockCoin() == null ? 0 : drama.getUnlockCoin());
drama.setFreeEpisodes(freeEpisodes);
drama.setUnlockCoin(unlockCoin);
dramaMapper.updateById(drama);
List<DramaEpisode> episodes = dramaEpisodeMapper.selectList(Wrappers.<DramaEpisode>lambdaQuery()
.eq(DramaEpisode::getDramaId, drama.getId())
.orderByAsc(DramaEpisode::getEpNo));
Map<Long, PriceSaveReqVO.EpisodePrice> 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());
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 (!Objects.equals(episode.getIsFree(), isFree) || !Objects.equals(episode.getUnlockCoin(), coin)) {
episode.setIsFree(isFree);
episode.setUnlockCoin(coin);
dramaEpisodeMapper.updateById(episode);
changed++;
}
}
PriceRespVO vo = new PriceRespVO();
vo.setDramaId(drama.getId());
vo.setFreeEpisodes(freeEpisodes);
vo.setUnlockCoin(unlockCoin);
vo.setEpisodeCount(episodes.size());
vo.setChangedCount(changed);
vo.setEpisodes(episodes.stream().map(episode -> {
PriceRespVO.DramaEpisodePriceVO item = new PriceRespVO.DramaEpisodePriceVO();
item.setEpisodeId(episode.getId());
item.setNumber(episode.getEpNo());
item.setTitle(episode.getTitle());
item.setFree(episode.getIsFree() != null && episode.getIsFree() == 1);
item.setUnlockCoin(episode.getUnlockCoin());
return item;
}).toList());
return vo;
}
}
@@ -0,0 +1,29 @@
package com.fruitshorts.content.vo;
import lombok.Data;
import java.util.List;
/** 价格配置结果:便于运营确认改动范围。 */
@Data
public class PriceRespVO {
private Long dramaId;
private Integer freeEpisodes;
private Integer unlockCoin;
/** 该剧剧集总数 */
private Integer episodeCount;
/** 本次实际发生变化的剧集数 */
private Integer changedCount;
/** 剧集明细:后台定价页要按集展示当前价格与免费状态 */
private List<DramaEpisodePriceVO> episodes;
@Data
public static class DramaEpisodePriceVO {
private Long episodeId;
private Integer number;
private String title;
private Boolean free;
private Integer unlockCoin;
}
}
@@ -0,0 +1,26 @@
package com.fruitshorts.content.vo;
import lombok.Data;
import java.util.List;
/** 分集价格与免费集配置。 */
@Data
public class PriceSaveReqVO {
private Long dramaId;
/** 整剧免费集数:前 N 集免费,其余按 unlockCoin 收费 */
private Integer freeEpisodes;
/** 整剧默认解锁金币(逐集未单独指定时生效) */
private Integer unlockCoin;
/** 逐集覆盖,可空;列表里的集优先于整剧默认值 */
private List<EpisodePrice> episodes;
@Data
public static class EpisodePrice {
private Long episodeId;
/** 1 免费 / 0 收费 */
private Integer isFree;
private Integer unlockCoin;
}
}