diff --git a/docs/api-phase1.md b/docs/api-phase1.md index 8b051b0..ede21ed 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -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 质量、数据与上线 | 已建 | 已冻结 | 压测与埋点 下一批 | diff --git a/fruitshorts-admin-api/pom.xml b/fruitshorts-admin-api/pom.xml index e09c587..adbe771 100644 --- a/fruitshorts-admin-api/pom.xml +++ b/fruitshorts-admin-api/pom.xml @@ -43,6 +43,10 @@ com.fruitshorts fruitshorts-module-commerce + + com.fruitshorts + fruitshorts-module-creator + org.springframework.boot spring-boot-starter-test diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CreatorAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CreatorAdminController.java new file mode 100644 index 0000000..aa80fb1 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CreatorAdminController.java @@ -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> page(CreatorQueryReqVO req) { + return Result.ok(creatorService.page(req)); + } + + @GetMapping("/{id}") + @PreAuthorize("@ss.hasPermission('creator:list')") + public Result detail(@PathVariable Long id) { + return Result.ok(creatorService.getExisting(id)); + } + + /** 资质文件:审核前要看证件原件。 */ + @GetMapping("/{id}/documents") + @PreAuthorize("@ss.hasPermission('creator:list')") + public Result> documents(@PathVariable Long id) { + return Result.ok(creatorService.documents(id)); + } + + @PutMapping("/{id}/audit") + @PreAuthorize("@ss.hasPermission('creator:audit')") + @OperationLog(module = "创作者管理", action = "入驻审核") + public Result audit(@PathVariable Long id, @RequestBody CreatorAuditReqVO req) { + return Result.ok(creatorService.audit(id, req, SecurityUtils.getUserId())); + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/PriceAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/PriceAdminController.java new file mode 100644 index 0000000..cae7213 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/PriceAdminController.java @@ -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 detail(@RequestParam Long dramaId) { + return Result.ok(priceService.detail(dramaId)); + } + + @PutMapping + @PreAuthorize("@ss.hasPermission('price:update')") + @OperationLog(module = "定价管理", action = "配置分集价格") + public Result save(@RequestBody PriceSaveReqVO req) { + return Result.ok(priceService.save(req)); + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/SubmissionAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/SubmissionAdminController.java new file mode 100644 index 0000000..10a9922 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/SubmissionAdminController.java @@ -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> page(SubmissionQueryReqVO req) { + return Result.ok(submissionService.page(req)); + } + + @GetMapping("/{id}") + @PreAuthorize("@ss.hasPermission('submission:list')") + public Result detail(@PathVariable Long id) { + return Result.ok(submissionService.getExisting(id)); + } + + @PutMapping("/{id}/audit") + @PreAuthorize("@ss.hasPermission('submission:audit')") + @OperationLog(module = "内容审核", action = "提交审核") + public Result audit(@PathVariable Long id, @RequestBody SubmissionAuditReqVO req) { + return Result.ok(submissionService.audit(id, req, SecurityUtils.getUserId(), SecurityUtils.getUsername())); + } +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaPublishJob.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaPublishJob.java new file mode 100644 index 0000000..3bf70e3 --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaPublishJob.java @@ -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); + } + } +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java index ef8489c..5ec6f3b 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java @@ -143,6 +143,24 @@ public class DramaService { } + /** + * 定时发布:把已到 publish_at 的待发布短剧推上线。 + * 状态机不允许把 approved 直接写库,所以逐条走 PUBLISH 动作,顺带留下审核记录。 + */ + @Transactional(rollbackFor = Exception.class) + public int publishDue() { + List due = dramaMapper.selectList(Wrappers.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 listReviewRecords(Long dramaId) { return dramaReviewRecordMapper.selectList(Wrappers.lambdaQuery() .eq(DramaReviewRecord::getBizType, "DRAMA") 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 new file mode 100644 index 0000000..ec33d1f --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/PriceService.java @@ -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; + +/** + * 分集价格与免费集配置。 + * + *

价格直接落在 {@code drama_episode.is_free / unlock_coin}(未单独指定时回落到 {@code drama.unlock_coin}), + * 播放与下载的付费判定读的就是这两个字段,因此改价**立即生效**;已购用户的权益在 {@code entitlement}, + * 与价格解耦,改价不会回收或重算已购权益。

+ */ +@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 episodes = dramaEpisodeMapper.selectList(Wrappers.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 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()); + 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; + } +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/PriceRespVO.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/PriceRespVO.java new file mode 100644 index 0000000..758d2e5 --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/PriceRespVO.java @@ -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 episodes; + + @Data + public static class DramaEpisodePriceVO { + private Long episodeId; + private Integer number; + private String title; + private Boolean free; + private Integer unlockCoin; + } +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/PriceSaveReqVO.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/PriceSaveReqVO.java new file mode 100644 index 0000000..6e3fda9 --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/PriceSaveReqVO.java @@ -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 episodes; + + @Data + public static class EpisodePrice { + private Long episodeId; + /** 1 免费 / 0 收费 */ + private Integer isFree; + private Integer unlockCoin; + } +} diff --git a/fruitshorts-module-creator/pom.xml b/fruitshorts-module-creator/pom.xml new file mode 100644 index 0000000..482e19f --- /dev/null +++ b/fruitshorts-module-creator/pom.xml @@ -0,0 +1,31 @@ + + + 4.0.0 + + + com.fruitshorts + fruitshorts + 1.0.0-SNAPSHOT + + + fruitshorts-module-creator + fruitshorts-module-creator + 创作者域:入驻资质审核、内容提交审核流转 + + + + com.fruitshorts + fruitshorts-framework + + + com.fruitshorts + fruitshorts-module-content + + + com.fruitshorts + fruitshorts-module-user + + + diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/dal/entity/ContentSubmission.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/dal/entity/ContentSubmission.java new file mode 100644 index 0000000..1b20a57 --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/dal/entity/ContentSubmission.java @@ -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; +} diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/dal/entity/Creator.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/dal/entity/Creator.java new file mode 100644 index 0000000..9f35951 --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/dal/entity/Creator.java @@ -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; +} diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/dal/entity/CreatorDocument.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/dal/entity/CreatorDocument.java new file mode 100644 index 0000000..7c0a601 --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/dal/entity/CreatorDocument.java @@ -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; +} diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/dal/mapper/ContentSubmissionMapper.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/dal/mapper/ContentSubmissionMapper.java new file mode 100644 index 0000000..b3305b5 --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/dal/mapper/ContentSubmissionMapper.java @@ -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 { +} diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/dal/mapper/CreatorDocumentMapper.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/dal/mapper/CreatorDocumentMapper.java new file mode 100644 index 0000000..f6b3cbe --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/dal/mapper/CreatorDocumentMapper.java @@ -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 { +} diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/dal/mapper/CreatorMapper.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/dal/mapper/CreatorMapper.java new file mode 100644 index 0000000..7783a95 --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/dal/mapper/CreatorMapper.java @@ -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 { +} diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/ContentSubmissionService.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/ContentSubmissionService.java new file mode 100644 index 0000000..bfd5a95 --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/ContentSubmissionService.java @@ -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 REVIEWABLE = Set.of(STATUS_SUBMITTED, STATUS_REVIEWING); + + private final ContentSubmissionMapper submissionMapper; + private final DramaService dramaService; + + public PageResult page(SubmissionQueryReqVO req) { + Page page = new Page<>(req.getSafePageIndex(), req.getSafePageSize()); + IPage result = submissionMapper.selectPage(page, + Wrappers.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 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; + }; + } +} diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorService.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorService.java new file mode 100644 index 0000000..dd6fd19 --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorService.java @@ -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 page(CreatorQueryReqVO req) { + Page page = new Page<>(req.getSafePageIndex(), req.getSafePageSize()); + IPage result = creatorMapper.selectPage(page, Wrappers.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 documents(Long creatorId) { + getExisting(creatorId); + return creatorDocumentMapper.selectList(Wrappers.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 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; + }; + } +} diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorAuditReqVO.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorAuditReqVO.java new file mode 100644 index 0000000..e7083da --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorAuditReqVO.java @@ -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; +} diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorQueryReqVO.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorQueryReqVO.java new file mode 100644 index 0000000..c74ddc1 --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorQueryReqVO.java @@ -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; +} diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/SubmissionAuditReqVO.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/SubmissionAuditReqVO.java new file mode 100644 index 0000000..548f8fd --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/SubmissionAuditReqVO.java @@ -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; +} diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/SubmissionQueryReqVO.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/SubmissionQueryReqVO.java new file mode 100644 index 0000000..4041964 --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/SubmissionQueryReqVO.java @@ -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; +} diff --git a/pom.xml b/pom.xml index 1c92ae2..2cac673 100644 --- a/pom.xml +++ b/pom.xml @@ -28,6 +28,7 @@ fruitshorts-module-playback fruitshorts-module-interaction fruitshorts-module-commerce + fruitshorts-module-creator fruitshorts-admin-api fruitshorts-app-api @@ -90,6 +91,11 @@ fruitshorts-module-commerce ${project.version}
+ + com.fruitshorts + fruitshorts-module-creator + ${project.version} + com.baomidou diff --git a/sql/data-phase1.sql b/sql/data-phase1.sql index 0ada34b..19f64e9 100644 --- a/sql/data-phase1.sql +++ b/sql/data-phase1.sql @@ -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; -- 第一阶段新增的白名单数据(当前阶段都是免费内容,先把演示商品占位好)