From 8b8ebf944f1b07c213d3d195ee62a7bcae9ba064 Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Thu, 15 Oct 2026 14:12:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=2007=20=E5=88=9B=E4=BD=9C=E8=80=85?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E6=89=B9=203=EF=BC=88=E5=88=86=E7=89=87?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E6=8C=82=E8=BD=BD=20+=20=E5=88=86=E9=9B=86?= =?UTF-8?q?=E6=94=B6=E8=B4=B9=E4=B8=8E=E8=AF=95=E7=9C=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:创作者传完片子得把产物挂到分集上才能播,而这条链路之前有两个洞: 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 端付费判定。 --- docs/api-phase1.md | 35 +++++++++- .../controller/CreatorContentController.java | 28 ++++++++ .../admin/controller/MediaController.java | 12 +++- .../content/service/PriceService.java | 15 +++- .../fruitshorts/content/vo/PriceRespVO.java | 2 + .../content/vo/PriceSaveReqVO.java | 2 + fruitshorts-module-creator/pom.xml | 5 ++ .../service/CreatorContentService.java | 68 +++++++++++++++++++ .../creator/vo/CreatorAttachReqVO.java | 12 ++++ .../media/service/MediaService.java | 53 ++++++++++++++- .../media/vo/UploadProgressRespVO.java | 27 ++++++++ sql/data-phase1.sql | 20 +++++- 12 files changed, 269 insertions(+), 10 deletions(-) create mode 100644 fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorAttachReqVO.java create mode 100644 fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/UploadProgressRespVO.java diff --git a/docs/api-phase1.md b/docs/api-phase1.md index 927c514..3036a5d 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -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. 契约与实现状态对照 diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CreatorContentController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CreatorContentController.java index c488b95..4bb646a 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CreatorContentController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CreatorContentController.java @@ -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> 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 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 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 savePrice(@PathVariable Long id, @RequestBody PriceSaveReqVO req) { + return Result.ok(creatorContentService.savePrice(id, req)); + } } 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 1ddc1f6..3fe272e 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 @@ -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 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 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 progress(@PathVariable String uploadId) { + return Result.ok(mediaService.progress(uploadId, SecurityUtils.getUserId())); } @GetMapping("/{id}") 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 index ec33d1f..bbd1e90 100644 --- 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 @@ -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; 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 index 758d2e5..63b92eb 100644 --- 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 @@ -25,5 +25,7 @@ public class PriceRespVO { private String title; private Boolean free; private Integer unlockCoin; + /** 试看秒数,0 = 不限制 */ + private Integer previewSeconds; } } 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 index 6e3fda9..774fc7c 100644 --- 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 @@ -22,5 +22,7 @@ public class PriceSaveReqVO { /** 1 免费 / 0 收费 */ private Integer isFree; private Integer unlockCoin; + /** 试看秒数:0 = 不限制(复用 drama_episode.preview_seconds,播放接口已经在下发这个字段) */ + private Integer previewSeconds; } } diff --git a/fruitshorts-module-creator/pom.xml b/fruitshorts-module-creator/pom.xml index 482e19f..ce156a1 100644 --- a/fruitshorts-module-creator/pom.xml +++ b/fruitshorts-module-creator/pom.xml @@ -27,5 +27,10 @@ com.fruitshorts fruitshorts-module-user + + + com.fruitshorts + fruitshorts-module-media + 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 d8e0a75..6b7bf26 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 @@ -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); diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorAttachReqVO.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorAttachReqVO.java new file mode 100644 index 0000000..9019a8f --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorAttachReqVO.java @@ -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; +} diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaService.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaService.java index bac0e13..925cffd 100644 --- a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaService.java +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaService.java @@ -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.lambdaQuery().eq(MediaUploadTask::getUploadId, uploadId)); diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/UploadProgressRespVO.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/UploadProgressRespVO.java new file mode 100644 index 0000000..325d9f0 --- /dev/null +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/UploadProgressRespVO.java @@ -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; +} diff --git a/sql/data-phase1.sql b/sql/data-phase1.sql index d6849fe..071986c 100644 --- a/sql/data-phase1.sql +++ b/sql/data-phase1.sql @@ -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;