feat(video-analysis): 本地直发成功且群策略要下载链接时补传 S3
本地直发经 hexi/core/outbound_media 钩子改写后已真正可用,此前它因协议端不在本机 恒失败,public_url 因此总有值(每次实际都落到第 2 级 S3)。本地通道通了以后若不补, public_url 会恒为 None,entry.py 的 `if pub and policy.sends_link` 会让「发送下载 链接」静默失效。 - _local_sent / _public_link_for 把「取链接」与「发送」解耦:只有策略真要链接 (policy.sends_link)时才补一次 S3 上传,其余情况一概不碰 S3 —— 媒体不再进 S3 归档是有意的,temp 保留交给 cleanup - 补传失败只记日志返回 None:发送本身已经成功,不能反过来判失败 - tests/test_video_sender_link.py:覆盖 sends_link 开/关、多文件逐个补传、 拿不到 public_url、上传抛异常、policy 为 None 五条路径 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,12 @@
|
||||
2. 失败 → 上传本地 S3,用预签名链接发送
|
||||
3. 再失败 → 回退 temp 本地文件再发一次
|
||||
|
||||
本地直发要真的通,得让协议端能拿到文件:2026-09-22 起由 `hexi/core/outbound_media`
|
||||
的全局钩子把本地路径改写成 `/media/<token>` 链接(此前本地那级因协议端不在本机、
|
||||
`file://` 必然 ENOENT 而恒失败,实际每次都落到第 2 级 S3)。因此**媒体不再进 S3
|
||||
归档是有意的**——只有群策略真要发下载链接(`policy.sends_link`)时才补传一次拿
|
||||
public_url,见 `_local_sent`。
|
||||
|
||||
多媒体(2026-09-08 用户需求): 多图(图集)、图+视频混合、多视频(动图)
|
||||
等一个作品落地成多个文件时,直接 UniMessage 拼成一个消息发送会报错
|
||||
(视频段不能与其他段混合,一条消息也放不下多段视频/图集体验差)
|
||||
@@ -17,6 +23,7 @@
|
||||
temp 下的文件发送成功后也不清理(用户手动处理 data/temp)。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
@@ -173,12 +180,42 @@ def _build_s3_msg(
|
||||
return msg, public_url
|
||||
|
||||
|
||||
async def _public_link_for(media: PendingMedia) -> str | None:
|
||||
"""发送已由本地通道完成后,为「发送下载链接」策略补一次 S3 上传。
|
||||
|
||||
本地直发不经 S3(最快),但群策略开了链接就需要公网 URL——这里**只取链接**,
|
||||
不重建消息。失败只记日志并返回 None:发送本身已经成功了,不能反过来判失败。
|
||||
"""
|
||||
public_url = None
|
||||
for fp in media.files:
|
||||
try:
|
||||
_, pub = await asyncio.to_thread(upload_with_plan, fp, policy=media.policy)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"补传 S3 取下载链接失败 {fp}: {e}")
|
||||
return None
|
||||
if pub:
|
||||
public_url = pub
|
||||
return public_url
|
||||
|
||||
|
||||
async def _local_sent(media: PendingMedia) -> tuple[bool, str | None]:
|
||||
"""本地通道发送成功后的返回。
|
||||
|
||||
只有策略真的要发下载链接(`sends_link` = send_link 且已开公网)时才补 S3;
|
||||
其余情况一概不碰 S3 —— 媒体不走 S3 归档是有意为之(temp 保留由 cleanup 管)。
|
||||
"""
|
||||
if media.policy is not None and media.policy.sends_link:
|
||||
return True, await _public_link_for(media)
|
||||
return True, None
|
||||
|
||||
|
||||
async def _send_media_core(
|
||||
media: PendingMedia, event: Event | None = None
|
||||
) -> tuple[bool, str | None]:
|
||||
"""多级发送主体,返回 (是否成功, public_url)
|
||||
|
||||
public_url 仅在走 S3 链接发送成功时返回(调用方决定是否发文字)。
|
||||
public_url 有两种来源:走 S3 链接发送成功,或本地发送成功但策略要链接时
|
||||
补传 S3(见 `_local_sent`);其余情况为 None(调用方据 `sends_link` 决定是否发文字)。
|
||||
temp 文件发送成功后保留(用户手动清理 data/temp)。
|
||||
|
||||
多媒体作品(多图 / 图+视频 / 多视频)先走合并转发(本地文件 → S3 链接),
|
||||
@@ -200,7 +237,7 @@ async def _send_media_core(
|
||||
user_id=user_id,
|
||||
):
|
||||
logger.info("媒体发送成功(合并转发,本地文件)")
|
||||
return True, None
|
||||
return await _local_sent(media)
|
||||
try:
|
||||
s3_items, public_url = await _build_s3_forward_items(media)
|
||||
if await _send_forward_media(
|
||||
@@ -217,7 +254,7 @@ async def _send_media_core(
|
||||
try:
|
||||
await _build_local_msg(media.files, media.image_post).send()
|
||||
logger.info("媒体发送成功(temp 本地文件直达)")
|
||||
return True, None
|
||||
return await _local_sent(media)
|
||||
except Exception as e:
|
||||
logger.warning(f"temp 本地文件发送失败,切换本地 S3 链接: {e}")
|
||||
|
||||
@@ -234,7 +271,7 @@ async def _send_media_core(
|
||||
try:
|
||||
await _build_local_msg(media.files, media.image_post).send()
|
||||
logger.info("媒体发送成功(回退 temp 本地文件)")
|
||||
return True, None
|
||||
return await _local_sent(media)
|
||||
except Exception as e:
|
||||
logger.exception(f"回退发送失败: {e}")
|
||||
return False, None
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""媒体发送成功后「补传 S3 取下载链接」的单元测试
|
||||
|
||||
背景:2026-09-22 起 `hexi/core/outbound_media` 的全局钩子把本地路径改写成
|
||||
`/media/<token>`,本地直发那一级才真正可用。此前它因协议端不在本机、`file://`
|
||||
必然 ENOENT 而**恒失败**,所以每次都落到第 2 级 S3 —— `public_url` 因此总有值。
|
||||
本地直发通了以后,如果什么都不做,`public_url` 会恒为 None,`entry.py` 的
|
||||
`if pub and policy.sends_link` 就会让「发送下载链接」**静默失效**。
|
||||
|
||||
`_local_sent` / `_public_link_for` 就是为此把「取链接」与「发送」解耦:
|
||||
只有策略真要链接(`sends_link`)时才补一次 S3,其余情况一概不碰 S3。
|
||||
|
||||
sender.py 有插件内相对导入(`..config` / `..policy` / `..services...`),
|
||||
无法裸加载,故在 sys.modules 里预置这些依赖的替身再按路径加载目标模块。
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[1]
|
||||
_PKG = "hexi.plugins.nonebot_plugin_video_analysis"
|
||||
_SENDER_PATH = (
|
||||
_REPO
|
||||
/ "hexi"
|
||||
/ "plugins"
|
||||
/ "nonebot_plugin_video_analysis"
|
||||
/ "handlers"
|
||||
/ "sender.py"
|
||||
)
|
||||
|
||||
#: 预置的替身模块名 → 该模块要暴露的属性占位(真实函数在测试里替换)
|
||||
_STUBS = {
|
||||
_PKG: {},
|
||||
f"{_PKG}.handlers": {},
|
||||
f"{_PKG}.config": {"group_file_settings": lambda: ([], None)},
|
||||
f"{_PKG}.policy": {"Policy": type("Policy", (), {})},
|
||||
f"{_PKG}.services": {},
|
||||
f"{_PKG}.services.storage": {},
|
||||
f"{_PKG}.services.storage.group_file": {"upload_group_files": None},
|
||||
f"{_PKG}.services.storage.s3": {"upload_with_plan": None},
|
||||
f"{_PKG}.utils": {"media_rel_dir_of": lambda _p: ""},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def sender():
|
||||
saved = {name: sys.modules.get(name) for name in _STUBS}
|
||||
for name, attrs in _STUBS.items():
|
||||
module = types.ModuleType(name)
|
||||
for key, value in attrs.items():
|
||||
setattr(module, key, value)
|
||||
sys.modules[name] = module
|
||||
|
||||
target = f"{_PKG}.handlers.sender"
|
||||
saved[target] = sys.modules.get(target)
|
||||
spec = importlib.util.spec_from_file_location(target, _SENDER_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[target] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
yield module
|
||||
|
||||
for name, previous in saved.items():
|
||||
if previous is None:
|
||||
sys.modules.pop(name, None)
|
||||
else:
|
||||
sys.modules[name] = previous
|
||||
|
||||
|
||||
class _Policy:
|
||||
"""只带 sends_link 的策略替身(真实 Policy 需要一堆字段/归一化)。"""
|
||||
|
||||
def __init__(self, sends_link: bool):
|
||||
self.sends_link = sends_link
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uploads(monkeypatch, sender):
|
||||
"""记录 upload_with_plan 的调用;返回 (调用列表, 设置返回值的钩子)。"""
|
||||
calls: list[Path] = []
|
||||
result: dict = {"pub": "https://pub.example/x.mp4", "raise": None}
|
||||
|
||||
def fake_upload(file_path, *, policy=None):
|
||||
calls.append(Path(file_path))
|
||||
if result["raise"]:
|
||||
raise result["raise"]
|
||||
return "local-url", result["pub"]
|
||||
|
||||
monkeypatch.setattr(sender, "upload_with_plan", fake_upload)
|
||||
return calls, result
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def media(sender):
|
||||
def build(sends_link: bool, n: int = 1, policy=None):
|
||||
files = [Path(f"D:/temp/f{i}.mp4") for i in range(n)]
|
||||
return sender.PendingMedia(
|
||||
files=files,
|
||||
policy=_Policy(sends_link) if policy is None else policy,
|
||||
)
|
||||
|
||||
return build
|
||||
|
||||
|
||||
async def test_no_s3_when_link_disabled(sender, media, uploads):
|
||||
"""默认路径:本地发成功就够了,绝不碰 S3(媒体不走 S3 归档是有意的)。"""
|
||||
calls, _ = uploads
|
||||
ok, pub = await sender._local_sent(media(sends_link=False))
|
||||
assert (ok, pub) == (True, None)
|
||||
assert calls == []
|
||||
|
||||
|
||||
async def test_s3_fetched_when_link_enabled(sender, media, uploads):
|
||||
"""策略要下载链接时:补传 S3 并把 public_url 交出去(本次修的回归)。"""
|
||||
calls, _ = uploads
|
||||
ok, pub = await sender._local_sent(media(sends_link=True))
|
||||
assert (ok, pub) == (True, "https://pub.example/x.mp4")
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
async def test_multi_file_uploads_each(sender, media, uploads):
|
||||
calls, _ = uploads
|
||||
ok, pub = await sender._local_sent(media(sends_link=True, n=3))
|
||||
assert ok is True
|
||||
assert pub == "https://pub.example/x.mp4"
|
||||
assert len(calls) == 3
|
||||
|
||||
|
||||
async def test_no_public_url_keeps_none(sender, media, uploads):
|
||||
"""S3 有本地链接但没公网(upload_public 关)→ 不带链接,但也不算失败。"""
|
||||
calls, result = uploads
|
||||
result["pub"] = None
|
||||
ok, pub = await sender._local_sent(media(sends_link=True))
|
||||
assert (ok, pub) == (True, None)
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
async def test_upload_failure_never_breaks_send(sender, media, uploads):
|
||||
"""补传失败只记日志:发送本身已经成功了,不能反过来判失败。"""
|
||||
calls, result = uploads
|
||||
result["raise"] = RuntimeError("S3 挂了")
|
||||
ok, pub = await sender._local_sent(media(sends_link=True))
|
||||
assert (ok, pub) == (True, None)
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
async def test_policy_none_is_safe(sender, media, uploads):
|
||||
"""无策略(如 Web 面板路径)时不补传也不炸。"""
|
||||
calls, _ = uploads
|
||||
ok, pub = await sender._local_sent(media(sends_link=False, policy=None))
|
||||
assert (ok, pub) == (True, None)
|
||||
assert calls == []
|
||||
Reference in New Issue
Block a user