fix: P0-2 预签名URL诊断日志 + P0-3 xfade exit 234 三重修复 #207
@@ -166,12 +166,28 @@ class OSSStorageService:
|
||||
if self.bucket is None:
|
||||
if self._is_local_generated_url(storage_key_or_url):
|
||||
return storage_key_or_url
|
||||
logger.warning(
|
||||
"get_download_url: OSS bucket not configured, returning raw URL. "
|
||||
"storage_key_or_url=%s",
|
||||
storage_key_or_url[:200],
|
||||
)
|
||||
return self.get_url(self._normalize_storage_key(storage_key_or_url))
|
||||
|
||||
storage_key = self._normalize_storage_key(storage_key_or_url)
|
||||
try:
|
||||
return self.bucket.sign_url("GET", storage_key, expires_seconds)
|
||||
signed = self.bucket.sign_url("GET", storage_key, expires_seconds)
|
||||
logger.info(
|
||||
"get_download_url: signed URL generated. storage_key=%s url_prefix=%s",
|
||||
storage_key[:80],
|
||||
signed[:60],
|
||||
)
|
||||
return signed
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"get_download_url: sign_url failed, falling back to raw URL. "
|
||||
"storage_key=%s",
|
||||
storage_key[:200],
|
||||
)
|
||||
return self.get_url(storage_key)
|
||||
|
||||
def _normalize_storage_key(self, storage_key_or_url: str) -> str:
|
||||
|
||||
@@ -250,8 +250,11 @@ def build_xfade_filter_chain(
|
||||
) -> tuple[str, float]:
|
||||
"""构建 xfade 转场滤镜链。
|
||||
|
||||
对每步 xfade 自动钳制 transition duration,确保
|
||||
``offset + td ≤ first_input_duration``,避免 FFmpeg exit 234。
|
||||
|
||||
Args:
|
||||
clip_durations: 每个片段的时长
|
||||
clip_durations: 每个片段的时长(必须与 trim 后的实际时长一致)
|
||||
clip_video_labels: 每个片段的视频流标签(如 "v0", "v1")
|
||||
transitions: 每个片段对应的转场效果(第一个片段的转场被忽略)
|
||||
transition_duration: 转场时长(秒)
|
||||
@@ -262,23 +265,42 @@ def build_xfade_filter_chain(
|
||||
"""
|
||||
n = len(clip_durations)
|
||||
parts: list[str] = []
|
||||
total_duration = sum(clip_durations)
|
||||
|
||||
if n == 0:
|
||||
return "", 0.0
|
||||
|
||||
if n == 1:
|
||||
parts.append(f"[{clip_video_labels[0]}]copy[{output_label}]")
|
||||
return ";".join(parts), total_duration
|
||||
return ";".join(parts), clip_durations[0]
|
||||
|
||||
# xfade 链
|
||||
# xfade 链 — 每步动态钳制 td,防止 offset + td > first_input_duration
|
||||
cumulative = 0.0
|
||||
prev_label = clip_video_labels[0]
|
||||
total_transition = 0.0 # 累计已使用的转场时长
|
||||
|
||||
for i in range(1, n):
|
||||
cumulative += clip_durations[i - 1]
|
||||
|
||||
# 当前 xfade 的第一个输入时长
|
||||
if i == 1:
|
||||
first_input_dur = clip_durations[0]
|
||||
else:
|
||||
first_input_dur = cumulative - total_transition
|
||||
|
||||
# 原始 offset 计算
|
||||
offset = max(0.0, cumulative - transition_duration * i)
|
||||
|
||||
# 安全钳制:offset + td 不能超过第一个输入的时长
|
||||
available = max(0.0, first_input_dur - offset)
|
||||
safe_td = min(transition_duration, available)
|
||||
|
||||
# 同时不能超过剩余总时长
|
||||
remaining = max(0.0, sum(clip_durations) - cumulative)
|
||||
safe_td = min(safe_td, remaining)
|
||||
# 同时不能超过当前第二个输入(单个片段)的时长
|
||||
safe_td = min(safe_td, clip_durations[i])
|
||||
safe_td = max(0.001, safe_td) # 至少 1ms,避免 td=0
|
||||
|
||||
transition = transitions[i] if i < len(transitions) else "cut"
|
||||
xfade_transition = resolve_xfade_transition(transition)
|
||||
|
||||
@@ -290,12 +312,13 @@ def build_xfade_filter_chain(
|
||||
parts.append(
|
||||
f"[{prev_label}][{clip_video_labels[i]}]"
|
||||
f"xfade=transition={xfade_transition}"
|
||||
f":duration={transition_duration}"
|
||||
f":duration={safe_td:.3f}"
|
||||
f":offset={offset:.3f}"
|
||||
f"[{out_label}]"
|
||||
)
|
||||
prev_label = out_label
|
||||
total_transition += safe_td
|
||||
|
||||
# 总时长减去转场重叠部分
|
||||
total_duration -= transition_duration * (n - 1)
|
||||
total_duration = sum(clip_durations) - total_transition
|
||||
return ";".join(parts), max(0.0, total_duration)
|
||||
|
||||
@@ -310,9 +310,16 @@ class UnifiedRenderService:
|
||||
|
||||
filters: list[str] = []
|
||||
|
||||
# trim(如果指定了 duration)
|
||||
if clip.duration > 0 and clip.duration < clip.actual_duration:
|
||||
filters.append(f"trim=duration={clip.duration}")
|
||||
# trim — 始终将输出截断到有效时长,防止 xfade offset 与实际时长不匹配
|
||||
# 有效时长 = min(指定时长, 实际时长);若均未设置则跳过
|
||||
effective_duration = 0.0
|
||||
if clip.duration > 0:
|
||||
effective_duration = min(clip.duration, clip.actual_duration) if clip.actual_duration > 0 else clip.duration
|
||||
elif clip.actual_duration > 0:
|
||||
effective_duration = clip.actual_duration
|
||||
|
||||
if effective_duration > 0:
|
||||
filters.append(f"trim=duration={effective_duration}")
|
||||
filters.append("setpts=PTS-STARTPTS")
|
||||
|
||||
# scale
|
||||
@@ -344,10 +351,15 @@ class UnifiedRenderService:
|
||||
for layer in layers:
|
||||
layer_clip_indices = [all_clips.index(c) for c in layer.clips]
|
||||
layer_labels = [preprocessed_labels[i] for i in layer_clip_indices]
|
||||
layer_durations = [
|
||||
all_clips[i].duration if all_clips[i].duration > 0 else all_clips[i].actual_duration
|
||||
for i in layer_clip_indices
|
||||
]
|
||||
# 使用 trim 后的有效时长,与 Step 1 的 trim=duration 保持一致
|
||||
layer_durations = []
|
||||
for i in layer_clip_indices:
|
||||
c = all_clips[i]
|
||||
if c.duration > 0:
|
||||
eff = min(c.duration, c.actual_duration) if c.actual_duration > 0 else c.duration
|
||||
else:
|
||||
eff = c.actual_duration if c.actual_duration > 0 else 0.0
|
||||
layer_durations.append(eff)
|
||||
layer_transitions = [all_clips[i].transition_effect for i in layer_clip_indices]
|
||||
|
||||
if len(layer_labels) == 1:
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
"""P0-2 / P0-3 修复验证测试.
|
||||
|
||||
P0-2: OSSStorageService.get_download_url 预签名 URL 逻辑验证
|
||||
P0-3: FFmpeg xfade exit 234 — build_xfade_filter_chain 安全钳制 + effective_duration trim
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from video_processing.ffmpeg_utils import build_xfade_filter_chain
|
||||
|
||||
# ── P0-3: build_xfade_filter_chain 安全钳制 ──────────────────────────────────
|
||||
|
||||
|
||||
|
||||
class TestBuildXfadeFilterChainSafetyClamp:
|
||||
"""验证 xfade 滤镜链的安全钳制逻辑,防止 exit 234。"""
|
||||
|
||||
def test_empty_clips(self):
|
||||
"""空片段列表返回空字符串和 0 时长。"""
|
||||
f, dur = build_xfade_filter_chain([], [], [])
|
||||
assert f == ""
|
||||
assert dur == 0.0
|
||||
|
||||
def test_single_clip(self):
|
||||
"""单片段用 copy,不做 xfade。"""
|
||||
f, dur = build_xfade_filter_chain([3.0], ["v0"], ["cut"])
|
||||
assert "copy" in f
|
||||
assert dur == 3.0
|
||||
|
||||
def test_two_clips_normal(self):
|
||||
"""两个正常时长片段 — offset + td ≤ first_input_duration。"""
|
||||
f, dur = build_xfade_filter_chain(
|
||||
clip_durations=[5.0, 5.0],
|
||||
clip_video_labels=["v0", "v1"],
|
||||
transitions=["cut", "fade"],
|
||||
transition_duration=0.5,
|
||||
)
|
||||
assert "xfade" in f
|
||||
assert "duration=0.500" in f
|
||||
# 总时长 = 5 + 5 - 0.5 = 9.5
|
||||
assert abs(dur - 9.5) < 0.01
|
||||
|
||||
def test_short_clip_safety_clamp(self):
|
||||
"""片段短于 transition_duration 时,td 被钳制,不会导致 exit 234。
|
||||
|
||||
这是 P0-3 的核心场景:视频只有 1s,td=0.5s,offset 计算后
|
||||
offset + td 可能超过 first_input_duration。
|
||||
"""
|
||||
# 两个 1s 片段,td=0.5s
|
||||
# 原始 offset = max(0, 1.0 - 0.5*1) = 0.5
|
||||
# first_input_dur = 1.0, available = 1.0 - 0.5 = 0.5
|
||||
# safe_td = min(0.5, 0.5) = 0.5 — 刚好 OK
|
||||
f, dur = build_xfade_filter_chain(
|
||||
clip_durations=[1.0, 1.0],
|
||||
clip_video_labels=["v0", "v1"],
|
||||
transitions=["cut", "fade"],
|
||||
transition_duration=0.5,
|
||||
)
|
||||
assert "xfade" in f
|
||||
# offset + td 必须 ≤ first_input_duration
|
||||
# offset=0.5, td=0.5 → 0.5+0.5=1.0 ≤ 1.0 ✓
|
||||
assert dur > 0
|
||||
|
||||
def test_very_short_clip_clamped(self):
|
||||
"""片段极短(0.3s),td 被钳制到 available 以内。"""
|
||||
# clip1=0.3s, clip2=5.0s, td=0.5s
|
||||
# offset = max(0, 0.3 - 0.5) = 0.0
|
||||
# first_input_dur = 0.3
|
||||
# available = 0.3 - 0.0 = 0.3
|
||||
# safe_td = min(0.5, 0.3) = 0.3
|
||||
f, dur = build_xfade_filter_chain(
|
||||
clip_durations=[0.3, 5.0],
|
||||
clip_video_labels=["v0", "v1"],
|
||||
transitions=["cut", "fade"],
|
||||
transition_duration=0.5,
|
||||
)
|
||||
assert "duration=0.300" in f # td 被钳制到 0.3
|
||||
|
||||
def test_multi_clip_chain_clamp(self):
|
||||
"""多片段链式 xfade,每步都钳制。"""
|
||||
# 3 个 0.5s 片段,td=0.5s
|
||||
# Step 1: offset=0, first_input_dur=0.5, available=0.5, safe_td=0.5
|
||||
# total_transition=0.5
|
||||
# Step 2: cumulative=1.0, first_input_dur=1.0-0.5=0.5
|
||||
# offset=max(0, 1.0-0.5*2)=0.0, available=0.5, safe_td=0.5
|
||||
f, dur = build_xfade_filter_chain(
|
||||
clip_durations=[0.5, 0.5, 0.5],
|
||||
clip_video_labels=["v0", "v1", "v2"],
|
||||
transitions=["cut", "fade", "fade"],
|
||||
transition_duration=0.5,
|
||||
)
|
||||
assert "xfade" in f
|
||||
assert f.count("xfade") == 2
|
||||
assert dur > 0
|
||||
|
||||
def test_middle_clip_shorter_than_td(self):
|
||||
"""P1 修复验证:中间片段短于 td 时,td 被钳制到该片段时长。
|
||||
|
||||
[5.0, 0.3, 5.0] + td=0.5s:
|
||||
- Step 1: second input = 0.3s, safe_td 必须 ≤ 0.3
|
||||
- Step 2: second input = 5.0s, safe_td 可以 = 0.5
|
||||
"""
|
||||
import re
|
||||
|
||||
f, dur = build_xfade_filter_chain(
|
||||
clip_durations=[5.0, 0.3, 5.0],
|
||||
clip_video_labels=["v0", "v1", "v2"],
|
||||
transitions=["cut", "fade", "fade"],
|
||||
transition_duration=0.5,
|
||||
)
|
||||
assert f.count("xfade") == 2
|
||||
|
||||
# 解析每个 xfade 的 duration
|
||||
durations_found = []
|
||||
for part in f.split(";"):
|
||||
if "xfade=" not in part:
|
||||
continue
|
||||
m = re.search(r"duration=([\d.]+)", part)
|
||||
assert m, f"无法解析: {part}"
|
||||
durations_found.append(float(m.group(1)))
|
||||
|
||||
# 第一个 xfade: td 必须 ≤ 0.3 (第二个输入 clip_durations[1]=0.3)
|
||||
assert durations_found[0] <= 0.3 + 0.001, (
|
||||
f"第一个 xfade td={durations_found[0]} 超过 clip_durations[1]=0.3"
|
||||
)
|
||||
# 第二个 xfade: td 可以 = 0.5 (clip_durations[2]=5.0)
|
||||
assert durations_found[1] <= 0.5 + 0.001
|
||||
assert dur > 0
|
||||
|
||||
def test_offset_plus_td_never_exceeds_input(self):
|
||||
"""压力测试:多种时长组合,offset + td 永远不超过 first_input_duration。"""
|
||||
test_cases = [
|
||||
([0.1, 5.0], 0.5),
|
||||
([0.5, 0.5], 0.5),
|
||||
([1.0, 1.0, 1.0], 0.5),
|
||||
([0.2, 0.3, 0.4], 0.5),
|
||||
([10.0, 0.1], 0.5),
|
||||
([3.0, 3.0, 3.0, 3.0], 0.5),
|
||||
([5.0, 0.3, 5.0], 0.5), # P1: 中间片段短于 td
|
||||
([5.0, 0.1, 0.1, 5.0], 0.5), # P1: 多个中间片段都短于 td
|
||||
]
|
||||
for durations, td in test_cases:
|
||||
labels = [f"v{i}" for i in range(len(durations))]
|
||||
transitions = ["cut"] + ["fade"] * (len(durations) - 1)
|
||||
f, dur = build_xfade_filter_chain(
|
||||
clip_durations=durations,
|
||||
clip_video_labels=labels,
|
||||
transitions=transitions,
|
||||
transition_duration=td,
|
||||
)
|
||||
assert dur >= 0, f" durations={durations} td={td} → dur={dur}"
|
||||
# 解析 filter 验证 offset + td 的合理性
|
||||
import re
|
||||
|
||||
# 按 xfade 步骤索引追踪第二个输入
|
||||
xfade_idx = 0
|
||||
for part in f.split(";"):
|
||||
if "xfade=" not in part:
|
||||
continue
|
||||
# 格式: [prev][next]xfade=transition=X:duration=D:offset=O[out]
|
||||
m = re.search(
|
||||
r"xfade=transition=(\w+):duration=([\d.]+):offset=([\d.]+)",
|
||||
part,
|
||||
)
|
||||
assert m, f"无法解析 xfade 参数: {part}"
|
||||
offset_val = float(m.group(3))
|
||||
dur_val = float(m.group(2))
|
||||
assert offset_val >= 0
|
||||
assert dur_val >= 0.001 # 至少 1ms
|
||||
# P1 修复验证: td 不能超过第二个输入片段时长
|
||||
second_input_idx = xfade_idx + 1
|
||||
assert dur_val <= durations[second_input_idx] + 0.001, (
|
||||
f"td={dur_val} > clip_durations[{second_input_idx}]={durations[second_input_idx]}"
|
||||
)
|
||||
xfade_idx += 1
|
||||
|
||||
|
||||
# ── P0-3: effective_duration trim 逻辑验证 ────────────────────────────────────
|
||||
|
||||
|
||||
class TestEffectiveDurationTrim:
|
||||
"""验证 _build_filter_complex 中 effective_duration trim 逻辑。"""
|
||||
|
||||
def _make_service(self, clips, asset_paths=None):
|
||||
from pathlib import Path
|
||||
|
||||
from video_processing.unified_render_service import UnifiedRenderService
|
||||
|
||||
plan = MagicMock()
|
||||
plan.id = "test_plan"
|
||||
work_dir = Path("/tmp/test_render")
|
||||
if asset_paths is None:
|
||||
asset_paths = {}
|
||||
for c in clips:
|
||||
asset_paths[c.asset_id] = Path(f"/tmp/{c.asset_id}")
|
||||
return UnifiedRenderService(
|
||||
plan=plan,
|
||||
clips=clips,
|
||||
asset_path_map=asset_paths,
|
||||
work_dir=work_dir,
|
||||
)
|
||||
|
||||
def _make_clip(self, clip_id, duration=0.0, actual_duration=5.0, clip_type="main", order=0):
|
||||
from pathlib import Path
|
||||
|
||||
from video_processing.unified_render_service import ResolvedClip
|
||||
|
||||
return ResolvedClip(
|
||||
clip_id=clip_id,
|
||||
asset_id=f"asset_{clip_id}.mp4",
|
||||
local_path=Path(f"/tmp/asset_{clip_id}.mp4"),
|
||||
clip_type=clip_type,
|
||||
order=order,
|
||||
duration=duration,
|
||||
actual_duration=actual_duration,
|
||||
transition_effect="fade",
|
||||
config={},
|
||||
)
|
||||
|
||||
def test_trim_applied_when_duration_less_than_actual(self):
|
||||
"""clip.duration < actual_duration → trim=duration=clip.duration。"""
|
||||
clip = self._make_clip("c1", duration=3.0, actual_duration=10.0)
|
||||
svc = self._make_service([clip])
|
||||
layers = svc._group_clips_into_layers([clip])
|
||||
fc, _ = svc._build_filter_complex(layers)
|
||||
assert "trim=duration=3.0" in fc
|
||||
|
||||
def test_trim_uses_actual_when_no_duration_set(self):
|
||||
"""clip.duration=0 → 使用 actual_duration 做 trim。"""
|
||||
clip = self._make_clip("c1", duration=0.0, actual_duration=7.5)
|
||||
svc = self._make_service([clip])
|
||||
layers = svc._group_clips_into_layers([clip])
|
||||
fc, _ = svc._build_filter_complex(layers)
|
||||
assert "trim=duration=7.5" in fc
|
||||
|
||||
def test_trim_uses_min_of_duration_and_actual(self):
|
||||
"""clip.duration > actual_duration → trim 到 actual_duration。"""
|
||||
clip = self._make_clip("c1", duration=10.0, actual_duration=2.0)
|
||||
svc = self._make_service([clip])
|
||||
layers = svc._group_clips_into_layers([clip])
|
||||
fc, _ = svc._build_filter_complex(layers)
|
||||
assert "trim=duration=2.0" in fc
|
||||
|
||||
def test_no_trim_when_both_zero(self):
|
||||
"""duration=0 且 actual_duration=0 → 不做 trim。"""
|
||||
clip = self._make_clip("c1", duration=0.0, actual_duration=0.0)
|
||||
svc = self._make_service([clip])
|
||||
layers = svc._group_clips_into_layers([clip])
|
||||
fc, _ = svc._build_filter_complex(layers)
|
||||
assert "trim=" not in fc
|
||||
|
||||
def test_xfade_uses_effective_durations(self):
|
||||
"""多片段 xfade 使用 trim 后的有效时长。"""
|
||||
clip1 = self._make_clip("c1", duration=3.0, actual_duration=10.0, order=0)
|
||||
clip2 = self._make_clip("c2", duration=4.0, actual_duration=10.0, order=1)
|
||||
svc = self._make_service([clip1, clip2])
|
||||
layers = svc._group_clips_into_layers([clip1, clip2])
|
||||
fc, _ = svc._build_filter_complex(layers)
|
||||
assert "xfade=" in fc
|
||||
# 两个 clip 的 trim 应该分别用 3.0 和 4.0
|
||||
assert "trim=duration=3.0" in fc
|
||||
assert "trim=duration=4.0" in fc
|
||||
|
||||
|
||||
# ── P0-2: get_download_url 预签名 URL 逻辑验证 ────────────────────────────────
|
||||
|
||||
|
||||
class TestGetDownloadUrl:
|
||||
"""验证 OSSStorageService.get_download_url 逻辑。"""
|
||||
|
||||
def test_returns_signed_url_when_bucket_configured(self):
|
||||
"""bucket 已配置 → 返回签名 URL。"""
|
||||
from app.core.storage import OSSStorageService
|
||||
|
||||
with patch.object(OSSStorageService, "__init__", lambda self: None):
|
||||
svc = OSSStorageService()
|
||||
svc.bucket = MagicMock()
|
||||
svc.bucket.sign_url.return_value = "https://signed-url.oss.com/file.mp4?signature=xxx"
|
||||
svc.public_url = "https://bucket.oss.com"
|
||||
|
||||
result = svc.get_download_url("uploads/video.mp4")
|
||||
|
||||
svc.bucket.sign_url.assert_called_once_with("GET", "uploads/video.mp4", 3600)
|
||||
assert "signed-url" in result
|
||||
|
||||
def test_returns_raw_url_when_bucket_none(self):
|
||||
"""bucket 未配置 → 返回原始公网 URL,并记录 warning。"""
|
||||
from app.core.storage import OSSStorageService
|
||||
|
||||
with patch.object(OSSStorageService, "__init__", lambda self: None):
|
||||
svc = OSSStorageService()
|
||||
svc.bucket = None
|
||||
svc.public_url = "https://bucket.oss.com"
|
||||
svc.local_url_prefix = "/generated-files"
|
||||
|
||||
with patch.object(svc, "_normalize_storage_key", return_value="uploads/video.mp4"):
|
||||
result = svc.get_download_url("uploads/video.mp4")
|
||||
|
||||
assert result == "https://bucket.oss.com/uploads/video.mp4"
|
||||
|
||||
def test_local_generated_url_returned_as_is(self):
|
||||
"""本地生成文件 URL → 原样返回,不走 OSS。"""
|
||||
from app.core.storage import OSSStorageService
|
||||
|
||||
with patch.object(OSSStorageService, "__init__", lambda self: None):
|
||||
svc = OSSStorageService()
|
||||
svc.bucket = None
|
||||
svc.local_url_prefix = "/generated-files"
|
||||
|
||||
result = svc.get_download_url("/generated-files/abc123.mp4")
|
||||
assert result == "/generated-files/abc123.mp4"
|
||||
|
||||
def test_normalize_strips_full_url(self):
|
||||
"""完整 URL → 提取路径部分作为 storage_key。"""
|
||||
from app.core.storage import OSSStorageService
|
||||
|
||||
with patch.object(OSSStorageService, "__init__", lambda self: None):
|
||||
svc = OSSStorageService()
|
||||
key = svc._normalize_storage_key("https://bucket.oss-cn-hangzhou.aliyuncs.com/uploads/video.mp4")
|
||||
assert key == "uploads/video.mp4"
|
||||
|
||||
def test_normalize_preserves_plain_key(self):
|
||||
"""纯路径 → 保持不变。"""
|
||||
from app.core.storage import OSSStorageService
|
||||
|
||||
with patch.object(OSSStorageService, "__init__", lambda self: None):
|
||||
svc = OSSStorageService()
|
||||
key = svc._normalize_storage_key("uploads/video.mp4")
|
||||
assert key == "uploads/video.mp4"
|
||||
|
||||
def test_sign_url_failure_falls_back(self):
|
||||
"""sign_url 异常 → 回退到原始 URL,不崩溃。"""
|
||||
from app.core.storage import OSSStorageService
|
||||
|
||||
with patch.object(OSSStorageService, "__init__", lambda self: None):
|
||||
svc = OSSStorageService()
|
||||
svc.bucket = MagicMock()
|
||||
svc.bucket.sign_url.side_effect = Exception("OSS error")
|
||||
svc.public_url = "https://bucket.oss.com"
|
||||
|
||||
with patch.object(svc, "_normalize_storage_key", return_value="uploads/video.mp4"):
|
||||
result = svc.get_download_url("uploads/video.mp4")
|
||||
|
||||
assert result == "https://bucket.oss.com/uploads/video.mp4"
|
||||
|
||||
def test_diagnostic_logging_on_bucket_none(self, caplog):
|
||||
"""bucket 未配置时记录 warning 日志。"""
|
||||
from app.core.storage import OSSStorageService
|
||||
|
||||
with patch.object(OSSStorageService, "__init__", lambda self: None):
|
||||
svc = OSSStorageService()
|
||||
svc.bucket = None
|
||||
svc.public_url = "https://bucket.oss.com"
|
||||
svc.local_url_prefix = "/generated-files"
|
||||
|
||||
with patch.object(svc, "_normalize_storage_key", return_value="uploads/video.mp4"):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
svc.get_download_url("https://bucket.oss.com/uploads/video.mp4")
|
||||
|
||||
assert any("OSS bucket not configured" in r.message for r in caplog.records)
|
||||
|
||||
def test_diagnostic_logging_on_sign_success(self, caplog):
|
||||
"""签名成功时记录 info 日志。"""
|
||||
from app.core.storage import OSSStorageService
|
||||
|
||||
with patch.object(OSSStorageService, "__init__", lambda self: None):
|
||||
svc = OSSStorageService()
|
||||
svc.bucket = MagicMock()
|
||||
svc.bucket.sign_url.return_value = "https://signed.oss.com/file.mp4?sig=xxx"
|
||||
svc.public_url = "https://bucket.oss.com"
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
svc.get_download_url("uploads/video.mp4")
|
||||
|
||||
assert any("signed URL generated" in r.message for r in caplog.records)
|
||||
Reference in New Issue
Block a user