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

+15 -2
View File
@@ -276,7 +276,20 @@ POST /app/auth/register
| POST | `/admin/wallets/coins` | `wallet:update` | 人工补币/扣回,必须带幂等键,先校验用户存在 |
| POST | `/admin/reconcile/run` · GET `/admin/reconcile/page` | `reconcile:run` / `order:list` | 手动对账与历史(默认每天 04:20 自动跑前一天) |
### 2.7 创作者审核、数据看板(下一批)
### 2.7 创作者与内容审核(已实现)
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/creators/page` `/admin/creators/{id}` `/{id}/documents` | `creator:list` | 创作者检索、详情、资质文件 |
| PUT | `/admin/creators/{id}/audit` | `creator:audit` | 入驻审核:`APPROVE` / `REJECT`(必填原因+原因码)/ `FREEZE` |
| GET | `/admin/submissions/page` `/admin/submissions/{id}` | `submission:list` | 内容提交检索与详情 |
| PUT | `/admin/submissions/{id}/audit` | `submission:audit` | 审核通过后按 `type` 落到短剧状态机(CREATE/UPDATE 留档、PUBLISH 上线、OFFLINE 下线) |
| GET · PUT | `/admin/prices` | `price:update` | 分集价格:整剧 `freeEpisodes`/`unlockCoin` + 逐集覆盖;改价立即影响播放与下载判定 |
**定时发布**:`DramaPublishJob` 每分钟扫一次,把 `publish_at` 到点的 `approved` 短剧推上线,
走的是短剧状态机(不直接改状态字段)。上下架也可通过内容提交的 `OFFLINE` 审核触发。
### 2.8 数据看板(下一批)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
@@ -309,6 +322,6 @@ POST /app/auth/register
| 05 记录、收藏与下载 | 已建 | 已冻结 | **已实现**:进度、观看流水、收藏、离线下载授权与任务 |
| 06 评论、点赞与分享 | 已建 | 已冻结 | **已实现**:评论(游标翻页/审核/删除)、点赞、举报、敏感词;分享埋点在下一批 |
| 07 创作者中心 | 已建 | 已冻结 | 下一批(转码任务与产物记账已实现) |
| 08 平台运营后台 | 已建 | 已冻结 | 用户管理、内容治理、**订单与权益已实现**;创作者审核与数据看板下一批 |
| 08 平台运营后台 | 已建 | 已冻结 | 用户管理、内容治理、订单与权益、**创作者与内容审核已实现**;数据看板下一批 |
| 09 分集收费与支付 | 已建 | 已冻结 | 下一批(权益判定端口已预留,接上即生效) |
| 10 质量、数据与上线 | 已建 | 已冻结 | 压测与埋点 下一批 |
+4
View File
@@ -43,6 +43,10 @@
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-commerce</artifactId>
</dependency>
<dependency>
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-creator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
@@ -0,0 +1,56 @@
package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.creator.dal.entity.Creator;
import com.fruitshorts.creator.dal.entity.CreatorDocument;
import com.fruitshorts.creator.service.CreatorService;
import com.fruitshorts.creator.vo.CreatorAuditReqVO;
import com.fruitshorts.creator.vo.CreatorQueryReqVO;
import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.framework.security.SecurityUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 后台创作者:检索、详情、资质、入驻审核。 */
@RestController
@RequestMapping("/admin/creators")
@RequiredArgsConstructor
public class CreatorAdminController {
private final CreatorService creatorService;
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('creator:list')")
public Result<PageResult<Creator>> page(CreatorQueryReqVO req) {
return Result.ok(creatorService.page(req));
}
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('creator:list')")
public Result<Creator> detail(@PathVariable Long id) {
return Result.ok(creatorService.getExisting(id));
}
/** 资质文件:审核前要看证件原件。 */
@GetMapping("/{id}/documents")
@PreAuthorize("@ss.hasPermission('creator:list')")
public Result<List<CreatorDocument>> documents(@PathVariable Long id) {
return Result.ok(creatorService.documents(id));
}
@PutMapping("/{id}/audit")
@PreAuthorize("@ss.hasPermission('creator:audit')")
@OperationLog(module = "创作者管理", action = "入驻审核")
public Result<Creator> audit(@PathVariable Long id, @RequestBody CreatorAuditReqVO req) {
return Result.ok(creatorService.audit(id, req, SecurityUtils.getUserId()));
}
}
@@ -0,0 +1,38 @@
package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.content.service.PriceService;
import com.fruitshorts.content.vo.PriceRespVO;
import com.fruitshorts.content.vo.PriceSaveReqVO;
import com.fruitshorts.framework.log.OperationLog;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 后台分集价格与免费集配置。 */
@RestController
@RequestMapping("/admin/prices")
@RequiredArgsConstructor
public class PriceAdminController {
private final PriceService priceService;
/** 当前价格配置:整剧默认 + 分集明细。 */
@GetMapping
@PreAuthorize("@ss.hasPermission('price:update')")
public Result<PriceRespVO> detail(@RequestParam Long dramaId) {
return Result.ok(priceService.detail(dramaId));
}
@PutMapping
@PreAuthorize("@ss.hasPermission('price:update')")
@OperationLog(module = "定价管理", action = "配置分集价格")
public Result<PriceRespVO> save(@RequestBody PriceSaveReqVO req) {
return Result.ok(priceService.save(req));
}
}
@@ -0,0 +1,46 @@
package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.creator.dal.entity.ContentSubmission;
import com.fruitshorts.creator.service.ContentSubmissionService;
import com.fruitshorts.creator.vo.SubmissionAuditReqVO;
import com.fruitshorts.creator.vo.SubmissionQueryReqVO;
import com.fruitshorts.framework.log.OperationLog;
import com.fruitshorts.framework.security.SecurityUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 后台内容提交审核:检索、详情、审核(通过 / 驳回 / 撤回)。 */
@RestController
@RequestMapping("/admin/submissions")
@RequiredArgsConstructor
public class SubmissionAdminController {
private final ContentSubmissionService submissionService;
@GetMapping("/page")
@PreAuthorize("@ss.hasPermission('submission:list')")
public Result<PageResult<ContentSubmission>> page(SubmissionQueryReqVO req) {
return Result.ok(submissionService.page(req));
}
@GetMapping("/{id}")
@PreAuthorize("@ss.hasPermission('submission:list')")
public Result<ContentSubmission> detail(@PathVariable Long id) {
return Result.ok(submissionService.getExisting(id));
}
@PutMapping("/{id}/audit")
@PreAuthorize("@ss.hasPermission('submission:audit')")
@OperationLog(module = "内容审核", action = "提交审核")
public Result<ContentSubmission> audit(@PathVariable Long id, @RequestBody SubmissionAuditReqVO req) {
return Result.ok(submissionService.audit(id, req, SecurityUtils.getUserId(), SecurityUtils.getUsername()));
}
}
@@ -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;
}
}
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>fruitshorts-module-creator</artifactId>
<name>fruitshorts-module-creator</name>
<description>创作者域:入驻资质审核、内容提交审核流转</description>
<dependencies>
<dependency>
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-framework</artifactId>
</dependency>
<dependency>
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-content</artifactId>
</dependency>
<dependency>
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-user</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,39 @@
package com.fruitshorts.creator.dal.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** 创作者内容提交与审核流转,对应 content_submission 表。 */
@Data
@TableName("content_submission")
public class ContentSubmission {
@TableId(type = IdType.AUTO)
private Long id;
private Long creatorId;
private Long dramaId;
/** CREATE / UPDATE / PUBLISH / OFFLINE */
private String type;
/** DRAFT / SUBMITTED / REVIEWING / APPROVED / REJECTED / WITHDRAWN */
private String status;
private Integer version;
private LocalDateTime submitAt;
private Long reviewerId;
private LocalDateTime reviewAt;
private String reviewNote;
private String rejectCode;
private LocalDateTime createdAt;
@TableField(updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,52 @@
package com.fruitshorts.creator.dal.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 创作者主体与资质,对应 creator 表。 */
@Data
@TableName("creator")
public class Creator {
@TableId(type = IdType.AUTO)
private Long id;
/** 关联 C 端账号 app_user.id */
private Long userId;
private String code;
private String name;
/** INDIVIDUAL / COMPANY */
private String type;
private String contactName;
private String contactMobile;
private String contactEmail;
private String countryCode;
/** pending / approved / rejected / frozen */
private String status;
private String auditNote;
private Long auditBy;
private LocalDateTime auditAt;
private String settlementCurrency;
/** 预留:加密存储(第二阶段结算) */
private String bankAccount;
/** 预留:税号 */
private String taxNo;
/** 预留:KYC 状态 */
private String kycStatus;
/** 预留:分成比例(第二阶段) */
private BigDecimal shareRate;
private LocalDateTime createdAt;
@TableField(updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,36 @@
package com.fruitshorts.creator.dal.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** 创作者资质文件,对应 creator_document 表。 */
@Data
@TableName("creator_document")
public class CreatorDocument {
@TableId(type = IdType.AUTO)
private Long id;
private Long creatorId;
/** ID_CARD / BUSINESS_LICENSE / CONTRACT */
private String type;
private String fileUrl;
/** pending / approved / rejected */
private String status;
private String auditNote;
/** 证件有效期 */
private LocalDateTime expireAt;
private LocalDateTime createdAt;
@TableField(updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,7 @@
package com.fruitshorts.creator.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.creator.dal.entity.ContentSubmission;
public interface ContentSubmissionMapper extends BaseMapper<ContentSubmission> {
}
@@ -0,0 +1,7 @@
package com.fruitshorts.creator.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.creator.dal.entity.CreatorDocument;
public interface CreatorDocumentMapper extends BaseMapper<CreatorDocument> {
}
@@ -0,0 +1,7 @@
package com.fruitshorts.creator.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.creator.dal.entity.Creator;
public interface CreatorMapper extends BaseMapper<Creator> {
}
@@ -0,0 +1,144 @@
package com.fruitshorts.creator.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.enums.ContentStatus;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.creator.dal.entity.ContentSubmission;
import com.fruitshorts.creator.dal.mapper.ContentSubmissionMapper;
import com.fruitshorts.creator.vo.SubmissionAuditReqVO;
import com.fruitshorts.creator.vo.SubmissionQueryReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.Set;
/** 创作者内容提交:后台检索、审核,通过后按 type 落到短剧状态机。 */
@Service
@RequiredArgsConstructor
public class ContentSubmissionService {
public static final String STATUS_DRAFT = "DRAFT";
public static final String STATUS_SUBMITTED = "SUBMITTED";
public static final String STATUS_REVIEWING = "REVIEWING";
public static final String STATUS_APPROVED = "APPROVED";
public static final String STATUS_REJECTED = "REJECTED";
public static final String STATUS_WITHDRAWN = "WITHDRAWN";
private static final Set<String> REVIEWABLE = Set.of(STATUS_SUBMITTED, STATUS_REVIEWING);
private final ContentSubmissionMapper submissionMapper;
private final DramaService dramaService;
public PageResult<ContentSubmission> page(SubmissionQueryReqVO req) {
Page<ContentSubmission> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
IPage<ContentSubmission> result = submissionMapper.selectPage(page,
Wrappers.<ContentSubmission>lambdaQuery()
.eq(req.getCreatorId() != null, ContentSubmission::getCreatorId, req.getCreatorId())
.eq(req.getDramaId() != null, ContentSubmission::getDramaId, req.getDramaId())
.eq(StringUtils.hasText(req.getStatus()), ContentSubmission::getStatus, req.getStatus())
.eq(StringUtils.hasText(req.getType()), ContentSubmission::getType, req.getType())
.orderByDesc(ContentSubmission::getId));
return new PageResult<>(result.getTotal(), result.getRecords());
}
public ContentSubmission getExisting(Long id) {
ContentSubmission submission = id == null ? null : submissionMapper.selectById(id);
if (submission == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "内容提交记录不存在");
}
return submission;
}
/** 审核:通过 / 驳回 / 撤回。 */
@Transactional(rollbackFor = Exception.class)
public ContentSubmission audit(Long id, SubmissionAuditReqVO req, Long operatorId, String operatorName) {
ContentSubmission submission = getExisting(id);
String current = submission.getStatus();
String action = req.getAction() == null ? "" : req.getAction();
switch (action) {
case "APPROVE" -> {
requireState(current, "通过", REVIEWABLE);
submission.setStatus(STATUS_APPROVED);
applyToDrama(submission, operatorId, operatorName);
}
case "REJECT" -> {
requireState(current, "驳回", REVIEWABLE);
if (!StringUtils.hasText(req.getReason())) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "驳回必须填写原因");
}
submission.setStatus(STATUS_REJECTED);
}
case "WITHDRAW" -> {
requireState(current, "撤回", Set.of(STATUS_DRAFT, STATUS_SUBMITTED));
submission.setStatus(STATUS_WITHDRAWN);
}
default -> throw new ServiceException(ErrorCode.BAD_REQUEST, "未知的审核动作: " + action);
}
submission.setReviewerId(operatorId == null ? 0L : operatorId);
submission.setReviewAt(LocalDateTime.now());
submission.setReviewNote(req.getReason() == null ? "" : req.getReason());
submission.setRejectCode(req.getRejectCode() == null ? "" : req.getRejectCode());
submissionMapper.updateById(submission);
return submission;
}
/**
* 审核通过后落到短剧状态机:PUBLISH 上线、OFFLINE 下线,CREATE / UPDATE 只留档不改状态。
* 上线时若短剧还停在草稿,按 SUBMIT → APPROVE → PUBLISH 顺推,保证状态机不被绕过。
*/
private void applyToDrama(ContentSubmission submission, Long operatorId, String operatorName) {
String type = submission.getType() == null ? "" : submission.getType();
Long dramaId = submission.getDramaId();
String reason = "创作者内容审核通过";
if ("PUBLISH".equals(type)) {
Drama drama = dramaService.getExisting(dramaId);
String status = drama.getStatus();
if (ContentStatus.PUBLISHED.getCode().equals(status)) {
return;
}
if (ContentStatus.DRAFT.getCode().equals(status)) {
dramaService.changeStatus(dramaId, "SUBMIT", reason, operatorId, operatorName);
}
Drama afterSubmit = dramaService.getExisting(dramaId);
if (ContentStatus.REVIEWING.getCode().equals(afterSubmit.getStatus())) {
dramaService.changeStatus(dramaId, "APPROVE", reason, operatorId, operatorName);
}
dramaService.changeStatus(dramaId, "PUBLISH", reason, operatorId, operatorName);
} else if ("OFFLINE".equals(type)) {
Drama drama = dramaService.getExisting(dramaId);
if (ContentStatus.PUBLISHED.getCode().equals(drama.getStatus())) {
dramaService.changeStatus(dramaId, "OFFLINE", reason, operatorId, operatorName);
}
}
}
private void requireState(String current, String action, Set<String> allowed) {
if (!allowed.contains(current)) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"当前状态(" + statusText(current) + ")不允许执行「" + action + "」");
}
}
public static String statusText(String code) {
return switch (code == null ? "" : code) {
case STATUS_DRAFT -> "草稿";
case STATUS_SUBMITTED -> "已提交";
case STATUS_REVIEWING -> "审核中";
case STATUS_APPROVED -> "已通过";
case STATUS_REJECTED -> "已驳回";
case STATUS_WITHDRAWN -> "已撤回";
default -> code;
};
}
}
@@ -0,0 +1,126 @@
package com.fruitshorts.creator.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.creator.dal.entity.Creator;
import com.fruitshorts.creator.dal.entity.CreatorDocument;
import com.fruitshorts.creator.dal.mapper.CreatorDocumentMapper;
import com.fruitshorts.creator.dal.mapper.CreatorMapper;
import com.fruitshorts.creator.vo.CreatorAuditReqVO;
import com.fruitshorts.creator.vo.CreatorQueryReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
/** 创作者主体与资质:后台检索、详情、入驻审核。 */
@Service
@RequiredArgsConstructor
public class CreatorService {
public static final String STATUS_PENDING = "pending";
public static final String STATUS_APPROVED = "approved";
public static final String STATUS_REJECTED = "rejected";
public static final String STATUS_FROZEN = "frozen";
private final CreatorMapper creatorMapper;
private final CreatorDocumentMapper creatorDocumentMapper;
public PageResult<Creator> page(CreatorQueryReqVO req) {
Page<Creator> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
IPage<Creator> result = creatorMapper.selectPage(page, Wrappers.<Creator>lambdaQuery()
.and(StringUtils.hasText(req.getKeyword()), w -> w
.like(Creator::getName, req.getKeyword())
.or().like(Creator::getCode, req.getKeyword())
.or().like(Creator::getContactName, req.getKeyword())
.or().like(Creator::getContactMobile, req.getKeyword()))
.eq(StringUtils.hasText(req.getStatus()), Creator::getStatus, req.getStatus())
.eq(StringUtils.hasText(req.getType()), Creator::getType, req.getType())
.eq(StringUtils.hasText(req.getCountryCode()), Creator::getCountryCode, req.getCountryCode())
.orderByDesc(Creator::getId));
return new PageResult<>(result.getTotal(), result.getRecords());
}
public Creator getExisting(Long id) {
Creator creator = id == null ? null : creatorMapper.selectById(id);
if (creator == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "创作者不存在");
}
return creator;
}
/** 资质文件列表:审核时要能看到证件原件。 */
public List<CreatorDocument> documents(Long creatorId) {
getExisting(creatorId);
return creatorDocumentMapper.selectList(Wrappers.<CreatorDocument>lambdaQuery()
.eq(CreatorDocument::getCreatorId, creatorId)
.orderByAsc(CreatorDocument::getType)
.orderByDesc(CreatorDocument::getId));
}
/** 入驻审核:通过 / 驳回 / 冻结。 */
@Transactional(rollbackFor = Exception.class)
public Creator audit(Long id, CreatorAuditReqVO req, Long operatorId) {
Creator creator = getExisting(id);
String current = creator.getStatus();
String action = req.getAction() == null ? "" : req.getAction();
String target = switch (action) {
case "APPROVE" -> {
requireState(current, "通过", Set.of(STATUS_PENDING, STATUS_REJECTED, STATUS_FROZEN));
yield STATUS_APPROVED;
}
case "REJECT" -> {
requireState(current, "驳回", Set.of(STATUS_PENDING));
if (!StringUtils.hasText(req.getReason())) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "驳回必须填写原因");
}
yield STATUS_REJECTED;
}
case "FREEZE" -> {
requireState(current, "冻结", Set.of(STATUS_APPROVED));
yield STATUS_FROZEN;
}
default -> throw new ServiceException(ErrorCode.BAD_REQUEST, "未知的审核动作: " + action);
};
creator.setStatus(target);
creator.setAuditNote(buildNote(req));
creator.setAuditBy(operatorId == null ? 0L : operatorId);
creator.setAuditAt(LocalDateTime.now());
creatorMapper.updateById(creator);
return creator;
}
/** 驳回原因码没有独立列,与说明合并存进 audit_note,保留可追溯性。 */
private String buildNote(CreatorAuditReqVO req) {
String reason = req.getReason() == null ? "" : req.getReason();
return StringUtils.hasText(req.getRejectCode())
? "[" + req.getRejectCode() + "] " + reason
: reason;
}
private void requireState(String current, String action, Set<String> allowed) {
if (!allowed.contains(current)) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"当前状态(" + statusText(current) + ")不允许执行「" + action + "」");
}
}
public static String statusText(String code) {
return switch (code == null ? "" : code) {
case STATUS_PENDING -> "待审核";
case STATUS_APPROVED -> "已通过";
case STATUS_REJECTED -> "已驳回";
case STATUS_FROZEN -> "已冻结";
default -> code;
};
}
}
@@ -0,0 +1,15 @@
package com.fruitshorts.creator.vo;
import lombok.Data;
/** 创作者入驻审核请求。 */
@Data
public class CreatorAuditReqVO {
/** APPROVE / REJECT / FREEZE */
private String action;
/** 驳回原因码,REJECT 时必填 */
private String rejectCode;
/** 审核说明 / 驳回原因 */
private String reason;
}
@@ -0,0 +1,19 @@
package com.fruitshorts.creator.vo;
import com.fruitshorts.common.pojo.PageParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
/** 后台创作者检索条件。 */
@Data
@EqualsAndHashCode(callSuper = true)
public class CreatorQueryReqVO extends PageParam {
/** 名称 / 编号 / 联系人 / 联系方式 关键字 */
private String keyword;
/** pending / approved / rejected / frozen */
private String status;
/** INDIVIDUAL / COMPANY */
private String type;
private String countryCode;
}
@@ -0,0 +1,15 @@
package com.fruitshorts.creator.vo;
import lombok.Data;
/** 内容提交审核请求。 */
@Data
public class SubmissionAuditReqVO {
/** APPROVE / REJECT / WITHDRAW */
private String action;
/** 驳回原因码,REJECT 时必填 */
private String rejectCode;
/** 审核说明 / 驳回原因 */
private String reason;
}
@@ -0,0 +1,18 @@
package com.fruitshorts.creator.vo;
import com.fruitshorts.common.pojo.PageParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
/** 后台内容提交审核检索条件。 */
@Data
@EqualsAndHashCode(callSuper = true)
public class SubmissionQueryReqVO extends PageParam {
private Long creatorId;
private Long dramaId;
/** DRAFT / SUBMITTED / REVIEWING / APPROVED / REJECTED / WITHDRAWN */
private String status;
/** CREATE / UPDATE / PUBLISH / OFFLINE */
private String type;
}
+6
View File
@@ -28,6 +28,7 @@
<module>fruitshorts-module-playback</module>
<module>fruitshorts-module-interaction</module>
<module>fruitshorts-module-commerce</module>
<module>fruitshorts-module-creator</module>
<module>fruitshorts-admin-api</module>
<module>fruitshorts-app-api</module>
</modules>
@@ -90,6 +91,11 @@
<artifactId>fruitshorts-module-commerce</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-creator</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
+31 -1
View File
@@ -55,6 +55,14 @@ INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES
('reconcile:run', '手动对账', '订单与权益', 'BUTTON', 126)
ON CONFLICT (code) DO NOTHING;
INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES
('creator:list', '查看创作者', '创作者管理', 'BUTTON', 131),
('creator:audit', '创作者入驻审核','创作者管理', 'BUTTON', 132),
('submission:list', '查看内容提交', '创作者管理', 'BUTTON', 133),
('submission:audit', '内容提交审核', '创作者管理', 'BUTTON', 134),
('price:update', '配置分集价格', '内容管理', 'BUTTON', 135)
ON CONFLICT (code) DO NOTHING;
-- 超管默认拥有全部权限点
INSERT INTO sys_role_permission (role_id, permission_id)
SELECT r.id, p.id
@@ -65,7 +73,29 @@ WHERE r.code = 'super_admin'
'comment:list', 'comment:audit', 'report:list', 'report:handle',
'sensitive-word:list', 'sensitive-word:update',
'order:list', 'order:update', 'entitlement:list', 'entitlement:update',
'wallet:update', 'reconcile:run')
'wallet:update', 'reconcile:run',
'creator:list', 'creator:audit', 'submission:list', 'submission:audit',
'price:update')
ON CONFLICT DO NOTHING;
-- ---------- 菜单:创作者管理 ----------
-- component 指向 src/views/creator/ 下的页面,前端按这个字符串动态加载。
INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon,
permission_code, type, always_show, affix, sort) VALUES
(17, 0, '创作者管理', '/creator', 'CreatorCenter', '#', '/creator/creators', 'ep:user-filled', 'creator:list', 'CATALOG', 1, 0, 5),
(18, 17, '创作者入驻', 'creators', 'creators', 'views/creator/CreatorAuditView', '', '', 'creator:list', 'MENU', 0, 0, 1),
(19, 17, '内容审核', 'submissions', 'submissions', 'views/creator/SubmissionAuditView', '', '', 'submission:list', 'MENU', 0, 0, 2)
ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED.title,
route_path = EXCLUDED.route_path, route_name = EXCLUDED.route_name, component = EXCLUDED.component,
redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, permission_code = EXCLUDED.permission_code,
type = EXCLUDED.type, always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort;
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT r.id, m.id
FROM sys_role r
CROSS JOIN sys_menu m
WHERE r.code = 'super_admin'
AND m.id IN (17, 18, 19)
ON CONFLICT DO NOTHING;
-- 第一阶段新增的白名单数据(当前阶段都是免费内容,先把演示商品占位好)