fix: make Feishu attachments collision-safe

This commit is contained in:
hetaoBackend 2026-08-06 10:24:33 +08:00
parent a0d26e446c
commit d1f41a9d57
2 changed files with 57 additions and 7 deletions

View File

@ -22,9 +22,11 @@ from app.channels.message_bus import (
OutboundMessage,
ResolvedAttachment,
)
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths
from deerflow.config.paths import get_paths
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.sandbox.sandbox_provider import get_sandbox_provider
from deerflow.uploads.layout import upload_virtual_path
from deerflow.uploads.manager import publish_upload_bytes
logger = logging.getLogger(__name__)
PENDING_CLARIFICATION_TTL_SECONDS = 30 * 60
@ -447,11 +449,7 @@ class FeishuChannel(Channel):
def _persist():
paths.ensure_thread_dirs(thread_id, user_id=effective_user_id)
uploads_dir = paths.sandbox_uploads_dir(thread_id, user_id=effective_user_id).resolve()
resolved_target = uploads_dir / filename
# Use thread_lock to avoid filename conflicts when writing.
with self._thread_lock:
resolved_target.write_bytes(content)
return resolved_target
return publish_upload_bytes(uploads_dir, filename, content)
try:
resolved_target = await asyncio.to_thread(_persist)
@ -459,7 +457,7 @@ class FeishuChannel(Channel):
logger.exception("[Feishu] failed to persist downloaded resource: %s, type=%s", filename, type)
return f"Failed to obtain the [{type}]"
virtual_path = f"{VIRTUAL_PATH_PREFIX}/uploads/{resolved_target.name}"
virtual_path = upload_virtual_path(resolved_target.name)
try:
sandbox_provider = await asyncio.to_thread(get_sandbox_provider)

View File

@ -124,3 +124,55 @@ async def test_receive_file_mounted_sandbox_skips_redundant_sync(tmp_path, monke
assert result == "/mnt/user-data/uploads/report.pdf"
uploaded = tmp_path / "users" / "ou-user" / "threads" / "thread-1" / "user-data" / "uploads" / "report.pdf"
assert await asyncio.to_thread(uploaded.read_bytes) == b"DATA"
async def test_concurrent_same_name_files_preserve_every_payload(tmp_path, monkeypatch) -> None:
from deerflow.config.paths import Paths
paths = await asyncio.to_thread(Paths, str(tmp_path))
monkeypatch.setattr("app.channels.feishu.get_paths", lambda: paths)
monkeypatch.setattr("app.channels.feishu.get_sandbox_provider", lambda: _MountedProvider())
payloads = [f"payload-{index}".encode() for index in range(8)]
channels = [_channel_with_file(payload, "report.pdf") for payload in payloads]
results = await asyncio.gather(
*(
channel._receive_single_file(
f"message-{index}",
f"file-key-{index}",
"file",
"thread-1",
user_id="ou-user",
)
for index, channel in enumerate(channels)
)
)
assert len(set(results)) == len(payloads)
uploads = paths.sandbox_uploads_dir("thread-1", user_id="ou-user")
assert {await asyncio.to_thread((uploads / result.rsplit("/", 1)[-1]).read_bytes) for result in results} == set(payloads)
async def test_receive_file_renames_around_planted_symlink(tmp_path, monkeypatch) -> None:
from deerflow.config.paths import Paths
paths = await asyncio.to_thread(Paths, str(tmp_path))
await asyncio.to_thread(paths.ensure_thread_dirs, "thread-1", user_id="ou-user")
uploads = paths.sandbox_uploads_dir("thread-1", user_id="ou-user")
outside = tmp_path / "outside.pdf"
await asyncio.to_thread((uploads / "report.pdf").symlink_to, outside)
monkeypatch.setattr("app.channels.feishu.get_paths", lambda: paths)
monkeypatch.setattr("app.channels.feishu.get_sandbox_provider", lambda: _MountedProvider())
result = await _channel_with_file(b"DATA", "report.pdf")._receive_single_file(
"message-1",
"file-key",
"file",
"thread-1",
user_id="ou-user",
)
assert result == "/mnt/user-data/uploads/report_1.pdf"
assert not await asyncio.to_thread(outside.exists)
assert await asyncio.to_thread((uploads / "report.pdf").is_symlink)
assert await asyncio.to_thread((uploads / "report_1.pdf").read_bytes) == b"DATA"