Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3df66c2f4c | ||
|
|
32ada4c2bf | ||
|
|
eba7d400e1 | ||
|
|
3faeba0589 |
No files matched your search
+5
-2
@@ -25,12 +25,15 @@ function Write-Log($msg) {
|
||||
|
||||
function Send-Alert($title, $msg) {
|
||||
if (-not $FeishuWebhook) { return }
|
||||
$body = @{
|
||||
$json = @{
|
||||
msg_type = "text"
|
||||
content = @{ text = "[$title] $msg" }
|
||||
} | ConvertTo-Json -Depth 3
|
||||
try {
|
||||
Invoke-RestMethod -Uri $FeishuWebhook -Method Post -Body $body -ContentType "application/json" -TimeoutSec 10
|
||||
# PS5 的 Invoke-RestMethod 对字符串 body 默认按 Latin-1 编码,中文会变成 ???,
|
||||
# 必须手动转 UTF-8 字节数组发送
|
||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
|
||||
Invoke-RestMethod -Uri $FeishuWebhook -Method Post -Body $bytes -ContentType "application/json; charset=utf-8" -TimeoutSec 10
|
||||
} catch {
|
||||
Write-Log "飞书告警发送失败: $_"
|
||||
}
|
||||
|
||||
+5
-1
@@ -78,6 +78,9 @@ services:
|
||||
# 限流/缓存用 Redis;容器内 127.0.0.1 是容器自身,必须指向编排里的 redis
|
||||
REDIS_HOST: ${REDIS_HOST:-redis}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
# CORS:公网入口 nginx 把 SSL 跑在 80 端口上(腾讯云安全组只放 80),
|
||||
# 浏览器 fetch 带 Origin 时端口不匹配会被 Spring CORS 拒(必须精确字符串匹配)。
|
||||
CORS_ALLOWED_ORIGINS: "https://api.frostgiant.vip:80,https://api.frostgiant.vip,http://api.frostgiant.vip,https://39.162.50.99:8081,http://localhost:3005,http://127.0.0.1:3005"
|
||||
ports:
|
||||
- "8081:8081"
|
||||
volumes:
|
||||
@@ -108,7 +111,8 @@ services:
|
||||
SERVER_PORT: "8080"
|
||||
SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: "2GB"
|
||||
SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE: "2GB"
|
||||
CORS_ALLOWED_ORIGINS: "http://localhost:3005,http://127.0.0.1:3005,http://39.162.50.99:3005,http://192.168.31.250:3005"
|
||||
# 浏览后台时 Origin 是实际访问地址,必须精确列全:本机、局域网、公网 IP、域名。
|
||||
CORS_ALLOWED_ORIGINS: "http://localhost:3005,http://127.0.0.1:3005,http://39.162.50.99:3005,http://192.168.31.250:3005,https://api.frostgiant.vip,https://admin.frostgiant.vip"
|
||||
DB_URL: ${DB_URL:-jdbc:postgresql://db:5432/${DB_NAME:-fruitshorts}}
|
||||
# 管理端仅内网,页面上直接播放/下载裸媒资地址(不签播放签名),
|
||||
# 因此这里不启用 /files/episode/** 的签名校验;公开的 app-api 仍保持开启。
|
||||
|
||||
+9
@@ -113,6 +113,15 @@ public class DramaController {
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "审核通过并上线")
|
||||
@PostMapping("/{id}/approve-publish")
|
||||
@PreAuthorize("@ss.hasPermission('drama:review')")
|
||||
@OperationLog(module = "内容管理", action = "审核通过并上线")
|
||||
public Result<Void> approvePublish(@PathVariable Long id, @RequestBody(required = false) DramaStatusReqVO req) {
|
||||
changeStatus(id, "APPROVE_PUBLISH", req);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "上线")
|
||||
@PostMapping("/{id}/publish")
|
||||
@PreAuthorize("@ss.hasPermission('drama:review')")
|
||||
|
||||
+6
@@ -162,6 +162,12 @@ public class DramaService {
|
||||
requireState(current, "APPROVE", Set.of(ContentStatus.REVIEWING.getCode()));
|
||||
yield ContentStatus.APPROVED.getCode();
|
||||
}
|
||||
case "APPROVE_PUBLISH" -> {
|
||||
// 一步审核通过并上线,减少后台操作步骤
|
||||
requireState(current, "APPROVE_PUBLISH", Set.of(ContentStatus.DRAFT.getCode(),
|
||||
ContentStatus.REVIEWING.getCode(), ContentStatus.OFFLINE.getCode()));
|
||||
yield ContentStatus.PUBLISHED.getCode();
|
||||
}
|
||||
case "REJECT" -> {
|
||||
requireState(current, "REJECT", Set.of(ContentStatus.REVIEWING.getCode()));
|
||||
yield ContentStatus.DRAFT.getCode();
|
||||
|
||||
+42
@@ -4,11 +4,13 @@ package com.fruitshorts.content.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.content.dal.entity.Drama;
|
||||
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
|
||||
import com.fruitshorts.content.dal.mapper.DramaMapper;
|
||||
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
|
||||
import com.fruitshorts.content.enums.ContentStatus;
|
||||
import com.fruitshorts.content.vo.DramaSaveReqVO;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
@@ -25,6 +27,7 @@ import java.util.stream.Collectors;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.mock;
|
||||
@@ -159,6 +162,45 @@ class DramaServiceTest {
|
||||
verify(dramaEpisodeMapper, never()).updateById(any(DramaEpisode.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_publish_directly_from_draft_when_approve_publish() {
|
||||
Drama drama = givenDramaWithStatus(ContentStatus.DRAFT.getCode());
|
||||
|
||||
dramaService.changeStatus(DRAMA_ID, "APPROVE_PUBLISH", "", 1L, "admin");
|
||||
|
||||
assertThat(drama.getStatus()).isEqualTo(ContentStatus.PUBLISHED.getCode());
|
||||
assertThat(drama.getPublishedAt()).isNotNull();
|
||||
verify(dramaMapper).updateById(drama);
|
||||
// 上线时同步把草稿分集发布
|
||||
verify(dramaEpisodeMapper).update(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_publish_directly_from_reviewing_when_approve_publish() {
|
||||
Drama drama = givenDramaWithStatus(ContentStatus.REVIEWING.getCode());
|
||||
|
||||
dramaService.changeStatus(DRAMA_ID, "APPROVE_PUBLISH", "", 1L, "admin");
|
||||
|
||||
assertThat(drama.getStatus()).isEqualTo(ContentStatus.PUBLISHED.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_approve_publish_when_already_published() {
|
||||
givenDramaWithStatus(ContentStatus.PUBLISHED.getCode());
|
||||
|
||||
assertThatThrownBy(() -> dramaService.changeStatus(DRAMA_ID, "APPROVE_PUBLISH", "", 1L, "admin"))
|
||||
.isInstanceOf(ServiceException.class);
|
||||
}
|
||||
|
||||
private Drama givenDramaWithStatus(String status) {
|
||||
Drama drama = new Drama();
|
||||
drama.setId(DRAMA_ID);
|
||||
drama.setStatus(status);
|
||||
given(dramaMapper.selectById(DRAMA_ID)).willReturn(drama);
|
||||
given(dramaMapper.updateById(any(Drama.class))).willReturn(1);
|
||||
return drama;
|
||||
}
|
||||
|
||||
private void givenExistingDrama(int freeEpisodes, int unlockCoin) {
|
||||
Drama drama = new Drama();
|
||||
drama.setId(DRAMA_ID);
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ import org.springframework.util.StringUtils;
|
||||
public class EpisodeTranscodeBindingService {
|
||||
|
||||
private final MediaAssetMapper mediaAssetMapper;
|
||||
private final MediaStorageService storageService;
|
||||
private final MediaStorage storageService;
|
||||
private final TranscodeService transcodeService;
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -38,7 +38,7 @@ public class MediaService {
|
||||
Set.of(".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".avif");
|
||||
|
||||
private final MediaProperties properties;
|
||||
private final MediaStorageService storageService;
|
||||
private final MediaStorage storageService;
|
||||
private final MediaAssetMapper mediaAssetMapper;
|
||||
private final MediaUploadTaskMapper mediaUploadTaskMapper;
|
||||
private final TranscodeService transcodeService;
|
||||
@@ -104,7 +104,7 @@ public class MediaService {
|
||||
"分片未传完(" + chunks + "/" + task.getTotalChunks() + ")");
|
||||
}
|
||||
|
||||
MediaStorageService.MergeResult merged =
|
||||
MediaStorage.MergeResult merged =
|
||||
storageService.merge(req.getUploadId(), task.getObjectKey(), task.getTotalChunks());
|
||||
String publicUrl = storageService.toPublicUrl(task.getObjectKey());
|
||||
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.media.service;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* 媒资存储抽象:当前实现为本地磁盘({@link MediaStorageService}),
|
||||
* 后期新增 S3 实现后以配置切换,业务代码不感知。
|
||||
*
|
||||
* <p>S3 实现要点:分片走 multipart upload 或本地暂存后 putObject;
|
||||
* {@link #fileFor(String)} 改成「下载到临时目录 / 上传回对象存储」的适配(ffmpeg 需要本地文件);
|
||||
* {@link #toPublicUrl(String)} 返回 CDN / 对象存储域名地址。
|
||||
*/
|
||||
public interface MediaStorage {
|
||||
|
||||
/** 合并结果:落地路径、大小与 MD5。 */
|
||||
record MergeResult(Path path, long size, String md5) {
|
||||
}
|
||||
|
||||
/** 存储根目录(本地实现为磁盘目录;S3 实现为本地暂存目录)。 */
|
||||
Path rootDir();
|
||||
|
||||
/**
|
||||
* 把 objectKey 解析成本地可读写的路径。转码产物落地用它。
|
||||
* 实现方必须校验 objectKey 不允许跑出根目录。
|
||||
*/
|
||||
Path fileFor(String objectKey);
|
||||
|
||||
/** 把对外 URL 反解成 objectKey(去掉前缀与签名查询串)。 */
|
||||
String objectKeyOf(String publicUrl);
|
||||
|
||||
/** 分片临时目录。 */
|
||||
Path chunkDir(String uploadId);
|
||||
|
||||
/** 保存一个分片。 */
|
||||
void saveChunk(String uploadId, int index, MultipartFile file);
|
||||
|
||||
/** 已收到的分片数。 */
|
||||
int countChunks(String uploadId);
|
||||
|
||||
/** 按分片序号顺序合并成正式文件,返回大小与 MD5。 */
|
||||
MergeResult merge(String uploadId, String objectKey, int totalChunks);
|
||||
|
||||
/** 清理分片临时目录。 */
|
||||
void cleanChunkDir(String uploadId);
|
||||
|
||||
/** 删除一个媒资文件。 */
|
||||
void delete(String objectKey);
|
||||
|
||||
/** 生成对象键:bizType/yyyyMM/uuid.ext。 */
|
||||
String buildObjectKey(String bizType, String originalName);
|
||||
|
||||
/** objectKey 转对外访问 URL。 */
|
||||
String toPublicUrl(String objectKey);
|
||||
}
|
||||
+2
-5
@@ -28,20 +28,17 @@ import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 本地磁盘媒资存储:分片写临时目录,完成后合并成正式文件。
|
||||
* 之后要换成对象存储时,只需替换本类实现。
|
||||
* 是 {@link MediaStorage} 的本地实现;切对象存储时新增实现类即可,业务代码只依赖接口。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MediaStorageService {
|
||||
public class MediaStorageService implements MediaStorage {
|
||||
|
||||
private static final DateTimeFormatter MONTH_DIR = DateTimeFormatter.ofPattern("yyyyMM");
|
||||
|
||||
private final MediaProperties properties;
|
||||
|
||||
public record MergeResult(Path path, long size, String md5) {
|
||||
}
|
||||
|
||||
public Path rootDir() {
|
||||
Path root = Paths.get(properties.getRootDir()).toAbsolutePath().normalize();
|
||||
try {
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ public class MediaTranscodeOutputService {
|
||||
|
||||
private final MediaAssetMapper mediaAssetMapper;
|
||||
private final TranscodeService transcodeService;
|
||||
private final MediaStorageService storageService;
|
||||
private final MediaStorage storageService;
|
||||
private final PlaybackSigner signer;
|
||||
|
||||
public List<MediaRenditionRespVO> renditions(Long mediaAssetId) {
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ import java.util.Map;
|
||||
public class ThumbnailUrlResolver {
|
||||
|
||||
private final TranscodeService transcodeService;
|
||||
private final MediaStorageService storageService;
|
||||
private final MediaStorage storageService;
|
||||
private final PlaybackSigner signer;
|
||||
|
||||
/** 单集播放使用。 */
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ import com.fruitshorts.media.config.MediaTranscodeProfile;
|
||||
import com.fruitshorts.media.dal.entity.MediaThumbnail;
|
||||
import com.fruitshorts.media.dal.entity.TranscodeTask;
|
||||
import com.fruitshorts.media.service.MediaService;
|
||||
import com.fruitshorts.media.service.MediaStorageService;
|
||||
import com.fruitshorts.media.service.MediaStorage;
|
||||
import com.fruitshorts.media.service.MediaTranscodeProfileService;
|
||||
import com.fruitshorts.media.service.EpisodeTranscodeCompletionPort;
|
||||
import com.fruitshorts.media.service.TranscodeErrorTruncator;
|
||||
@@ -45,7 +45,7 @@ public class TranscodeWorker {
|
||||
|
||||
private final TranscodeService transcodeService;
|
||||
private final MediaService mediaService;
|
||||
private final MediaStorageService storageService;
|
||||
private final MediaStorage storageService;
|
||||
private final TranscodeRunner transcodeRunner;
|
||||
private final MediaTranscodeProfileService profileService;
|
||||
private final EpisodeTranscodeCompletionPort episodeCompletionPort;
|
||||
|
||||
+3
-2
@@ -8,6 +8,7 @@ import com.fruitshorts.media.dal.entity.MediaUploadTask;
|
||||
import com.fruitshorts.media.dal.mapper.MediaAssetMapper;
|
||||
import com.fruitshorts.media.dal.mapper.MediaUploadTaskMapper;
|
||||
import com.fruitshorts.media.service.MediaService;
|
||||
import com.fruitshorts.media.service.MediaStorage;
|
||||
import com.fruitshorts.media.service.MediaStorageService;
|
||||
import com.fruitshorts.media.service.EpisodeVideoSourcePort;
|
||||
import com.fruitshorts.media.service.TranscodeService;
|
||||
@@ -63,7 +64,7 @@ class MediaServiceTranscodePipelineTest {
|
||||
givenCompletedUpload("video.mp4");
|
||||
given(storageService.countChunks("upload-1")).willReturn(1);
|
||||
given(storageService.merge("upload-1", "episode/202610/video.mp4", 1))
|
||||
.willReturn(new MediaStorageService.MergeResult(
|
||||
.willReturn(new MediaStorage.MergeResult(
|
||||
Path.of("/tmp/video.mp4"), 1024L, "md5"));
|
||||
given(storageService.toPublicUrl("episode/202610/video.mp4"))
|
||||
.willReturn(SOURCE_URL);
|
||||
@@ -79,7 +80,7 @@ class MediaServiceTranscodePipelineTest {
|
||||
givenCompletedUpload("cover.jpg");
|
||||
given(storageService.countChunks("upload-1")).willReturn(1);
|
||||
given(storageService.merge("upload-1", "episode/202610/cover.jpg", 1))
|
||||
.willReturn(new MediaStorageService.MergeResult(
|
||||
.willReturn(new MediaStorage.MergeResult(
|
||||
Path.of("/tmp/cover.jpg"), 512L, "md5"));
|
||||
given(storageService.toPublicUrl("episode/202610/cover.jpg"))
|
||||
.willReturn("/files/episode/202610/cover.jpg");
|
||||
|
||||
Reference in new issue
Block a user