From efeb9fcfaf7965e95825600fef7513a293ea57c9 Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Thu, 15 Oct 2026 15:25:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9B=E4=BD=9C=E8=80=85=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E6=94=AF=E6=8C=81=E8=B6=85=E7=AE=A1=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=E4=BB=A3=E7=9C=8B=EF=BC=88=E6=9C=AA=E7=BB=91=E5=AE=9A=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E7=99=BD=E5=B1=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:/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 条)。 --- docs/api-phase1.md | 22 +++++ .../controller/CreatorCenterController.java | 11 +++ .../admin/controller/MediaController.java | 5 + .../common/exception/ErrorCode.java | 10 +- .../security/ImpersonationGuard.java | 72 ++++++++++++++ fruitshorts-module-creator/pom.xml | 5 + .../creator/service/CreatorCenterService.java | 95 +++++++++++++++---- .../service/CreatorContentService.java | 17 ++-- .../creator/service/CreatorContext.java | 12 +++ .../creator/vo/CreatorCenterTargetVO.java | 14 +++ .../system/dal/entity/AuditLog.java | 33 +++++++ .../system/dal/mapper/AuditLogMapper.java | 9 ++ .../system/service/AuditLogService.java | 54 +++++++++++ sql/data-phase1.sql | 17 +++- 14 files changed, 351 insertions(+), 25 deletions(-) create mode 100644 fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/ImpersonationGuard.java create mode 100644 fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorContext.java create mode 100644 fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorCenterTargetVO.java create mode 100644 fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/AuditLog.java create mode 100644 fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/AuditLogMapper.java create mode 100644 fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/AuditLogService.java diff --git a/docs/api-phase1.md b/docs/api-phase1.md index 39291b7..782d05a 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -479,6 +479,28 @@ POST /app/auth/register **本阶段进度**:07 全部 5 批完成,第一阶段 05 / 06 / 07 / 08 四块闭环。 +**超管代看(只读)** + +后台账号如果没绑定创作者主体,又点进了创作者中心,接口不再直接 403 白屏,而是三级解析: + +| 情况 | 结果 | +| --- | --- | +| 账号绑定了 `creator.admin_user_id` | 用自己的身份(**创作者角色永远走这条**,代看逻辑不影响他) | +| 未绑定 + 有 `creator-center:impersonate` + 请求带 `X-Creator-Id` | 只读代看该创作者 | +| 其余 | `200004`(未绑定)/ `200005`(有代看权限但没选目标)—— 前端据此渲染引导页 | + +**红线**: +- 目标创作者走**独立请求头 `X-Creator-Id`**,不做查询参数 —— creator-center 接口依旧不接收 `creatorId`; +- 这个头**只有**持有 `creator-center:impersonate` 的账号才生效,创作者自己带上没有任何作用(实测:带头上限仍只看到自己的数据,仍能正常编辑自己的作品); +- **代看只读**:作品编辑、分集增删改与排序、上传会话(`/admin/media/upload/*` 的 init/chunk/complete)与产物挂载、 + 提交 / 撤回、收费配置**全部拒绝**,错误码 `200006`; +- 代看进入写 `audit_log`(`biz_type=CREATOR`、`action=IMPERSONATE_VIEW`、操作人、IP、目标创作者), + 同一操作人对同一目标 10 分钟内去重,避免翻页把审计表刷爆。 + +| 方法 | 路径 | 权限点 | 说明 | +| --- | --- | --- | --- | +| GET | `/admin/creator-center/impersonation/targets` | `creator-center:impersonate` | 代看目标下拉的数据源(id / 名称 / 状态) | + ## 3. 契约与实现状态对照 | 模块 | 表结构 | 接口 | 代码 | diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CreatorCenterController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CreatorCenterController.java index 2e0266b..4c118e9 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CreatorCenterController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CreatorCenterController.java @@ -7,6 +7,7 @@ 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.admin.service.CreatorDashboardService; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; @@ -67,4 +68,14 @@ public class CreatorCenterController { public Result> dashboard() { return Result.ok(creatorDashboardService.overview()); } + + /** + * 代看目标下拉的数据源:只有带 `creator-center:impersonate` 的账号(第一阶段 = 超管)能调。 + * 前端拿它渲染顶部的「当前创作者」下拉;没权限的账号不显示下拉,也不会请求这个接口。 + */ + @GetMapping("/impersonation/targets") + @PreAuthorize("@ss.hasPermission('creator-center:impersonate')") + public Result> impersonationTargets() { + return Result.ok(creatorCenterService.impersonationTargets()); + } } diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MediaController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MediaController.java index 3fe272e..99b36a2 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MediaController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MediaController.java @@ -2,6 +2,7 @@ package com.fruitshorts.admin.controller; import com.fruitshorts.common.pojo.Result; import com.fruitshorts.framework.security.SecurityUtils; +import com.fruitshorts.framework.security.ImpersonationGuard; import com.fruitshorts.media.service.MediaService; import com.fruitshorts.media.vo.MediaAssetRespVO; import com.fruitshorts.media.vo.UploadCompleteReqVO; @@ -33,6 +34,8 @@ public class MediaController { @PostMapping("/upload/init") @PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')") public Result init(@Valid @RequestBody UploadInitReqVO req) { + // 代看是只读:上传会话属于写操作,代看态直接拒(不带 X-Creator-Id 的普通后台上传不受影响) + ImpersonationGuard.rejectWrite(); return Result.ok(mediaService.init(req, SecurityUtils.getUserId())); } @@ -41,6 +44,7 @@ public class MediaController { public Result uploadChunk(@RequestParam String uploadId, @RequestParam int index, @RequestParam("file") MultipartFile file) { + ImpersonationGuard.rejectWrite(); mediaService.saveChunk(uploadId, index, file, SecurityUtils.getUserId()); return Result.ok(index); } @@ -48,6 +52,7 @@ public class MediaController { @PostMapping("/upload/complete") @PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')") public Result complete(@Valid @RequestBody UploadCompleteReqVO req) { + ImpersonationGuard.rejectWrite(); return Result.ok(mediaService.complete(req, SecurityUtils.getUserId())); } diff --git a/fruitshorts-common/src/main/java/com/fruitshorts/common/exception/ErrorCode.java b/fruitshorts-common/src/main/java/com/fruitshorts/common/exception/ErrorCode.java index 8420d13..e45ad5b 100644 --- a/fruitshorts-common/src/main/java/com/fruitshorts/common/exception/ErrorCode.java +++ b/fruitshorts-common/src/main/java/com/fruitshorts/common/exception/ErrorCode.java @@ -27,7 +27,15 @@ public enum ErrorCode { DATA_EXISTS(200001, "数据已存在"), DATA_NOT_FOUND(200002, "数据不存在"), - OPERATION_NOT_ALLOWED(200003, "当前状态不允许该操作"); + OPERATION_NOT_ALLOWED(200003, "当前状态不允许该操作"), + + /** + * 创作者中心的身份类错误:单独给码,前端才能区分「未绑定」(渲染引导页) + * 与「没选代看目标」「代看只读」三种情况,而不是一律弹通用报错。 + */ + CREATOR_NOT_BOUND(200004, "当前账号未绑定创作者主体"), + CREATOR_TARGET_REQUIRED(200005, "请先选择要代看的创作者"), + CREATOR_IMPERSONATE_READONLY(200006, "代看模式下只读,不能修改创作者数据"); private final int code; private final String message; diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/ImpersonationGuard.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/ImpersonationGuard.java new file mode 100644 index 0000000..9a7b903 --- /dev/null +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/ImpersonationGuard.java @@ -0,0 +1,72 @@ +package com.fruitshorts.framework.security; + +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.util.StringUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +/** + * 超管「代看创作者」的识别与只读闸。 + * + * 约定(红线): + * 1. 目标创作者走**独立请求头** `X-Creator-Id`,不做查询参数 —— 创作中心的接口永远不接收 creatorId 参数; + * 2. 这个头**只有**持有 `creator-center:impersonate` 的账号才生效。创作者自己带上它没有任何作用, + * 也不影响"只能看自己的"这条规则; + * 3. 代看是**只读**:写操作(作品编辑、分集增删改、上传会话与产物挂载、提交/撤回、收费配置)一律拒绝。 + */ +public final class ImpersonationGuard { + + public static final String CREATOR_HEADER = "X-Creator-Id"; + public static final String PERMISSION = "creator-center:impersonate"; + + private ImpersonationGuard() { + } + + /** 请求头里的目标创作者 id;没带或格式不对都返回 null。 */ + public static Long requestedCreatorId() { + HttpServletRequest request = currentRequest(); + if (request == null) { + return null; + } + String raw = request.getHeader(CREATOR_HEADER); + if (!StringUtils.hasText(raw)) { + return null; + } + try { + long value = Long.parseLong(raw.trim()); + return value > 0 ? value : null; + } catch (NumberFormatException ignored) { + return null; + } + } + + /** 当前这次请求是否属于"代看":带了目标 id + 调用方确实有代看权限。 */ + public static boolean isImpersonating() { + LoginUser loginUser = SecurityUtils.getLoginUserOrNull(); + return loginUser != null + && loginUser.hasPermission(PERMISSION) + && requestedCreatorId() != null; + } + + /** 代看状态下拒绝写操作;不是代看则什么也不做。 */ + public static void rejectWrite() { + if (isImpersonating()) { + throw new ServiceException(ErrorCode.CREATOR_IMPERSONATE_READONLY, + "代看模式下只读:不能修改创作者数据(当前代看创作者 #" + requestedCreatorId() + ")"); + } + } + + public static String clientIp() { + HttpServletRequest request = currentRequest(); + return request == null ? "" : com.fruitshorts.framework.web.WebUtils.getClientIp(request); + } + + private static HttpServletRequest currentRequest() { + if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes) { + return attributes.getRequest(); + } + return null; + } +} diff --git a/fruitshorts-module-creator/pom.xml b/fruitshorts-module-creator/pom.xml index ce156a1..23a80c7 100644 --- a/fruitshorts-module-creator/pom.xml +++ b/fruitshorts-module-creator/pom.xml @@ -32,5 +32,10 @@ com.fruitshorts fruitshorts-module-media + + + com.fruitshorts + fruitshorts-module-system + diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorCenterService.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorCenterService.java index a8495b3..3444758 100644 --- a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorCenterService.java +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorCenterService.java @@ -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.lambdaQuery() - .eq(Creator::getAdminUserId, adminUserId) + /** + * 当前请求对应的创作者上下文:自己的 or 只读代看的。 + * 三级解析见类注释;代看成功时写一条审计(10 分钟内同人同目标去重)。 + */ + public CreatorContext currentContext() { + LoginUser loginUser = SecurityUtils.getLoginUser(); + + // ① 绑定优先:创作者角色永远走这条,代看逻辑不会影响他自己 + Creator own = creatorMapper.selectOne(Wrappers.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 impersonationTargets() { + return creatorMapper.selectList(Wrappers.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(); diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorContentService.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorContentService.java index 6b7bf26..2b74107 100644 --- a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorContentService.java +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorContentService.java @@ -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 ids) { - Drama drama = centerService.requireMyDrama(dramaId); + Drama drama = centerService.requireWritableDrama(dramaId); requireEditable(drama); List 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); diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorContext.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorContext.java new file mode 100644 index 0000000..db68448 --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorContext.java @@ -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) { +} diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorCenterTargetVO.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorCenterTargetVO.java new file mode 100644 index 0000000..a47be69 --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorCenterTargetVO.java @@ -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; +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/AuditLog.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/AuditLog.java new file mode 100644 index 0000000..702014d --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/AuditLog.java @@ -0,0 +1,33 @@ +package com.fruitshorts.system.dal.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 业务对象变更审计(支付、权益、定价、代看等敏感操作)。 + * + * 与 sys_operation_log(接口级操作日志)区分:这里是"谁对哪个业务对象做了什么", + * 表上没有 deleted 列,审计记录只增不改。 + */ +@Data +@TableName("audit_log") +public class AuditLog { + + @TableId(type = IdType.AUTO) + private Long id; + /** ORDER / ENTITLEMENT / USER / CREATOR / PRICE */ + private String bizType; + private String bizId; + private String action; + private String beforeValue; + private String afterValue; + private Long operatorId; + private String operatorName; + private String ip; + private String remark; + private LocalDateTime createdAt; +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/AuditLogMapper.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/AuditLogMapper.java new file mode 100644 index 0000000..6410270 --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/AuditLogMapper.java @@ -0,0 +1,9 @@ +package com.fruitshorts.system.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.fruitshorts.system.dal.entity.AuditLog; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface AuditLogMapper extends BaseMapper { +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/AuditLogService.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/AuditLogService.java new file mode 100644 index 0000000..fe580f9 --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/AuditLogService.java @@ -0,0 +1,54 @@ +package com.fruitshorts.system.service; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fruitshorts.system.dal.entity.AuditLog; +import com.fruitshorts.system.dal.mapper.AuditLogMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +/** 审计写入。目前只有"代看创作者"在用,后续支付/定价等敏感操作也走这里。 */ +@Service +@RequiredArgsConstructor +public class AuditLogService { + + public static final String BIZ_CREATOR = "CREATOR"; + public static final String ACTION_IMPERSONATE_VIEW = "IMPERSONATE_VIEW"; + + /** 同一操作人看同一个创作者,10 分钟内只记一条,避免下拉/翻页把审计表刷爆 */ + private static final int DEDUPE_MINUTES = 10; + + private final AuditLogMapper auditLogMapper; + + /** + * 记录"代看进入"。 + * + * 前端切换代看目标不是一次独立请求(只是给后续请求加了个头),所以这里做去重: + * 同一操作人对同一创作者,10 分钟内的连续访问合并成一条 —— 既留下"谁什么时候看了谁", + * 又不会因为列表翻页刷出几百条。 + */ + public void recordImpersonationView(Long operatorId, String operatorName, Long creatorId, + String creatorCode, String ip) { + LocalDateTime since = LocalDateTime.now().minusMinutes(DEDUPE_MINUTES); + Long recent = auditLogMapper.selectCount(Wrappers.lambdaQuery() + .eq(AuditLog::getBizType, BIZ_CREATOR) + .eq(AuditLog::getBizId, String.valueOf(creatorId)) + .eq(AuditLog::getAction, ACTION_IMPERSONATE_VIEW) + .eq(AuditLog::getOperatorId, operatorId == null ? 0L : operatorId) + .ge(AuditLog::getCreatedAt, since)); + if (recent != null && recent > 0) { + return; + } + + AuditLog log = new AuditLog(); + log.setBizType(BIZ_CREATOR); + log.setBizId(String.valueOf(creatorId)); + log.setAction(ACTION_IMPERSONATE_VIEW); + log.setOperatorId(operatorId == null ? 0L : operatorId); + log.setOperatorName(operatorName == null ? "" : operatorName); + log.setIp(ip == null ? "" : ip); + log.setRemark("代看创作者 " + (creatorCode == null ? "" : creatorCode) + "(只读)"); + auditLogMapper.insert(log); + } +} diff --git a/sql/data-phase1.sql b/sql/data-phase1.sql index 0974006..4420345 100644 --- a/sql/data-phase1.sql +++ b/sql/data-phase1.sql @@ -185,6 +185,19 @@ INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES ('creator-center:stat:view', '创作者中心-查看看板', '创作者中心', 'BUTTON', 154) ON CONFLICT (code) DO NOTHING; +-- 代看创作者(只读):第一阶段只给超管,content_operator 不给 +INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES + ('creator-center:impersonate', '代看创作者(只读)', '创作者中心', 'BUTTON', 155) +ON CONFLICT (code) DO NOTHING; + +INSERT INTO sys_role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM sys_role r +CROSS JOIN sys_permission p +WHERE r.code = 'super_admin' + AND p.code = 'creator-center:impersonate' +ON CONFLICT DO NOTHING; + -- 创作者角色只拿创作者中心这几个点,不给任何后台运营权限 INSERT INTO sys_role_permission (role_id, permission_id) SELECT r.id, p.id @@ -220,7 +233,9 @@ ON CONFLICT DO NOTHING; -- 组件路径对应 src/views/creator-center/ 下的四个页面。 INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon, permission_code, type, always_show, affix, sort, visible) VALUES - (26, 0, '创作者中心', '/creator-center', 'CreatorCenter', '#', '/creator-center/workspace', 'ep:film', 'creator-center:view', 'CATALOG', 1, 0, 3, 1), + -- route_name 必须全局唯一:17 号菜单(创作者管理)已经叫 CreatorCenter, + -- 同名会让 Vue Router 后者覆盖前者、整个 /creator-center 子树注册不上(超管点进去就 404) + (26, 0, '创作者中心', '/creator-center', 'CreatorWorkspace', '#', '/creator-center/workspace', 'ep:film', 'creator-center:view', 'CATALOG', 1, 0, 3, 1), (27, 26, '我的作品', 'workspace', 'creator-workspace', 'views/creator-center/WorkspaceView', '', '', 'creator-center:view', 'MENU', 0, 0, 1, 1), (28, 26, '作品编辑', 'dramas/:id', 'creator-drama-edit', 'views/creator-center/DramaEditView', '', '', 'creator-center:drama:edit', 'MENU', 0, 0, 2, 0), (29, 26, '收费配置', 'prices', 'creator-prices', 'views/creator-center/PriceConfigView', '', '', 'creator-center:price:update', 'MENU', 0, 0, 3, 1),