feat: 创作者中心支持超管只读代看(未绑定不再白屏)
为什么:/creator-center 菜单同时给了超管和创作者,但接口只认"绑定了 creator.admin_user_id 的账号", 而 creator 表为空 —— 超管点进去每个接口都 403,页面全白。这是设计缺口:需要一条"超管代看"的合法路径, 且必须既能让运营排查问题,又不能变成越权入口。 - requireCurrentCreator 改三级解析:① 绑定优先(创作者角色永远走这条)② 未绑定 + creator-center:impersonate + 请求头 X-Creator-Id → 只读代看 ③ 其余给可识别错误码(200004 未绑定 / 200005 未选目标) - 目标走独立请求头而不是查询参数:creator-center 接口继续"不接收 creatorId"; 且只有具备 impersonate 权限的账号这个头才生效 —— 创作者自己带也不影响"只看自己的" - 只读硬边界:代看态下作品编辑、分集增删改/排序、上传会话与产物挂载、提交/撤回、收费配置一律 200006 (写路径统一走 requireWritableDrama/requireWritable;上传会话在 MediaController 里用 ImpersonationGuard 拦) - 代看进入写 audit_log(AuditLog 实体 + AuditLogService,同人同目标 10 分钟去重),表本来就有、之前无代码引用 - 新增 GET /admin/creator-center/impersonation/targets(仅 impersonate)供顶部下拉取数 - 权限点 creator-center:impersonate 只授予 super_admin(content_operator 第一阶段不给) - 修掉菜单 26 的路由名冲突:它和 17 号菜单(创作者管理)都叫 CreatorCenter,Vue Router 同名路由互相覆盖, 导致超管整个 /creator-center 子树注册不上(点进去 404)。改名 CreatorWorkspace,并核对全表无重名 实测:创作者带 X-Creator-Id 仍只看自己的、仍能编辑自己的作品;代看下 9 个写操作全 403; audit_log 有且仅有一条代看记录(12 次代看请求去重成 1 条)。
This commit is contained in:
1 parent
a635de7111
commit
efeb9fcfaf
14 files changed
+351
-25
No files matched your search
@@ -32,5 +32,10 @@
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-module-media</artifactId>
|
||||
</dependency>
|
||||
<!-- 代看创作者要写 audit_log(审计表在系统域) -->
|
||||
<dependency>
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-module-system</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+79
-16
@@ -17,7 +17,11 @@ 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.creator.vo.CreatorCenterTargetVO;
|
||||
import com.fruitshorts.framework.security.ImpersonationGuard;
|
||||
import com.fruitshorts.framework.security.LoginUser;
|
||||
import com.fruitshorts.framework.security.SecurityUtils;
|
||||
import com.fruitshorts.system.service.AuditLogService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -30,12 +34,16 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 创作者中心(Web 端,做在后台管理里):创作者本人视角的只读查询与归属校验。
|
||||
* 创作者中心(Web 端,做在后台管理里):创作者本人视角的查询与归属校验。
|
||||
*
|
||||
* **归属判定的唯一入口是 {@link #requireCurrentCreator()}**:
|
||||
* 创作者身份从登录态(sys_admin_user.id)解析,creator-center 的所有接口都**不收 creatorId 参数**,
|
||||
* 查询一律带 `where creator_id = 当前创作者`。所以横向越权不是靠"记得加过滤",
|
||||
* 而是参数层面就没有入口;真被别人猜到 id 时,{@link #requireMyDrama} 会直接 403。
|
||||
* **归属判定的唯一入口是 {@link #currentContext()},三级解析**:
|
||||
* ① 账号绑定了 creator → 用自己(**创作者角色永远走这条**);
|
||||
* ② 未绑定 + 有 `creator-center:impersonate` + 请求带 `X-Creator-Id` → **只读代看**;
|
||||
* ③ 其余 → 带可识别错误码的 403({@link ErrorCode#CREATOR_NOT_BOUND} / {@link ErrorCode#CREATOR_TARGET_REQUIRED}),
|
||||
* 前端据此渲染引导页而不是白屏。
|
||||
*
|
||||
* creator-center 的所有接口都**不收 creatorId 参数**,查询一律带 `where creator_id = 当前创作者`;
|
||||
* 真被别人猜到 id 时,{@link #requireMyDrama} 会直接 403。代看态写操作由 {@link #requireWritable()} 拦。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -46,20 +54,65 @@ public class CreatorCenterService {
|
||||
private final CreatorMapper creatorMapper;
|
||||
private final DramaMapper dramaMapper;
|
||||
private final ContentSubmissionMapper submissionMapper;
|
||||
private final AuditLogService auditLogService;
|
||||
|
||||
/** 当前登录人绑定的创作者主体;没绑定就是 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)
|
||||
/**
|
||||
* 当前请求对应的创作者上下文:自己的 or 只读代看的。
|
||||
* 三级解析见类注释;代看成功时写一条审计(10 分钟内同人同目标去重)。
|
||||
*/
|
||||
public CreatorContext currentContext() {
|
||||
LoginUser loginUser = SecurityUtils.getLoginUser();
|
||||
|
||||
// ① 绑定优先:创作者角色永远走这条,代看逻辑不会影响他自己
|
||||
Creator own = creatorMapper.selectOne(Wrappers.<Creator>lambdaQuery()
|
||||
.eq(Creator::getAdminUserId, loginUser.userId())
|
||||
.last("limit 1"));
|
||||
if (creator == null) {
|
||||
throw new ServiceException(ErrorCode.FORBIDDEN, "当前账号未绑定创作者主体,无法进入创作者中心");
|
||||
if (own != null) {
|
||||
return new CreatorContext(own, false);
|
||||
}
|
||||
return creator;
|
||||
|
||||
// ② 未绑定:只有带代看权限的账号才能指定目标创作者
|
||||
if (!loginUser.hasPermission(ImpersonationGuard.PERMISSION)) {
|
||||
throw new ServiceException(ErrorCode.CREATOR_NOT_BOUND,
|
||||
"当前账号未绑定创作者主体,无法进入创作者中心");
|
||||
}
|
||||
Long targetId = ImpersonationGuard.requestedCreatorId();
|
||||
if (targetId == null) {
|
||||
throw new ServiceException(ErrorCode.CREATOR_TARGET_REQUIRED,
|
||||
"当前账号未绑定创作者主体,请选择要代看的创作者");
|
||||
}
|
||||
Creator target = creatorMapper.selectById(targetId);
|
||||
if (target == null) {
|
||||
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "要代看的创作者不存在");
|
||||
}
|
||||
auditLogService.recordImpersonationView(loginUser.userId(), loginUser.username(),
|
||||
target.getId(), target.getCode(), ImpersonationGuard.clientIp());
|
||||
return new CreatorContext(target, true);
|
||||
}
|
||||
|
||||
/** 当前登录人(或代看目标)对应的创作者主体。 */
|
||||
public Creator requireCurrentCreator() {
|
||||
return currentContext().creator();
|
||||
}
|
||||
|
||||
/** 代看态拒绝写操作;创作者本人不受影响。 */
|
||||
public void requireWritable() {
|
||||
ImpersonationGuard.rejectWrite();
|
||||
}
|
||||
|
||||
/** 代看目标下拉的数据源:只有带代看权限的账号能调(接口层再加 @PreAuthorize)。 */
|
||||
public List<CreatorCenterTargetVO> impersonationTargets() {
|
||||
return creatorMapper.selectList(Wrappers.<Creator>lambdaQuery()
|
||||
.orderByAsc(Creator::getCode))
|
||||
.stream().map(creator -> {
|
||||
CreatorCenterTargetVO vo = new CreatorCenterTargetVO();
|
||||
vo.setId(creator.getId());
|
||||
vo.setCode(creator.getCode());
|
||||
vo.setName(creator.getName());
|
||||
vo.setStatus(creator.getStatus());
|
||||
vo.setStatusText(CreatorService.statusText(creator.getStatus()));
|
||||
return vo;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,6 +128,16 @@ public class CreatorCenterService {
|
||||
return drama;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写路径专用:先确认作品属于"当前生效的创作者",再挡住代看态。
|
||||
* 写接口一律用这个方法(而不是 requireMyDrama + 手写 requireWritable),漏一处就是越权。
|
||||
*/
|
||||
public Drama requireWritableDrama(Long dramaId) {
|
||||
Drama drama = requireMyDrama(dramaId);
|
||||
requireWritable();
|
||||
return drama;
|
||||
}
|
||||
|
||||
public CreatorCenterProfileVO profile() {
|
||||
Creator creator = requireCurrentCreator();
|
||||
CreatorCenterProfileVO vo = new CreatorCenterProfileVO();
|
||||
|
||||
+10
-7
@@ -52,6 +52,7 @@ public class CreatorContentService {
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long createDrama(CreatorDramaEditReqVO req) {
|
||||
Creator creator = centerService.requireCurrentCreator();
|
||||
centerService.requireWritable();
|
||||
requireApprovedCreator(creator);
|
||||
return dramaService.createForCreator(toSaveReq(req), creator.getId(), creator.getAdminUserId());
|
||||
}
|
||||
@@ -59,7 +60,7 @@ public class CreatorContentService {
|
||||
/** 编辑作品信息。定价、热门、排序不在本接口的可改范围,原样回填,避免被默认值冲掉。 */
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateDrama(Long dramaId, CreatorDramaEditReqVO req) {
|
||||
Drama drama = centerService.requireMyDrama(dramaId);
|
||||
Drama drama = centerService.requireWritableDrama(dramaId);
|
||||
requireEditable(drama);
|
||||
// 没传的字段沿用现值:底层 apply() 对 null 会写空串 / 默认值,直接透传会把"没改的字段"抹掉
|
||||
DramaSaveReqVO vo = toSaveReq(req, drama);
|
||||
@@ -78,7 +79,7 @@ public class CreatorContentService {
|
||||
/** 新增分集:集号不传就接在当前最大集号后面;新分集默认免费,收费由批 3 的配置页决定。 */
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long addEpisode(Long dramaId, CreatorEpisodeReqVO req) {
|
||||
Drama drama = centerService.requireMyDrama(dramaId);
|
||||
Drama drama = centerService.requireWritableDrama(dramaId);
|
||||
requireEditable(drama);
|
||||
|
||||
Integer epNo = req.getEpNo();
|
||||
@@ -105,7 +106,7 @@ public class CreatorContentService {
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateEpisode(Long dramaId, Long episodeId, CreatorEpisodeReqVO req) {
|
||||
Drama drama = centerService.requireMyDrama(dramaId);
|
||||
Drama drama = centerService.requireWritableDrama(dramaId);
|
||||
requireEditable(drama);
|
||||
DramaEpisode episode = requireMyEpisode(dramaId, episodeId);
|
||||
|
||||
@@ -126,7 +127,7 @@ public class CreatorContentService {
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteEpisode(Long dramaId, Long episodeId) {
|
||||
Drama drama = centerService.requireMyDrama(dramaId);
|
||||
Drama drama = centerService.requireWritableDrama(dramaId);
|
||||
requireEditable(drama);
|
||||
requireMyEpisode(dramaId, episodeId);
|
||||
episodeService.delete(episodeId);
|
||||
@@ -135,7 +136,7 @@ public class CreatorContentService {
|
||||
/** 批量排序:先确认列表里每一集都属于这部剧,避免借排序接口动别人的分集。 */
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void sortEpisodes(Long dramaId, List<Long> ids) {
|
||||
Drama drama = centerService.requireMyDrama(dramaId);
|
||||
Drama drama = centerService.requireWritableDrama(dramaId);
|
||||
requireEditable(drama);
|
||||
List<DramaEpisode> episodes = episodeMapper.selectBatchIds(ids);
|
||||
boolean allMine = episodes.size() == ids.size()
|
||||
@@ -148,12 +149,14 @@ public class CreatorContentService {
|
||||
|
||||
public ContentSubmission submit(Long dramaId, String type) {
|
||||
Creator creator = centerService.requireCurrentCreator();
|
||||
centerService.requireWritable();
|
||||
requireApprovedCreator(creator);
|
||||
return submissionService.submit(creator, dramaId, type);
|
||||
}
|
||||
|
||||
public ContentSubmission withdraw(Long submissionId) {
|
||||
Creator creator = centerService.requireCurrentCreator();
|
||||
centerService.requireWritable();
|
||||
return submissionService.withdraw(creator, submissionId);
|
||||
}
|
||||
|
||||
@@ -172,7 +175,7 @@ public class CreatorContentService {
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void attachAsset(Long dramaId, Long episodeId, Long assetId) {
|
||||
Drama drama = centerService.requireMyDrama(dramaId);
|
||||
Drama drama = centerService.requireWritableDrama(dramaId);
|
||||
requireEditable(drama);
|
||||
DramaEpisode episode = requireMyEpisode(dramaId, episodeId);
|
||||
|
||||
@@ -217,7 +220,7 @@ public class CreatorContentService {
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public PriceRespVO savePrice(Long dramaId, PriceSaveReqVO req) {
|
||||
Drama drama = centerService.requireMyDrama(dramaId);
|
||||
Drama drama = centerService.requireWritableDrama(dramaId);
|
||||
requireEditable(drama);
|
||||
req.setDramaId(drama.getId());
|
||||
return priceService.save(req);
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.fruitshorts.creator.service;
|
||||
|
||||
import com.fruitshorts.creator.dal.entity.Creator;
|
||||
|
||||
/**
|
||||
* 一次创作者中心请求的身份上下文。
|
||||
*
|
||||
* @param creator 生效的创作者主体(自己的,或代看目标)
|
||||
* @param impersonated 是否处于超管代看状态(只读)
|
||||
*/
|
||||
public record CreatorContext(Creator creator, boolean impersonated) {
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.fruitshorts.creator.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** 代看目标下拉项:超管选择要看哪个创作者时用(只给 id / 名称 / 状态,不给联系方式)。 */
|
||||
@Data
|
||||
public class CreatorCenterTargetVO {
|
||||
|
||||
private Long id;
|
||||
private String code;
|
||||
private String name;
|
||||
private String status;
|
||||
private String statusText;
|
||||
}
|
||||
Reference in new issue
Block a user