- 重构 TimelinePanel.vue 组件,使用 Tailwind CSS 替代 Less,简化样式代码 - 改进视觉设计:更新颜色方案、间距和图标,提升用户体验 - 移除音频结束位置标记,优化刻度尺和轨道显示逻辑 - 统一时长差异提示的样式和状态显示 feat(infra): 扩展文件预签名接口支持 Content-Type 参数 - 在 FileApi、FileClient、FileService 接口中新增带 Content-Type 参数的 presignGetUrl 方法 - 实现 S3FileClient 对 Content-Type 参数的支持,确保浏览器正确渲染媒体文件 - 在 TikUserFileServiceImpl 中为音视频文件生成预签名 URL 时自动推断 Content-Type - 支持公开访问和私有访问两种模式下的 Content-Type 参数传递
This commit is contained in:
@@ -1,94 +1,94 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="timeline-panel">
|
<div class="bg-[#fafbfc] border border-[#e5e7eb] rounded-md px-4 py-3 mt-2">
|
||||||
<div class="timeline-header">
|
<div class="flex justify-between items-center mb-2.5">
|
||||||
<span class="timeline-title">时间轴对比</span>
|
<span class="text-[13px] font-semibold text-gray-800">时间轴对比</span>
|
||||||
<span v-if="showDurations" class="duration-badge">
|
<span v-if="showDurations" class="flex items-center gap-1.5 text-[13px] text-gray-600 tabular-nums">
|
||||||
人脸 {{ formatDuration(faceDurationMs) }}
|
<span class="flex items-center gap-[5px]">
|
||||||
|
<span class="w-[7px] h-[7px] rounded-sm bg-blue-500"></span>
|
||||||
|
视频 {{ formatDuration(faceDurationMs) }}
|
||||||
|
</span>
|
||||||
<template v-if="audioDurationMs > 0">
|
<template v-if="audioDurationMs > 0">
|
||||||
<span class="divider"></span>
|
<span class="text-gray-400 text-xs">/</span>
|
||||||
音频 {{ formatDuration(audioDurationMs) }}
|
<span class="flex items-center gap-[5px]">
|
||||||
|
<span class="w-[7px] h-[7px] rounded-sm bg-emerald-500"></span>
|
||||||
|
音频 {{ formatDuration(audioDurationMs) }}
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 刻度尺 -->
|
<!-- 刻度尺 -->
|
||||||
<div class="timeline-ruler">
|
<div class="relative h-5 mb-1.5 ml-12">
|
||||||
<div
|
<div
|
||||||
v-for="mark in rulerMarks"
|
v-for="mark in rulerMarks"
|
||||||
:key="mark.time"
|
:key="mark.time"
|
||||||
class="ruler-mark"
|
class="absolute -translate-x-1/2 flex flex-col items-center"
|
||||||
:style="{ left: mark.position + '%' }"
|
:style="{ left: mark.position + '%' }"
|
||||||
>
|
>
|
||||||
<span class="ruler-label">{{ mark.label }}</span>
|
<span class="text-[11px] text-gray-400 leading-none mb-1">{{ mark.label }}</span>
|
||||||
<span class="ruler-tick"></span>
|
<span class="block w-px h-1 bg-[#e5e7eb]"></span>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 音频结束位置标记 -->
|
|
||||||
<div
|
|
||||||
v-if="audioDurationMs > 0 && isExceed"
|
|
||||||
class="audio-end-marker"
|
|
||||||
:style="{ left: audioEndPosition + '%' }"
|
|
||||||
>
|
|
||||||
<span class="audio-marker-label">{{ (audioDurationMs / 1000).toFixed(1) }}s</span>
|
|
||||||
<span class="audio-marker-line"></span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 轨道区域 -->
|
<!-- 轨道区域 -->
|
||||||
<div class="timeline-tracks">
|
<div class="flex flex-col gap-1.5">
|
||||||
<!-- 视频轨道 -->
|
<!-- 视频轨道 -->
|
||||||
<div class="track">
|
<div class="flex items-center gap-2">
|
||||||
<div class="track-info">
|
<div class="w-9 shrink-0">
|
||||||
<span class="track-icon">📹</span>
|
<span class="text-xs text-gray-600 font-medium">视频</span>
|
||||||
<span class="track-label">视频</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="track-bar">
|
<div class="flex-1 h-6 bg-[#f1f3f5] rounded relative overflow-hidden flex items-center">
|
||||||
<div
|
<div
|
||||||
class="track-fill video-fill"
|
class="h-full rounded flex items-center justify-center transition-[width] duration-300 min-w-[2px] bg-blue-500"
|
||||||
:style="{ width: videoBarWidth + '%' }"
|
:style="{ width: videoBarWidth + '%' }"
|
||||||
>
|
>
|
||||||
<span v-if="videoBarWidth > 15" class="track-time">{{ formatDuration(faceDurationMs) }}</span>
|
<span v-if="videoBarWidth > 20" class="text-xs text-white font-medium tracking-wide">{{ formatDuration(faceDurationMs) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 音频轨道 -->
|
<!-- 音频轨道 -->
|
||||||
<div class="track">
|
<div class="flex items-center gap-2">
|
||||||
<div class="track-info">
|
<div class="w-9 shrink-0">
|
||||||
<span class="track-icon">🎙️</span>
|
<span class="text-xs text-gray-600 font-medium">音频</span>
|
||||||
<span class="track-label">音频</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="track-bar">
|
<div class="flex-1 h-6 bg-[#f1f3f5] rounded relative overflow-hidden flex items-center">
|
||||||
<div
|
<div
|
||||||
v-if="audioDurationMs > 0"
|
v-if="audioDurationMs > 0"
|
||||||
class="track-fill audio-fill"
|
class="h-full rounded flex items-center justify-center transition-[width] duration-300 min-w-[2px]"
|
||||||
:class="{ 'audio-exceed': isExceed }"
|
:class="isExceed ? 'bg-red-500' : 'bg-emerald-500'"
|
||||||
:style="{ width: audioBarWidth + '%' }"
|
:style="{ width: audioBarWidth + '%' }"
|
||||||
>
|
>
|
||||||
<span v-if="audioBarWidth > 15" class="track-time">{{ formatDuration(audioDurationMs) }}</span>
|
<span v-if="audioBarWidth > 20" class="text-xs text-white font-medium tracking-wide">{{ formatDuration(audioDurationMs) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span v-else class="track-placeholder">等待生成音频</span>
|
<span v-else class="text-xs text-gray-400 pl-2.5">等待生成音频</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 校验错误提示 -->
|
<!-- 校验错误提示 -->
|
||||||
<div v-if="validationError" class="timeline-diff error">
|
<div v-if="validationError" class="flex items-center gap-1.5 mt-2.5 px-2.5 py-2 rounded text-xs font-medium text-red-500 bg-red-50 border border-red-200">
|
||||||
<Icon icon="lucide:x-circle" class="diff-icon" />
|
<Icon icon="lucide:x-circle" class="text-sm shrink-0" />
|
||||||
<span>{{ validationError }}</span>
|
<span>{{ validationError }}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 时长差异提示 -->
|
<!-- 时长差异提示 -->
|
||||||
<div v-else-if="audioDurationMs > 0" class="timeline-diff" :class="diffStatus">
|
<div v-else-if="audioDurationMs > 0" class="flex items-center gap-1.5 mt-2.5 px-2.5 py-2 rounded text-xs font-medium border border-transparent"
|
||||||
|
:class="{
|
||||||
|
'bg-[#f0fdf4] border-[#bbf7d0] text-emerald-600': diffStatus === 'match',
|
||||||
|
'bg-red-50 border-red-200 text-red-500': diffStatus === 'exceed',
|
||||||
|
'bg-amber-50 border-amber-200 text-amber-500': diffStatus === 'short',
|
||||||
|
}"
|
||||||
|
>
|
||||||
<template v-if="diffStatus === 'match'">
|
<template v-if="diffStatus === 'match'">
|
||||||
<Icon icon="lucide:check-circle" class="diff-icon" />
|
<Icon icon="lucide:check-circle" class="text-sm shrink-0" />
|
||||||
<span>时长匹配良好,可以生成</span>
|
<span>时长匹配良好,可以生成</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="diffStatus === 'exceed'">
|
<template v-else-if="diffStatus === 'exceed'">
|
||||||
<Icon icon="lucide:alert-circle" class="diff-icon" />
|
<Icon icon="lucide:alert-circle" class="text-sm shrink-0" />
|
||||||
<span>音频超出 {{ formatDuration(diffMs) }},建议缩短文案</span>
|
<span>音频超出 {{ formatDuration(diffMs) }},建议缩短文案</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="diffStatus === 'short'">
|
<template v-else-if="diffStatus === 'short'">
|
||||||
<Icon icon="lucide:info" class="diff-icon" />
|
<Icon icon="lucide:info" class="text-sm shrink-0" />
|
||||||
<span>音频较短,可适当增加文案</span>
|
<span>音频较短,可适当增加文案</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,10 +127,6 @@ const audioBarWidth = computed(() =>
|
|||||||
Math.min(100, (props.audioDurationMs / maxDuration.value) * 100)
|
Math.min(100, (props.audioDurationMs / maxDuration.value) * 100)
|
||||||
)
|
)
|
||||||
|
|
||||||
const audioEndPosition = computed(() =>
|
|
||||||
Math.min(100, (props.audioDurationMs / maxDuration.value) * 100)
|
|
||||||
)
|
|
||||||
|
|
||||||
const isExceed = computed(() => props.audioDurationMs > props.faceDurationMs)
|
const isExceed = computed(() => props.audioDurationMs > props.faceDurationMs)
|
||||||
|
|
||||||
const diffMs = computed(() => Math.abs(props.audioDurationMs - props.faceDurationMs))
|
const diffMs = computed(() => Math.abs(props.audioDurationMs - props.faceDurationMs))
|
||||||
@@ -178,235 +174,3 @@ function calculateInterval(duration: number): number {
|
|||||||
|
|
||||||
const formatDuration = formatDurationMs
|
const formatDuration = formatDurationMs
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
|
||||||
// 蓝紫主题配色 - 与主页面协调
|
|
||||||
@text-primary: #1e293b;
|
|
||||||
@text-secondary: #64748b;
|
|
||||||
@text-tertiary: #94a3b8;
|
|
||||||
@bg-subtle: rgba(59, 130, 246, 0.04);
|
|
||||||
@bg-hover: rgba(59, 130, 246, 0.08);
|
|
||||||
@border-light: rgba(59, 130, 246, 0.1);
|
|
||||||
@border-medium: rgba(59, 130, 246, 0.15);
|
|
||||||
@accent-blue: #3b82f6;
|
|
||||||
@accent-purple: #8b5cf6;
|
|
||||||
@accent-green: #10b981;
|
|
||||||
@accent-red: #ef4444;
|
|
||||||
@accent-orange: #f59e0b;
|
|
||||||
|
|
||||||
.timeline-panel {
|
|
||||||
background: @bg-subtle;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 14px 18px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-title {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: @text-secondary;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.duration-badge {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: @text-tertiary;
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
width: 4px;
|
|
||||||
height: 4px;
|
|
||||||
background: @border-medium;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刻度尺
|
|
||||||
.timeline-ruler {
|
|
||||||
position: relative;
|
|
||||||
height: 18px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
margin-left: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ruler-mark {
|
|
||||||
position: absolute;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ruler-label {
|
|
||||||
font-size: 10px;
|
|
||||||
color: @text-tertiary;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ruler-tick {
|
|
||||||
display: block;
|
|
||||||
width: 1px;
|
|
||||||
height: 4px;
|
|
||||||
background: @border-medium;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 音频结束位置标记
|
|
||||||
.audio-end-marker {
|
|
||||||
position: absolute;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 10;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-marker-label {
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: @accent-red;
|
|
||||||
background: rgba(235, 87, 87, 0.1);
|
|
||||||
padding: 1px 5px;
|
|
||||||
border-radius: 3px;
|
|
||||||
white-space: nowrap;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-marker-line {
|
|
||||||
width: 1px;
|
|
||||||
height: 6px;
|
|
||||||
background: @accent-red;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 轨道区域
|
|
||||||
.timeline-tracks {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
width: 68px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-icon {
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: @text-secondary;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-bar {
|
|
||||||
flex: 1;
|
|
||||||
height: 22px;
|
|
||||||
background: rgba(55, 53, 47, 0.06);
|
|
||||||
border-radius: 4px;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-fill {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-time {
|
|
||||||
font-size: 10px;
|
|
||||||
color: rgba(255, 255, 255, 0.95);
|
|
||||||
font-weight: 600;
|
|
||||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-placeholder {
|
|
||||||
font-size: 11px;
|
|
||||||
color: @text-tertiary;
|
|
||||||
padding-left: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-fill {
|
|
||||||
background: linear-gradient(90deg, @accent-blue 0%, @accent-purple 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-fill {
|
|
||||||
background: linear-gradient(90deg, @accent-green 0%, #059669 100%);
|
|
||||||
|
|
||||||
&.audio-exceed {
|
|
||||||
background: linear-gradient(90deg, @accent-red 0%, #dc2626 100%);
|
|
||||||
animation: pulse-warning 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse-warning {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.75; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 差异提示
|
|
||||||
.timeline-diff {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 12px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
|
|
||||||
.diff-icon {
|
|
||||||
font-size: 14px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.match {
|
|
||||||
background: rgba(16, 185, 129, 0.08);
|
|
||||||
color: @accent-green;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.exceed {
|
|
||||||
background: rgba(239, 68, 68, 0.08);
|
|
||||||
color: @accent-red;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.short {
|
|
||||||
background: rgba(245, 158, 11, 0.08);
|
|
||||||
color: @accent-orange;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.error {
|
|
||||||
background: rgba(239, 68, 68, 0.12);
|
|
||||||
color: @accent-red;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -52,6 +52,18 @@ public interface FileApi {
|
|||||||
String presignGetUrl(@NotEmpty(message = "URL 不能为空") String url,
|
String presignGetUrl(@NotEmpty(message = "URL 不能为空") String url,
|
||||||
Integer expirationSeconds);
|
Integer expirationSeconds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成文件预签名地址(带 Content-Type),用于读取
|
||||||
|
*
|
||||||
|
* @param url 完整的文件访问地址
|
||||||
|
* @param expirationSeconds 访问有效期,单位秒
|
||||||
|
* @param contentType 响应的 Content-Type,为 null 时不设置
|
||||||
|
* @return 文件预签名地址
|
||||||
|
*/
|
||||||
|
String presignGetUrl(@NotEmpty(message = "URL 不能为空") String url,
|
||||||
|
Integer expirationSeconds,
|
||||||
|
String contentType);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成文件预签名地址(带 OSS 处理参数),用于读取
|
* 生成文件预签名地址(带 OSS 处理参数),用于读取
|
||||||
* 用于阿里云 OSS 视频截帧等图片处理场景
|
* 用于阿里云 OSS 视频截帧等图片处理场景
|
||||||
|
|||||||
@@ -31,7 +31,12 @@ public class FileApiImpl implements FileApi {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String presignGetUrl(String url, Integer expirationSeconds) {
|
public String presignGetUrl(String url, Integer expirationSeconds) {
|
||||||
return fileService.presignGetUrl(url, expirationSeconds);
|
return presignGetUrl(url, expirationSeconds, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String presignGetUrl(String url, Integer expirationSeconds, String contentType) {
|
||||||
|
return fileService.presignGetUrl(url, expirationSeconds, contentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -71,6 +71,18 @@ public interface FileClient {
|
|||||||
* @return 文件预签名地址
|
* @return 文件预签名地址
|
||||||
*/
|
*/
|
||||||
default String presignGetUrl(String url, Integer expirationSeconds) {
|
default String presignGetUrl(String url, Integer expirationSeconds) {
|
||||||
|
return presignGetUrl(url, expirationSeconds, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成文件预签名地址(带 Content-Type),用于读取
|
||||||
|
*
|
||||||
|
* @param url 完整的文件访问地址
|
||||||
|
* @param expirationSeconds 访问有效期,单位秒
|
||||||
|
* @param contentType 响应的 Content-Type,为 null 时不设置
|
||||||
|
* @return 文件预签名地址
|
||||||
|
*/
|
||||||
|
default String presignGetUrl(String url, Integer expirationSeconds, String contentType) {
|
||||||
throw new UnsupportedOperationException("不支持的操作");
|
throw new UnsupportedOperationException("不支持的操作");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -200,12 +200,19 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String presignGetUrl(String url, Integer expirationSeconds) {
|
public String presignGetUrl(String url, Integer expirationSeconds, String contentType) {
|
||||||
return presignGetUrlWithProcess(url, expirationSeconds, null);
|
return presignGetUrlWithProcess(url, expirationSeconds, null, contentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String presignGetUrlWithProcess(String url, Integer expirationSeconds, String processParam) {
|
public String presignGetUrlWithProcess(String url, Integer expirationSeconds, String processParam) {
|
||||||
|
return presignGetUrlWithProcess(url, expirationSeconds, processParam, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成文件预签名地址(带 OSS 处理参数和 Content-Type),用于读取
|
||||||
|
*/
|
||||||
|
private String presignGetUrlWithProcess(String url, Integer expirationSeconds, String processParam, String contentType) {
|
||||||
// 1. 将 url 转换为 path(支持 CDN 域名和 OSS 原始域名)
|
// 1. 将 url 转换为 path(支持 CDN 域名和 OSS 原始域名)
|
||||||
String path = extractPathFromUrl(url);
|
String path = extractPathFromUrl(url);
|
||||||
String decodedPath = URLUtil.decode(path, StandardCharsets.UTF_8);
|
String decodedPath = URLUtil.decode(path, StandardCharsets.UTF_8);
|
||||||
@@ -213,11 +220,17 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
|
|||||||
// 2. 公开访问:无需签名,直接拼接参数
|
// 2. 公开访问:无需签名,直接拼接参数
|
||||||
if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) {
|
if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) {
|
||||||
String encodedPath = UriUtils.encodePath(decodedPath, StandardCharsets.UTF_8);
|
String encodedPath = UriUtils.encodePath(decodedPath, StandardCharsets.UTF_8);
|
||||||
String resultUrl = config.getDomain() + "/" + encodedPath;
|
StringBuilder resultUrl = new StringBuilder(config.getDomain()).append("/").append(encodedPath);
|
||||||
|
char separator = '?';
|
||||||
if (StrUtil.isNotBlank(processParam)) {
|
if (StrUtil.isNotBlank(processParam)) {
|
||||||
resultUrl = resultUrl + "?x-oss-process=" + processParam;
|
resultUrl.append(separator).append("x-oss-process=").append(processParam);
|
||||||
|
separator = '&';
|
||||||
}
|
}
|
||||||
return resultUrl;
|
if (StrUtil.isNotBlank(contentType)) {
|
||||||
|
resultUrl.append(separator).append("response-content-type=").append(
|
||||||
|
URLUtil.encode(contentType, StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
return resultUrl.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 私有访问:生成预签名 URL(需要将处理参数包含在签名中)
|
// 3. 私有访问:生成预签名 URL(需要将处理参数包含在签名中)
|
||||||
@@ -229,10 +242,14 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
|
|||||||
com.aliyun.oss.model.GeneratePresignedUrlRequest request =
|
com.aliyun.oss.model.GeneratePresignedUrlRequest request =
|
||||||
new com.aliyun.oss.model.GeneratePresignedUrlRequest(config.getBucket(), decodedPath, HttpMethod.GET);
|
new com.aliyun.oss.model.GeneratePresignedUrlRequest(config.getBucket(), decodedPath, HttpMethod.GET);
|
||||||
request.setExpiration(expirationDate);
|
request.setExpiration(expirationDate);
|
||||||
// 关键:将 x-oss-process 参数包含在签名中
|
// 将 x-oss-process 参数包含在签名中
|
||||||
if (StrUtil.isNotBlank(processParam)) {
|
if (StrUtil.isNotBlank(processParam)) {
|
||||||
request.addQueryParameter("x-oss-process", processParam);
|
request.addQueryParameter("x-oss-process", processParam);
|
||||||
}
|
}
|
||||||
|
// 设置 response-content-type,确保浏览器能正确渲染
|
||||||
|
if (StrUtil.isNotBlank(contentType)) {
|
||||||
|
request.addQueryParameter("response-content-type", contentType);
|
||||||
|
}
|
||||||
signedUrl = aliyunOssClient.generatePresignedUrl(request).toString();
|
signedUrl = aliyunOssClient.generatePresignedUrl(request).toString();
|
||||||
} else {
|
} else {
|
||||||
// 非阿里云不支持 OSS 处理参数,直接返回普通预签名 URL
|
// 非阿里云不支持 OSS 处理参数,直接返回普通预签名 URL
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ public interface FileService {
|
|||||||
*/
|
*/
|
||||||
String presignGetUrl(String url, Integer expirationSeconds);
|
String presignGetUrl(String url, Integer expirationSeconds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成文件预签名地址(带 Content-Type),用于读取
|
||||||
|
*
|
||||||
|
* @param url 完整的文件访问地址
|
||||||
|
* @param expirationSeconds 访问有效期,单位秒
|
||||||
|
* @param contentType 响应的 Content-Type,为 null 时不设置
|
||||||
|
* @return 文件预签名地址
|
||||||
|
*/
|
||||||
|
String presignGetUrl(String url, Integer expirationSeconds, String contentType);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成文件预签名地址(带 OSS 处理参数),用于读取
|
* 生成文件预签名地址(带 OSS 处理参数),用于读取
|
||||||
* 用于阿里云 OSS 视频截帧等图片处理场景
|
* 用于阿里云 OSS 视频截帧等图片处理场景
|
||||||
|
|||||||
@@ -142,8 +142,13 @@ public class FileServiceImpl implements FileService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String presignGetUrl(String url, Integer expirationSeconds) {
|
public String presignGetUrl(String url, Integer expirationSeconds) {
|
||||||
|
return presignGetUrl(url, expirationSeconds, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String presignGetUrl(String url, Integer expirationSeconds, String contentType) {
|
||||||
FileClient fileClient = fileConfigService.getMasterFileClient();
|
FileClient fileClient = fileConfigService.getMasterFileClient();
|
||||||
return fileClient.presignGetUrl(url, expirationSeconds);
|
return fileClient.presignGetUrl(url, expirationSeconds, contentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -339,7 +339,9 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 视频播放URL不缓存,每次都生成新的签名URL(使用 filePath 而非 fileUrl,避免域名匹配问题)
|
// 视频播放URL不缓存,每次都生成新的签名URL(使用 filePath 而非 fileUrl,避免域名匹配问题)
|
||||||
return fileApi.presignGetUrl(file.getFilePath(), PRESIGN_URL_EXPIRATION_SECONDS);
|
// 根据文件扩展名推断 Content-Type,确保浏览器 <video> 元素能正确渲染
|
||||||
|
String contentType = java.net.URLConnection.guessContentTypeFromName(file.getFilePath());
|
||||||
|
return fileApi.presignGetUrl(file.getFilePath(), PRESIGN_URL_EXPIRATION_SECONDS, contentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -354,7 +356,8 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 音频播放URL不缓存,每次都生成新的签名URL(使用 filePath 而非 fileUrl,避免域名匹配问题)
|
// 音频播放URL不缓存,每次都生成新的签名URL(使用 filePath 而非 fileUrl,避免域名匹配问题)
|
||||||
return fileApi.presignGetUrl(file.getFilePath(), PRESIGN_URL_EXPIRATION_SECONDS);
|
String contentType = java.net.URLConnection.guessContentTypeFromName(file.getFilePath());
|
||||||
|
return fileApi.presignGetUrl(file.getFilePath(), PRESIGN_URL_EXPIRATION_SECONDS, contentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
Reference in New Issue
Block a user