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:
2026-09-23 18:47:12 +08:00
co-authored by Claude Code
parent ed75a66982
commit 574216645c
2 changed files with 196 additions and 4 deletions
@@ -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