feat: 07 创作者中心批 1(账号绑定 + 我的作品 / 审核进度 / 通知)

为什么:创作者要在 Web 端(做进后台管理)看到自己的作品与审核进度,但 creator 模块
此前只有「后台审核视角」的服务,创作者本人没有任何入口;更要紧的是,全项目没有任何
地方把「后台账号」和「创作者主体」关联起来 —— 缺这层映射,横向越权就只能靠每个接口
自觉加 where 过滤,迟早会漏。所以批 1 先把归属判定的地基打好,再给工作台接口。

- creator 增加 admin_user_id(幂等 ALTER + 部分唯一索引,0 = 未绑定,多条 0 不冲突)
- 新增角色 creator 与权限点 creator-center:view(创作者角色 + 超管各授权一条),菜单等页面做好再挂
- 新增 CreatorCenterService:requireCurrentCreator() 是归属判定的唯一入口,requireMyDrama() 供后续批次复用
- 新增 /admin/creator-center 四个接口(profile / dramas/page / dramas/{id} / notices),
  一律不接收 creatorId,查询强制 where creator_id = 当前创作者,访问他人作品统一 403
- 通知不新建表:由入驻审核结果 + 内容提交审核结果派生(ops_notice 无产出方,等有公告功能再接)
- 顺手接通 drama.trailer_url:DDL 与实体早就有,但保存 VO / 响应 VO 都没带,预告片存不进也读不出
- ContentSubmissionService 补 typeText + TYPE_* 常量(通知文案与批 2 的提交都要用)
This commit is contained in:
Vicky committed 2026-10-15 13:45:32 +08:00
1 parent 8b261b71de
commit ea875c3e2b
14 files changed
+454

No files matched your search

@@ -20,6 +20,8 @@ public class Creator {
private Long id;
/** 关联 C 端账号 app_user.id */
private Long userId;
/** 绑定的后台账号 sys_admin_user.id(创作者中心的登录身份),0 表示未绑定 */
private Long adminUserId;
private String code;
private String name;
/** INDIVIDUAL / COMPANY */
@@ -33,6 +33,11 @@ public class ContentSubmissionService {
public static final String STATUS_REJECTED = "REJECTED";
public static final String STATUS_WITHDRAWN = "WITHDRAWN";
public static final String TYPE_CREATE = "CREATE";
public static final String TYPE_UPDATE = "UPDATE";
public static final String TYPE_PUBLISH = "PUBLISH";
public static final String TYPE_OFFLINE = "OFFLINE";
private static final Set<String> REVIEWABLE = Set.of(STATUS_SUBMITTED, STATUS_REVIEWING);
private final ContentSubmissionMapper submissionMapper;
@@ -141,4 +146,15 @@ public class ContentSubmissionService {
default -> code;
};
}
/** 提交类型文案:创作者看的通知里要写清楚是"新建"还是"申请上线" */
public static String typeText(String code) {
return switch (code == null ? "" : code) {
case TYPE_CREATE -> "新建";
case TYPE_UPDATE -> "编辑";
case TYPE_PUBLISH -> "申请上线";
case TYPE_OFFLINE -> "申请下线";
default -> code == null ? "" : code;
};
}
}
@@ -0,0 +1,202 @@
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.dal.mapper.DramaMapper;
import com.fruitshorts.content.enums.ContentStatus;
import com.fruitshorts.creator.dal.entity.ContentSubmission;
import com.fruitshorts.creator.dal.entity.Creator;
import com.fruitshorts.creator.dal.mapper.ContentSubmissionMapper;
import com.fruitshorts.creator.dal.mapper.CreatorMapper;
import com.fruitshorts.creator.vo.CreatorCenterDramaQueryReqVO;
import com.fruitshorts.creator.vo.CreatorCenterDramaVO;
import com.fruitshorts.creator.vo.CreatorCenterNoticeVO;
import com.fruitshorts.creator.vo.CreatorCenterProfileVO;
import com.fruitshorts.framework.security.SecurityUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 创作者中心(Web 端,做在后台管理里):创作者本人视角的只读查询与归属校验。
*
* **归属判定的唯一入口是 {@link #requireCurrentCreator()}**:
* 创作者身份从登录态(sys_admin_user.id)解析,creator-center 的所有接口都**不收 creatorId 参数**,
* 查询一律带 `where creator_id = 当前创作者`。所以横向越权不是靠"记得加过滤",
* 而是参数层面就没有入口;真被别人猜到 id 时,{@link #requireMyDrama} 会直接 403。
*/
@Service
@RequiredArgsConstructor
public class CreatorCenterService {
private static final int NOTICE_LIMIT = 50;
private final CreatorMapper creatorMapper;
private final DramaMapper dramaMapper;
private final ContentSubmissionMapper submissionMapper;
/** 当前登录人绑定的创作者主体;没绑定就是 403,不返回"你是管理员"之类的细节。 */
public Creator requireCurrentCreator() {
Long adminUserId = SecurityUtils.getUserId();
if (adminUserId == null) {
throw new ServiceException(ErrorCode.UNAUTHORIZED);
}
Creator creator = creatorMapper.selectOne(Wrappers.<Creator>lambdaQuery()
.eq(Creator::getAdminUserId, adminUserId)
.last("limit 1"));
if (creator == null) {
throw new ServiceException(ErrorCode.FORBIDDEN, "当前账号未绑定创作者主体,无法进入创作者中心");
}
return creator;
}
/**
* 作品归属校验:不是自己的作品,不区分"不存在"与"别人的",统一 403。
* 后续各批(编辑、上传挂载、收费配置)都复用这个方法,避免各写一份过滤条件。
*/
public Drama requireMyDrama(Long dramaId) {
Creator creator = requireCurrentCreator();
Drama drama = dramaId == null ? null : dramaMapper.selectById(dramaId);
if (drama == null || !Objects.equals(drama.getCreatorId(), creator.getId())) {
throw new ServiceException(ErrorCode.FORBIDDEN, "无权访问该作品");
}
return drama;
}
public CreatorCenterProfileVO profile() {
Creator creator = requireCurrentCreator();
CreatorCenterProfileVO vo = new CreatorCenterProfileVO();
vo.setId(creator.getId());
vo.setCode(creator.getCode());
vo.setName(creator.getName());
vo.setType(creator.getType());
vo.setStatus(creator.getStatus());
vo.setStatusText(CreatorService.statusText(creator.getStatus()));
vo.setAuditNote(creator.getAuditNote());
vo.setAuditAt(creator.getAuditAt());
vo.setContactName(creator.getContactName());
vo.setContactEmail(creator.getContactEmail());
vo.setCountryCode(creator.getCountryCode());
vo.setDramaTotal(dramaMapper.selectCount(Wrappers.<Drama>lambdaQuery()
.eq(Drama::getCreatorId, creator.getId())));
return vo;
}
public PageResult<CreatorCenterDramaVO> pageMyDramas(CreatorCenterDramaQueryReqVO req) {
Creator creator = requireCurrentCreator();
Page<Drama> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
IPage<Drama> result = dramaMapper.selectPage(page, Wrappers.<Drama>lambdaQuery()
.eq(Drama::getCreatorId, creator.getId())
.like(StringUtils.hasText(req.getKeyword()), Drama::getTitle, req.getKeyword())
.eq(StringUtils.hasText(req.getStatus()), Drama::getStatus, req.getStatus())
.orderByDesc(Drama::getUpdatedAt)
.orderByDesc(Drama::getId));
return new PageResult<>(result.getTotal(), toDramaVOList(creator.getId(), result.getRecords()));
}
public CreatorCenterDramaVO myDramaDetail(Long dramaId) {
Creator creator = requireCurrentCreator();
Drama drama = requireMyDrama(dramaId);
return toDramaVOList(creator.getId(), List.of(drama)).get(0);
}
/**
* 我的通知 = 入驻审核结果 + 我提交内容的审核结果(都从已有表派生,不新建通知表)。
* 系统公告(ops_notice)当前没有产出方,等有公告功能再接,避免出现"永远空列表"的入口。
*/
public List<CreatorCenterNoticeVO> notices() {
Creator creator = requireCurrentCreator();
List<CreatorCenterNoticeVO> list = new ArrayList<>();
if (StringUtils.hasText(creator.getStatus()) && !CreatorService.STATUS_PENDING.equals(creator.getStatus())
&& creator.getAuditAt() != null) {
CreatorCenterNoticeVO notice = new CreatorCenterNoticeVO();
notice.setId(creator.getId());
notice.setBizType("CREATOR");
notice.setTitle("入驻审核结果:" + CreatorService.statusText(creator.getStatus()));
notice.setContent(StringUtils.hasText(creator.getAuditNote()) ? creator.getAuditNote() : "—");
notice.setResult(creator.getStatus());
notice.setTime(creator.getAuditAt());
list.add(notice);
}
List<ContentSubmission> reviewed = submissionMapper.selectList(Wrappers.<ContentSubmission>lambdaQuery()
.eq(ContentSubmission::getCreatorId, creator.getId())
.in(ContentSubmission::getStatus,
ContentSubmissionService.STATUS_APPROVED, ContentSubmissionService.STATUS_REJECTED)
.orderByDesc(ContentSubmission::getReviewAt)
.last("limit " + NOTICE_LIMIT));
for (ContentSubmission submission : reviewed) {
CreatorCenterNoticeVO notice = new CreatorCenterNoticeVO();
notice.setId(submission.getId());
notice.setBizType("SUBMISSION");
notice.setTitle("作品 ID " + submission.getDramaId() + " 的"
+ ContentSubmissionService.typeText(submission.getType()) + "审核结果:"
+ ContentSubmissionService.statusText(submission.getStatus()));
notice.setContent(StringUtils.hasText(submission.getReviewNote())
? submission.getReviewNote() : "—");
notice.setResult(submission.getStatus());
notice.setTime(submission.getReviewAt());
list.add(notice);
}
list.sort(Comparator.comparing(CreatorCenterNoticeVO::getTime,
Comparator.nullsLast(Comparator.reverseOrder())));
return list;
}
/** 作品 + 最近一次提交:列表页要显示"审核状态与进度",两个表拼一次拿全,避免前端 N+1。 */
private List<CreatorCenterDramaVO> toDramaVOList(Long creatorId, List<Drama> dramas) {
if (dramas.isEmpty()) {
return List.of();
}
List<Long> dramaIds = dramas.stream().map(Drama::getId).toList();
List<ContentSubmission> submissions = submissionMapper.selectList(Wrappers.<ContentSubmission>lambdaQuery()
.eq(ContentSubmission::getCreatorId, creatorId)
.in(ContentSubmission::getDramaId, dramaIds)
.orderByDesc(ContentSubmission::getId));
Map<Long, ContentSubmission> latest = new HashMap<>();
for (ContentSubmission submission : submissions) {
latest.putIfAbsent(submission.getDramaId(), submission);
}
return dramas.stream().map(drama -> {
CreatorCenterDramaVO vo = new CreatorCenterDramaVO();
vo.setId(drama.getId());
vo.setCode(drama.getCode());
vo.setTitle(drama.getTitle());
vo.setCover(drama.getCoverUrl());
vo.setStatus(drama.getStatus());
vo.setStatusText(ContentStatus.textOf(drama.getStatus()));
vo.setEpisodes(drama.getTotalEpisodes());
vo.setViews(drama.getViewCount());
vo.setUpdatedAt(drama.getUpdatedAt());
ContentSubmission submission = latest.get(drama.getId());
if (submission != null) {
vo.setSubmissionId(submission.getId());
vo.setSubmissionType(submission.getType());
vo.setSubmissionStatus(submission.getStatus());
vo.setSubmissionStatusText(ContentSubmissionService.statusText(submission.getStatus()));
vo.setReviewNote(submission.getReviewNote());
vo.setRejectCode(submission.getRejectCode());
vo.setSubmitAt(submission.getSubmitAt());
vo.setReviewAt(submission.getReviewAt());
}
return vo;
}).toList();
}
}
@@ -0,0 +1,18 @@
package com.fruitshorts.creator.vo;
import com.fruitshorts.common.pojo.PageParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 创作者中心 · 我的作品检索条件。
*
* 注意:这里**没有 creatorId** —— 归属一律从登录态解析,前端传不进来,也就没有横向越权的入口。
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class CreatorCenterDramaQueryReqVO extends PageParam {
private String keyword;
private String status;
}
@@ -0,0 +1,32 @@
package com.fruitshorts.creator.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 创作者中心 · 我的作品(作品 + 最近一次提交的审核进度,一个接口给全,避免前端拼两个请求)。
*/
@Data
public class CreatorCenterDramaVO {
private Long id;
private String code;
private String title;
private String cover;
private String status;
private String statusText;
private Integer episodes;
private Long views;
private LocalDateTime updatedAt;
/** 最近一次内容提交(没有提交过则为空) */
private Long submissionId;
private String submissionType;
private String submissionStatus;
private String submissionStatusText;
private String reviewNote;
private String rejectCode;
private LocalDateTime submitAt;
private LocalDateTime reviewAt;
}
@@ -0,0 +1,25 @@
package com.fruitshorts.creator.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 创作者中心 · 通知。
*
* 第一阶段的通知来源是「审核结果」:入驻审核(CREATOR)与内容提交审核(SUBMISSION),
* 都从已有表派生,不新建通知表;系统公告(ops_notice)目前没有产出方,等有公告功能再接。
*/
@Data
public class CreatorCenterNoticeVO {
/** 来源单据 id */
private Long id;
/** CREATOR(入驻审核)/ SUBMISSION(内容提交审核) */
private String bizType;
private String title;
private String content;
/** 通过 / 驳回 等结果文案 */
private String result;
private LocalDateTime time;
}
@@ -0,0 +1,27 @@
package com.fruitshorts.creator.vo;
import lombok.Data;
import java.time.LocalDateTime;
/** 创作者中心 · 当前登录创作者的主体信息(只返回自己的,不含他人数据)。 */
@Data
public class CreatorCenterProfileVO {
private Long id;
private String code;
private String name;
/** INDIVIDUAL / COMPANY */
private String type;
/** pending / approved / rejected / frozen */
private String status;
private String statusText;
/** 入驻审核意见(驳回时是原因) */
private String auditNote;
private LocalDateTime auditAt;
private String contactName;
private String contactEmail;
private String countryCode;
/** 我的作品数(只统计自己的) */
private Long dramaTotal;
}