feat: 07 创作者中心批 3(分片上传挂载 + 分集收费与试看)

为什么:创作者传完片子得把产物挂到分集上才能播,而这条链路之前有两个洞:
uploadId 是"凭证",谁拿到都能往别人的会话里塞分片 / 合并;产物挂载也没有归属校验,
拿到别人的 assetId 就能把自己的剧指向别人的素材。定价这边则缺创作者入口与试看秒数配置。

- 上传复用现有 /admin/media/upload/{init,chunk,complete},不重写;新增 GET /admin/media/upload/{uploadId}
  做断点续传回显(receivedChunks/totalChunks/percent/status,完成后带 url)
- 上传会话归属校验:chunk / complete / 进度查询都要求会话发起人,别人拿到 uploadId 也操作不了(403)
- 新增 attach 接口:产物必须① 是本次登录的人上传的(media_asset.create_by)② bizType=EPISODE 且状态 ready/uploaded
  ③ 分集属于自己这部剧且作品可编辑;挂载时把产物时长补到分集上
- 收费配置复用 PriceService:新增创作者侧 GET/PUT /admin/creator-center/dramas/{id}/prices;
  PriceSaveReqVO/PriceRespVO 补 previewSeconds(落在已有的 drama_episode.preview_seconds,不新造一套)
- 定价沿用"可编辑状态"口径:draft/offline 可改,已上线要先申请下线;dramaId 以路径为准,别人的 episodeId 被忽略
- 权限点 creator-center:upload / creator-center:price:update;创作者角色同时获得 cover:upload / episode:upload(复用上传接口)

实测:未登录请求付费集返回 {"accessType":"COIN_REQUIRED","unlockCoin":50,"previewSeconds":15},
免费集直接拿到签名播放地址 —— 创作者改的价格确实走到了 C 端付费判定。
This commit is contained in:
Vicky committed 2026-10-15 14:12:10 +08:00
1 parent 255bcbf02b
commit 8b8ebf944f
12 files changed
+269 -10

No files matched your search

+33 -2
View File
@@ -229,6 +229,18 @@ POST /app/auth/register
新增字段:短剧 `rating`(内容分级)、`regionCodes`(可播区域)、版权窗口、
`creatorId`、`tags`、`trailerUrl`;剧集 `transcodeStatus`、`hlsUrl`、`previewSeconds`、字幕。
媒资上传补充(批 3 加固,后台与创作者共用同一套接口,未重写):
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| POST | `/admin/media/upload/init` | 建上传会话(返回 `uploadId` / `chunkSize` / `totalChunks` / `objectKey`) |
| POST | `/admin/media/upload/chunk` | 传分片(`uploadId` + `index` + `file`),可重复传同一片 |
| GET | `/admin/media/upload/{uploadId}` | **新增**:断点续传回显(`receivedChunks` / `totalChunks` / `percent` / `status` / 完成后带 `url`) |
| POST | `/admin/media/upload/complete` | 合并分片出媒资(幂等:同一 uploadId 重复调用返回同一产物) |
**上传会话归属**:`uploadId` 相当于上传凭证,`chunk` / `complete` / 进度查询都校验会话发起人,
别人拿到 `uploadId` 也塞不进分片(`{"code":403,"message":"无权操作该上传会话"}`)。
### 2.3 App 用户管理(已实现)
| 方法 | 路径 | 权限点 | 说明 |
@@ -354,7 +366,7 @@ POST /app/auth/register
菜单、权限点与超管授权由 `sql/data-phase1.sql` 幂等落库(内容治理 / 订单与权益 / 数据看板挂菜单 id 20–25,创作者两条为 17–19)。
页面只读态与可操作态按权限点区分:没有 `*:update` / `*:audit` 的角色进入页面时按钮隐藏或显示「只读」。
### 2.10 创作者中心(批 1 + 批 2 已实现:工作台 / 内容编辑与提交)
### 2.10 创作者中心(批 1 ~ 批 3 已实现:工作台 / 内容编辑 / 上传挂载 / 收费配置)
创作者 Web 端做在后台管理里(`ReelShortWeb`),身份复用 `sys_admin_user`:
`creator.admin_user_id` 绑定后台账号(部分唯一索引,0 = 未绑定),角色 `creator` 只拿 `creator-center:*` 权限点。
@@ -405,7 +417,26 @@ POST /app/auth/register
后台审核 `PUBLISH` 通过时,该剧仍处于 `draft` 的分集**一并上线**(否则会出现"剧上线了却一集都播不了");
后台**驳回**或创作者**撤回**时,处于 `reviewing` 的剧退回 `draft`,避免创作者卡在只读状态。
**进度**:批 3(分片上传、分集收费配置)、批 4(创作者数据看板)、批 5(前端页面)未开始。
**上传挂载与收费配置(批 3)**
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| POST | `/admin/creator-center/dramas/{id}/episodes/{episodeId}/attach` | `creator-center:upload` | 把已上传的媒资挂到自己的分集(写 `video_asset_id` / `video_url`,时长为 0 时用产物时长补齐) |
| GET | `/admin/creator-center/dramas/{id}/prices` | `creator-center:view` | 读整剧免费集数 + 逐集免费 / 收费 / 试看秒数 |
| PUT | `/admin/creator-center/dramas/{id}/prices` | `creator-center:price:update` | 保存收费与试看秒数,复用后台同一套 `PriceService` |
**上传走已有的 `/admin/media/upload/{init,chunk,complete}`**(创作者角色直接持 `cover:upload` / `episode:upload`,
不新造一套上传接口);挂载时三道校验:分集属于自己这部剧 → 作品可编辑 → **产物是本次登录的人自己上传的**
(`media_asset.create_by` 必须等于当前账号,否则 `{"code":403,"message":"无权使用该上传产物"}`)。
只接受 `bizType=EPISODE` 且状态为 `ready` / `uploaded` 的产物。
**收费配置**:`previewSeconds` 落在 `drama_episode.preview_seconds`(0 = 不限制),
与 `is_free` / `unlock_coin` 一起就是播放与下载付费判定读的字段,**改完立即生效**(实测:未登录请求付费集返回
`{"accessType":"COIN_REQUIRED","unlockCoin":50,"previewSeconds":15}`;免费集直接返回签名播放地址)。
定价沿用「可编辑状态」口径:`draft` / `offline` 可改,已上线要改价需先申请下线(避免创作者悄悄改已上线内容的价格);
`dramaId` 以路径为准,body 里传别人的剧 id 无效,列表里带别人的 `episodeId` 会被忽略(只作用于自己剧的分集)。
**进度**:批 4(创作者数据看板)、批 5(前端页面)未开始。
## 3. 契约与实现状态对照
@@ -3,8 +3,11 @@ package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.content.vo.EpisodeRespVO;
import com.fruitshorts.content.vo.EpisodeSortReqVO;
import com.fruitshorts.content.vo.PriceRespVO;
import com.fruitshorts.content.vo.PriceSaveReqVO;
import com.fruitshorts.creator.dal.entity.ContentSubmission;
import com.fruitshorts.creator.service.CreatorContentService;
import com.fruitshorts.creator.vo.CreatorAttachReqVO;
import com.fruitshorts.creator.vo.CreatorDramaEditReqVO;
import com.fruitshorts.creator.vo.CreatorEpisodeReqVO;
import com.fruitshorts.creator.vo.CreatorSubmitReqVO;
@@ -118,4 +121,29 @@ public class CreatorContentController {
public Result<List<ContentSubmission>> listSubmissions(@PathVariable Long id) {
return Result.ok(creatorContentService.listSubmissions(id));
}
/** 把上传好的视频产物挂到自己的分集上(产物必须是本人上传的)。 */
@PostMapping("/dramas/{id}/episodes/{episodeId}/attach")
@PreAuthorize("@ss.hasPermission('creator-center:upload')")
@OperationLog(module = "创作者中心", action = "挂载分集视频")
public Result<Void> attachAsset(@PathVariable Long id, @PathVariable Long episodeId,
@Valid @RequestBody CreatorAttachReqVO req) {
creatorContentService.attachAsset(id, episodeId, req.getAssetId());
return Result.ok();
}
/** 分集收费与试看秒数:读现状。 */
@GetMapping("/dramas/{id}/prices")
@PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<PriceRespVO> priceDetail(@PathVariable Long id) {
return Result.ok(creatorContentService.priceDetail(id));
}
/** 分集收费与试看秒数:保存(复用后台同一套 PriceService,改完立即影响付费判定)。 */
@PutMapping("/dramas/{id}/prices")
@PreAuthorize("@ss.hasPermission('creator-center:price:update')")
@OperationLog(module = "创作者中心", action = "配置分集价格")
public Result<PriceRespVO> savePrice(@PathVariable Long id, @RequestBody PriceSaveReqVO req) {
return Result.ok(creatorContentService.savePrice(id, req));
}
}
@@ -7,6 +7,7 @@ import com.fruitshorts.media.vo.MediaAssetRespVO;
import com.fruitshorts.media.vo.UploadCompleteReqVO;
import com.fruitshorts.media.vo.UploadInitReqVO;
import com.fruitshorts.media.vo.UploadInitRespVO;
import com.fruitshorts.media.vo.UploadProgressRespVO;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -40,14 +41,21 @@ public class MediaController {
public Result<Integer> uploadChunk(@RequestParam String uploadId,
@RequestParam int index,
@RequestParam("file") MultipartFile file) {
mediaService.saveChunk(uploadId, index, file);
mediaService.saveChunk(uploadId, index, file, SecurityUtils.getUserId());
return Result.ok(index);
}
@PostMapping("/upload/complete")
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
public Result<MediaAssetRespVO> complete(@Valid @RequestBody UploadCompleteReqVO req) {
return Result.ok(mediaService.complete(req));
return Result.ok(mediaService.complete(req, SecurityUtils.getUserId()));
}
/** 断点续传:查上传会话进度(只能查自己发起的会话)。 */
@GetMapping("/upload/{uploadId}")
@PreAuthorize("@ss.hasAnyPermission('cover:upload','episode:upload')")
public Result<UploadProgressRespVO> progress(@PathVariable String uploadId) {
return Result.ok(mediaService.progress(uploadId, SecurityUtils.getUserId()));
}
@GetMapping("/{id}")
@@ -50,6 +50,7 @@ public class PriceService {
item.setTitle(episode.getTitle());
item.setFree(episode.getIsFree() != null && episode.getIsFree() == 1);
item.setUnlockCoin(episode.getUnlockCoin());
item.setPreviewSeconds(episode.getPreviewSeconds() == null ? 0 : episode.getPreviewSeconds());
return item;
}).toList());
return vo;
@@ -89,6 +90,7 @@ public class PriceService {
int coin = isFree == 1 ? 0 : unlockCoin;
PriceSaveReqVO.EpisodePrice override = overrides.get(episode.getId());
Integer previewSeconds = null;
if (override != null) {
if (override.getIsFree() != null) {
isFree = override.getIsFree() == 1 ? 1 : 0;
@@ -97,11 +99,21 @@ public class PriceService {
if (isFree == 0 && override.getUnlockCoin() != null) {
coin = Math.max(0, override.getUnlockCoin());
}
if (override.getPreviewSeconds() != null) {
previewSeconds = Math.max(0, override.getPreviewSeconds());
}
}
if (!Objects.equals(episode.getIsFree(), isFree) || !Objects.equals(episode.getUnlockCoin(), coin)) {
boolean priceChanged = !Objects.equals(episode.getIsFree(), isFree)
|| !Objects.equals(episode.getUnlockCoin(), coin);
boolean previewChanged = previewSeconds != null
&& !Objects.equals(episode.getPreviewSeconds(), previewSeconds);
if (priceChanged || previewChanged) {
episode.setIsFree(isFree);
episode.setUnlockCoin(coin);
if (previewSeconds != null) {
episode.setPreviewSeconds(previewSeconds);
}
dramaEpisodeMapper.updateById(episode);
changed++;
}
@@ -120,6 +132,7 @@ public class PriceService {
item.setTitle(episode.getTitle());
item.setFree(episode.getIsFree() != null && episode.getIsFree() == 1);
item.setUnlockCoin(episode.getUnlockCoin());
item.setPreviewSeconds(episode.getPreviewSeconds() == null ? 0 : episode.getPreviewSeconds());
return item;
}).toList());
return vo;
@@ -25,5 +25,7 @@ public class PriceRespVO {
private String title;
private Boolean free;
private Integer unlockCoin;
/** 试看秒数,0 = 不限制 */
private Integer previewSeconds;
}
}
@@ -22,5 +22,7 @@ public class PriceSaveReqVO {
/** 1 免费 / 0 收费 */
private Integer isFree;
private Integer unlockCoin;
/** 试看秒数:0 = 不限制(复用 drama_episode.preview_seconds,播放接口已经在下发这个字段) */
private Integer previewSeconds;
}
}
+5
View File
@@ -27,5 +27,10 @@
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-user</artifactId>
</dependency>
<!-- 创作者把上传产物挂到自己的分集,要读媒资库;media 只依赖 framework,方向不会成环 -->
<dependency>
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-media</artifactId>
</dependency>
</dependencies>
</project>
@@ -12,10 +12,16 @@ import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.content.vo.DramaSaveReqVO;
import com.fruitshorts.content.vo.EpisodeRespVO;
import com.fruitshorts.content.vo.EpisodeSaveReqVO;
import com.fruitshorts.content.vo.PriceRespVO;
import com.fruitshorts.content.vo.PriceSaveReqVO;
import com.fruitshorts.content.service.PriceService;
import com.fruitshorts.creator.dal.entity.ContentSubmission;
import com.fruitshorts.creator.dal.entity.Creator;
import com.fruitshorts.creator.vo.CreatorDramaEditReqVO;
import com.fruitshorts.creator.vo.CreatorEpisodeReqVO;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.media.dal.entity.MediaAsset;
import com.fruitshorts.media.dal.mapper.MediaAssetMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -38,7 +44,9 @@ public class CreatorContentService {
private final ContentSubmissionService submissionService;
private final DramaService dramaService;
private final EpisodeService episodeService;
private final PriceService priceService;
private final DramaEpisodeMapper episodeMapper;
private final MediaAssetMapper mediaAssetMapper;
/** 新建草稿:归属写自己,状态固定 draft(是否上线由后续提交审核决定)。 */
@Transactional(rollbackFor = Exception.class)
@@ -155,6 +163,66 @@ public class CreatorContentService {
return submissionService.listByDrama(creator.getId(), dramaId);
}
/**
* 把上传好的媒资挂到分集上(视频源)。
*
* 三道闸:① 分集必须属于自己这部剧(requireMyDrama + requireMyEpisode);
* ② 作品得是可编辑状态;③ **产物必须是这次登录的人自己上传的** ——
* 否则拿到别人的 assetId 就能把别人的素材挂到自己的剧上(或反过来污染别人的剧)。
*/
@Transactional(rollbackFor = Exception.class)
public void attachAsset(Long dramaId, Long episodeId, Long assetId) {
Drama drama = centerService.requireMyDrama(dramaId);
requireEditable(drama);
DramaEpisode episode = requireMyEpisode(dramaId, episodeId);
MediaAsset asset = assetId == null ? null : mediaAssetMapper.selectById(assetId);
if (asset == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "媒资不存在");
}
long owner = asset.getCreateBy() == null ? 0L : asset.getCreateBy();
long current = SecurityUtils.getUserId() == null ? -1L : SecurityUtils.getUserId();
if (owner != current) {
throw new ServiceException(ErrorCode.FORBIDDEN, "无权使用该上传产物");
}
if (!"EPISODE".equals(asset.getBizType())) {
throw new ServiceException(ErrorCode.BAD_REQUEST,
"只能把视频产物(bizType=EPISODE)挂到分集,当前是 " + asset.getBizType());
}
String assetStatus = asset.getStatus() == null ? "" : asset.getStatus();
if (!"ready".equals(assetStatus) && !"uploaded".equals(assetStatus)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "产物尚未就绪,当前状态:" + assetStatus);
}
episode.setVideoAssetId(asset.getId());
episode.setVideoUrl(asset.getMp4Url() == null ? "" : asset.getMp4Url());
if ((episode.getDurationSec() == null || episode.getDurationSec() == 0)
&& asset.getDurationSec() != null) {
episode.setDurationSec(asset.getDurationSec());
}
episodeMapper.updateById(episode);
}
public PriceRespVO priceDetail(Long dramaId) {
centerService.requireMyDrama(dramaId);
return priceService.detail(dramaId);
}
/**
* 保存分集收费与试看秒数:复用后台同一套 PriceService(改完立即影响播放/下载的付费判定)。
*
* 两点约束:dramaId 以路径为准(body 里传别人的 id 无效);作品必须处于可编辑状态 ——
* 让创作者改已上线内容的定价风险太大,要改价先申请下线。这条口径写进 docs/api-phase1.md,
* 需要放开的话改一行就行。
*/
@Transactional(rollbackFor = Exception.class)
public PriceRespVO savePrice(Long dramaId, PriceSaveReqVO req) {
Drama drama = centerService.requireMyDrama(dramaId);
requireEditable(drama);
req.setDramaId(drama.getId());
return priceService.save(req);
}
/** 分集归属:分集必须属于这部剧,否则 403(防止拿自己的剧 id 配别人的分集 id)。 */
private DramaEpisode requireMyEpisode(Long dramaId, Long episodeId) {
DramaEpisode episode = episodeId == null ? null : episodeMapper.selectById(episodeId);
@@ -0,0 +1,12 @@
package com.fruitshorts.creator.vo;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/** 创作者中心 · 把已上传的媒资挂到自己的分集上。 */
@Data
public class CreatorAttachReqVO {
@NotNull(message = "缺少媒资 ID")
private Long assetId;
}
@@ -12,6 +12,7 @@ import com.fruitshorts.media.vo.MediaAssetRespVO;
import com.fruitshorts.media.vo.UploadCompleteReqVO;
import com.fruitshorts.media.vo.UploadInitReqVO;
import com.fruitshorts.media.vo.UploadInitRespVO;
import com.fruitshorts.media.vo.UploadProgressRespVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -63,8 +64,15 @@ public class MediaService {
return new UploadInitRespVO(task.getUploadId(), chunkSize, totalChunks, task.getObjectKey());
}
public void saveChunk(String uploadId, int index, MultipartFile file) {
/**
* 续传分片。
*
* uploadId 相当于一张"上传凭证",谁拿到都能往里塞分片,所以这里必须校验会话归属:
* 只有发起 init 的那个人能继续传自己的会话。
*/
public void saveChunk(String uploadId, int index, MultipartFile file, Long userId) {
MediaUploadTask task = getTask(uploadId);
requireTaskOwner(task, userId);
if (index < 0 || index >= task.getTotalChunks()) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "分片序号越界: " + index);
}
@@ -77,8 +85,9 @@ public class MediaService {
}
@Transactional(rollbackFor = Exception.class)
public MediaAssetRespVO complete(UploadCompleteReqVO req) {
public MediaAssetRespVO complete(UploadCompleteReqVO req, Long userId) {
MediaUploadTask task = getTask(req.getUploadId());
requireTaskOwner(task, userId);
if (TASK_COMPLETED.equals(task.getStatus()) && task.getAssetId() > 0) {
return get(task.getAssetId());
}
@@ -128,6 +137,46 @@ public class MediaService {
return toResp(asset);
}
/** 断点续传用:回显传到第几片、还剩多少;会话归属同样校验。 */
public UploadProgressRespVO progress(String uploadId, Long userId) {
MediaUploadTask task = getTask(uploadId);
requireTaskOwner(task, userId);
UploadProgressRespVO vo = new UploadProgressRespVO();
vo.setUploadId(task.getUploadId());
vo.setFileName(task.getFileName());
vo.setBizType(task.getBizType());
vo.setFileSize(task.getFileSize());
vo.setChunkSize(task.getChunkSize());
vo.setTotalChunks(task.getTotalChunks());
int received = task.getReceivedChunks() == null ? 0 : task.getReceivedChunks();
vo.setReceivedChunks(received);
int total = task.getTotalChunks() == null || task.getTotalChunks() == 0 ? 1 : task.getTotalChunks();
vo.setPercent(Math.min(100, received * 100 / total));
vo.setStatus(task.getStatus());
vo.setAssetId(task.getAssetId() == null ? 0L : task.getAssetId());
if (task.getAssetId() != null && task.getAssetId() > 0
&& TASK_COMPLETED.equals(task.getStatus())) {
MediaAsset asset = mediaAssetMapper.selectById(task.getAssetId());
vo.setUrl(asset == null ? "" : asset.getMp4Url());
} else {
vo.setUrl("");
}
return vo;
}
/**
* 上传会话归属:只有发起人自己能续传 / 合并 / 查进度。
* 不区分是后台管理员还是创作者 —— 凭证归谁就是谁的。
*/
private void requireTaskOwner(MediaUploadTask task, Long userId) {
long owner = task.getCreateBy() == null ? 0L : task.getCreateBy();
long current = userId == null ? 0L : userId;
if (owner != current) {
throw new ServiceException(ErrorCode.FORBIDDEN, "无权操作该上传会话");
}
}
private MediaUploadTask getTask(String uploadId) {
MediaUploadTask task = mediaUploadTaskMapper.selectOne(
Wrappers.<MediaUploadTask>lambdaQuery().eq(MediaUploadTask::getUploadId, uploadId));
@@ -0,0 +1,27 @@
package com.fruitshorts.media.vo;
import lombok.Data;
/**
* 分片上传进度:断点续传时客户端拿它恢复"传到第几片、还剩多少"。
*
* percent 由服务端算好,避免两端各算一遍口径不一致。
*/
@Data
public class UploadProgressRespVO {
private String uploadId;
private String fileName;
private String bizType;
private Long fileSize;
private Integer chunkSize;
private Integer totalChunks;
private Integer receivedChunks;
/** 0-100,按已收分片 / 总分片计算 */
private Integer percent;
/** uploading / completed */
private String status;
/** 合并完成后才有 */
private Long assetId;
private String url;
}
+17 -3
View File
@@ -179,7 +179,9 @@ ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, code = EXCLUDED.code,
-- 权限点按批补:批 1 只有「进创作者中心」这一个,后面各批用到什么再幂等追加
INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES
('creator-center:view', '访问创作者中心', '创作者中心', 'BUTTON', 150),
('creator-center:drama:edit', '创作者中心-编辑作品', '创作者中心', 'BUTTON', 151)
('creator-center:drama:edit', '创作者中心-编辑作品', '创作者中心', 'BUTTON', 151),
('creator-center:upload', '创作者中心-上传与挂载', '创作者中心', 'BUTTON', 152),
('creator-center:price:update', '创作者中心-配置分集价格', '创作者中心', 'BUTTON', 153)
ON CONFLICT (code) DO NOTHING;
-- 创作者角色只拿创作者中心这几个点,不给任何后台运营权限
@@ -188,7 +190,8 @@ SELECT r.id, p.id
FROM sys_role r
CROSS JOIN sys_permission p
WHERE r.code = 'creator'
AND p.code IN ('creator-center:view', 'creator-center:drama:edit')
AND p.code IN ('creator-center:view', 'creator-center:drama:edit',
'creator-center:upload', 'creator-center:price:update')
ON CONFLICT DO NOTHING;
-- 超管也留一份,方便排查创作者视角的问题(超管没有绑定创作者主体,进 creator-center 仍会被挡)
@@ -197,5 +200,16 @@ SELECT r.id, p.id
FROM sys_role r
CROSS JOIN sys_permission p
WHERE r.code = 'super_admin'
AND p.code IN ('creator-center:view', 'creator-center:drama:edit')
AND p.code IN ('creator-center:view', 'creator-center:drama:edit',
'creator-center:upload', 'creator-center:price:update')
ON CONFLICT DO NOTHING;
-- 媒资分片上传是复用后台已有的 /admin/media/upload/*,所以创作者角色要用现成的上传权限点,
-- 不再造一套 creator-center 的上传接口(上传会话归属与产物归属另在服务层校验)。
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 = 'creator'
AND p.code IN ('cover:upload', 'episode:upload')
ON CONFLICT DO NOTHING;