0a7ae8db4f
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 41s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 32s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m5s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m8s
AI Code Review / AI Code Review (pull_request) Failing after 2m24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m51s
CI/CD Pipeline / Validate - Style (push) Successful in 2m36s
CI/CD Pipeline / Integration Tests (push) Successful in 2m33s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m52s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m21s
CI/CD Pipeline / Validate - Security (push) Successful in 5m43s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m1s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m20s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m32s
CI/CD Pipeline / Unit Tests (push) Successful in 8m29s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m18s
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 21s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 24s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m43s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m41s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m50s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m4s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m25s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 4m35s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 7m16s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 17s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
339 lines
14 KiB
Python
339 lines
14 KiB
Python
"""Issue #1714:POST /upload/direct/complete 幂等 + multipart 幂等。
|
||
|
||
覆盖:
|
||
- 同 client_upload_id 重复 complete → 只建一条 asset、不重复派 ingest job
|
||
- 同 file_hash 重复 complete → 返回已存在记录
|
||
- 旧客户端不传 hash/token:近期同库同名 processing 占位 → 兜底幂等返回
|
||
- 旧客户端不传 hash/token:READY 历史同名 → 不兜底(正常新建)
|
||
- 兜底窗口外(>30 分钟)→ 不兜底
|
||
- 旧仓储(无新方法)鸭子类型降级 → 不报错、正常新建
|
||
- 重复 complete 时即使 OSS 已无文件(file_exists=False)也返回已存在记录
|
||
(模拟 complete 超时后 OSS 侧对象已过期/清理,重试仍不重复建库)
|
||
- multipart 上传:同 client_upload_id 重复提交 → 第二次直接 duplicated,不再传 OSS
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
from unittest.mock import MagicMock
|
||
|
||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||
|
||
from fastapi import FastAPI # noqa: E402
|
||
from fastapi.testclient import TestClient # noqa: E402
|
||
|
||
from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, IngestJob, Project # noqa: E402
|
||
|
||
|
||
class StubProjectRepository:
|
||
def __init__(self, projects: dict | None = None):
|
||
self._projects = projects or {}
|
||
|
||
def get(self, project_id: str):
|
||
return self._projects.get(project_id)
|
||
|
||
def find_by_id(self, project_id: str):
|
||
return self._projects.get(project_id)
|
||
|
||
|
||
class StubAssetLibraryRepository:
|
||
def __init__(self, libraries: dict | None = None):
|
||
self._libraries = libraries or {}
|
||
|
||
def find_by_project(self, project_id: str, kind=None) -> list:
|
||
return list(self._libraries.values())
|
||
|
||
|
||
class StubAssetRepository:
|
||
"""支持三种幂等查询的内存仓储,并统计 create 次数。"""
|
||
|
||
def __init__(self, assets: list[Asset] | None = None):
|
||
self._assets = list(assets or [])
|
||
self.created: list[Asset] = []
|
||
|
||
def find_by_library_and_file_hash(self, library_id: str, file_hash: str) -> Asset | None:
|
||
if not file_hash:
|
||
return None
|
||
return next((a for a in self._assets if a.library_id == library_id and a.file_hash == file_hash), None)
|
||
|
||
def find_by_library_and_client_upload_id(self, library_id: str, client_upload_id: str) -> Asset | None:
|
||
if not client_upload_id:
|
||
return None
|
||
return next(
|
||
(a for a in self._assets if a.library_id == library_id and a.client_upload_id == client_upload_id),
|
||
None,
|
||
)
|
||
|
||
def find_recent_active_by_library_and_name(
|
||
self, library_id: str, name: str, within_minutes: int = 30, file_size: int = 0
|
||
) -> Asset | None:
|
||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=within_minutes)
|
||
candidates = [
|
||
a
|
||
for a in self._assets
|
||
if a.library_id == library_id
|
||
and a.name == name
|
||
and a.status in (AssetStatus.UPLOADING, AssetStatus.PROCESSING)
|
||
and a.created_at >= cutoff
|
||
and (not file_size or a.file_size == file_size)
|
||
]
|
||
return max(candidates, key=lambda a: a.created_at) if candidates else None
|
||
|
||
def create(self, asset: Asset) -> Asset:
|
||
self._assets.append(asset)
|
||
self.created.append(asset)
|
||
return asset
|
||
|
||
def update(self, asset: Asset) -> Asset:
|
||
return asset
|
||
|
||
|
||
class LegacyStubAssetRepository:
|
||
"""旧仓储:只有 file_hash 去重,没有新方法(鸭子类型降级验证)。"""
|
||
|
||
def __init__(self, assets: list[Asset] | None = None):
|
||
self._assets = list(assets or [])
|
||
self.created: list[Asset] = []
|
||
|
||
def find_by_library_and_file_hash(self, library_id: str, file_hash: str) -> Asset | None:
|
||
if not file_hash:
|
||
return None
|
||
return next((a for a in self._assets if a.library_id == library_id and a.file_hash == file_hash), None)
|
||
|
||
def create(self, asset: Asset) -> Asset:
|
||
self._assets.append(asset)
|
||
self.created.append(asset)
|
||
return asset
|
||
|
||
|
||
class StubIngestJobRepository:
|
||
def __init__(self):
|
||
self._jobs: dict[str, IngestJob] = {}
|
||
self.created_count = 0
|
||
|
||
def create(self, job: IngestJob) -> IngestJob:
|
||
self._jobs[job.id] = job
|
||
self.created_count += 1
|
||
return job
|
||
|
||
def get(self, job_id: str) -> IngestJob | None:
|
||
return self._jobs.get(job_id)
|
||
|
||
def update(self, job: IngestJob) -> IngestJob:
|
||
self._jobs[job.id] = job
|
||
return job
|
||
|
||
|
||
def _make_project() -> Project:
|
||
return Project(id="proj-1", name="Test Project", owner_user_id="user-1")
|
||
|
||
|
||
def _make_library() -> AssetLibrary:
|
||
return AssetLibrary(id="lib-1", name="Test Library", project_id="proj-1", kind=AssetLibraryKind.VIDEO)
|
||
|
||
|
||
def _build_app(asset_repo=None, ingest_repo=None, storage=None):
|
||
from app.api.routes.upload import router
|
||
from app.auth import AuthenticatedUser, get_current_user
|
||
from app.core.storage import get_storage_service
|
||
from app.dependencies import (
|
||
get_asset_library_repository,
|
||
get_asset_repository,
|
||
get_ingest_job_repository,
|
||
get_project_repository,
|
||
)
|
||
|
||
app = FastAPI()
|
||
app.include_router(router, prefix="/api/v1")
|
||
|
||
project_repo = StubProjectRepository({"proj-1": _make_project()})
|
||
library_repo = StubAssetLibraryRepository({"lib-1": _make_library()})
|
||
asset_repo = asset_repo or StubAssetRepository()
|
||
ingest_repo = ingest_repo or StubIngestJobRepository()
|
||
|
||
storage = storage or MagicMock()
|
||
storage.is_configured = True
|
||
storage._normalize_storage_key = lambda key: key
|
||
storage.file_exists = MagicMock(return_value=True)
|
||
storage.upload_file = MagicMock(return_value="https://oss.example.com/file.mp4")
|
||
storage.get_url = MagicMock(return_value="https://oss.example.com/file.mp4")
|
||
|
||
mock_user = MagicMock(spec=AuthenticatedUser)
|
||
mock_user.id = "user-1"
|
||
mock_user.user = MagicMock(id="user-1")
|
||
mock_user.email = "test@example.com"
|
||
|
||
app.dependency_overrides[get_current_user] = lambda: mock_user
|
||
app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||
app.dependency_overrides[get_asset_library_repository] = lambda: library_repo
|
||
app.dependency_overrides[get_asset_repository] = lambda: asset_repo
|
||
app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo
|
||
app.dependency_overrides[get_storage_service] = lambda: storage
|
||
return app, asset_repo, ingest_repo, storage
|
||
|
||
|
||
def _client(**kwargs):
|
||
app, asset_repo, ingest_repo, storage = _build_app(**kwargs)
|
||
return TestClient(app), asset_repo, ingest_repo, storage
|
||
|
||
|
||
COMPLETE_BODY = {
|
||
"project_id": "proj-1",
|
||
"library_id": "lib-1",
|
||
"storage_key": "uploads/abc/IMG_2282.MOV",
|
||
}
|
||
|
||
|
||
class TestDirectCompleteIdempotency:
|
||
def test_same_client_upload_id_creates_single_asset_and_job(self):
|
||
"""同一 client_upload_id 连发两次 complete:只建 1 条 asset、1 个 job。"""
|
||
client, asset_repo, ingest_repo, _ = _client()
|
||
body = {**COMPLETE_BODY, "client_upload_id": "up-token-1", "file_size": 12345}
|
||
|
||
r1 = client.post("/api/v1/direct/complete", json=body)
|
||
r2 = client.post("/api/v1/direct/complete", json={**body, "storage_key": "uploads/zzz/IMG_2282.MOV"})
|
||
|
||
assert r1.status_code == 200 and r2.status_code == 200
|
||
b1, b2 = r1.json(), r2.json()
|
||
assert b1["duplicated"] is False
|
||
assert b2["duplicated"] is True
|
||
assert b1["asset_id"] == b2["asset_id"]
|
||
assert len(asset_repo.created) == 1
|
||
assert ingest_repo.created_count == 1
|
||
# 第二次返回的是已存在记录(其 storage_key 为第一次的 key)
|
||
assert b2["storage_key"] == "uploads/abc/IMG_2282.MOV"
|
||
|
||
def test_same_file_hash_returns_existing(self):
|
||
"""同 file_hash(不同 token)重复 complete → 返回已存在记录。"""
|
||
client, asset_repo, ingest_repo, _ = _client()
|
||
body1 = {**COMPLETE_BODY, "file_hash": "h" * 32, "client_upload_id": "tok-a"}
|
||
body2 = {
|
||
**COMPLETE_BODY,
|
||
"storage_key": "uploads/def/IMG_2282.MOV",
|
||
"file_hash": "h" * 32,
|
||
"client_upload_id": "tok-b",
|
||
}
|
||
|
||
client.post("/api/v1/direct/complete", json=body1)
|
||
r2 = client.post("/api/v1/direct/complete", json=body2)
|
||
|
||
assert r2.json()["duplicated"] is True
|
||
assert len(asset_repo.created) == 1
|
||
assert ingest_repo.created_count == 1
|
||
|
||
def test_fallback_dedup_when_no_hash_no_token(self):
|
||
"""旧客户端不传 hash/token:近期同库同名 processing 占位 → 兜底幂等。
|
||
|
||
模拟 complete 超时重试:第一次已建好占位,第二次(OSS 重传拿到新 key)
|
||
不应再建第二条。
|
||
"""
|
||
client, asset_repo, ingest_repo, _ = _client()
|
||
# 第一次 complete(旧客户端无 token/hash)
|
||
r1 = client.post("/api/v1/direct/complete", json=COMPLETE_BODY)
|
||
assert r1.json()["duplicated"] is False
|
||
# 重试:重新 prepare 产生新 storage_key(仅 uuid 目录不同,文件名一致——
|
||
# 前端重试传的是同一个 File),且近期
|
||
r2 = client.post(
|
||
"/api/v1/direct/complete",
|
||
json={**COMPLETE_BODY, "storage_key": "uploads/retry/IMG_2282.MOV", "file_size": 0},
|
||
)
|
||
assert r2.status_code == 200
|
||
assert r2.json()["duplicated"] is True
|
||
assert r2.json()["asset_id"] == r1.json()["asset_id"]
|
||
assert len(asset_repo.created) == 1
|
||
assert ingest_repo.created_count == 1
|
||
|
||
def test_fallback_dedup_ignores_ready_history(self):
|
||
"""READY 历史同名素材不触发兜底(允许用户再次上传同名文件)。"""
|
||
ready = Asset(
|
||
id="ready-1",
|
||
project_id="proj-1",
|
||
library_id="lib-1",
|
||
name="IMG_2282.MOV",
|
||
storage_key="uploads/old/IMG_2282.MOV",
|
||
mime_type="video/quicktime",
|
||
status=AssetStatus.READY,
|
||
)
|
||
client, asset_repo, ingest_repo, _ = _client(asset_repo=StubAssetRepository([ready]))
|
||
r = client.post("/api/v1/direct/complete", json=COMPLETE_BODY)
|
||
assert r.status_code == 200
|
||
assert r.json()["duplicated"] is False
|
||
assert len(asset_repo.created) == 1
|
||
|
||
def test_fallback_dedup_window_expired(self):
|
||
"""占位记录超过 30 分钟 → 不再兜底(视为孤儿,正常新建)。"""
|
||
stale = Asset(
|
||
id="stale-1",
|
||
project_id="proj-1",
|
||
library_id="lib-1",
|
||
name="IMG_2282.MOV",
|
||
storage_key="uploads/stale/IMG_2282.MOV",
|
||
mime_type="video/quicktime",
|
||
status=AssetStatus.PROCESSING,
|
||
)
|
||
stale.created_at = datetime.now(timezone.utc) - timedelta(minutes=45)
|
||
client, asset_repo, ingest_repo, _ = _client(asset_repo=StubAssetRepository([stale]))
|
||
r = client.post("/api/v1/direct/complete", json=COMPLETE_BODY)
|
||
assert r.status_code == 200
|
||
assert r.json()["duplicated"] is False
|
||
assert len(asset_repo.created) == 1
|
||
|
||
def test_legacy_repo_without_new_methods_still_works(self):
|
||
"""旧仓储没有新幂等方法 → 鸭子类型降级,不报错、正常创建。"""
|
||
client, asset_repo, ingest_repo, _ = _client(asset_repo=LegacyStubAssetRepository())
|
||
r = client.post(
|
||
"/api/v1/direct/complete",
|
||
json={**COMPLETE_BODY, "client_upload_id": "tok-x", "file_hash": "f" * 32},
|
||
)
|
||
assert r.status_code == 200
|
||
assert r.json()["duplicated"] is False
|
||
assert len(asset_repo.created) == 1
|
||
|
||
def test_duplicate_complete_returns_existing_even_if_oss_missing(self):
|
||
"""重复 complete 幂等检查先于 OSS file_exists:
|
||
|
||
第一次成功建占位后,重试时即使 OSS 对象已不存在(file_exists=False),
|
||
也必须返回已存在记录而不是 404/重复建库。"""
|
||
client, _, _, storage = _client()
|
||
body = {**COMPLETE_BODY, "client_upload_id": "tok-oss-gone"}
|
||
r1 = client.post("/api/v1/direct/complete", json=body)
|
||
assert r1.status_code == 200
|
||
|
||
storage.file_exists = MagicMock(return_value=False)
|
||
r2 = client.post(
|
||
"/api/v1/direct/complete",
|
||
json={**body, "storage_key": "uploads/retry2/IMG_2282.MOV"},
|
||
)
|
||
assert r2.status_code == 200
|
||
assert r2.json()["duplicated"] is True
|
||
assert r2.json()["asset_id"] == r1.json()["asset_id"]
|
||
|
||
|
||
class TestMultipartUploadIdempotency:
|
||
def test_same_client_upload_id_second_submit_deduplicated(self):
|
||
"""multipart 重复提交同 token:第二次直接 duplicated,不再上传 OSS。"""
|
||
client, asset_repo, ingest_repo, storage = _client()
|
||
|
||
def _post():
|
||
return client.post(
|
||
"/api/v1",
|
||
data={"project_id": "proj-1", "library_id": "lib-1", "client_upload_id": "mp-tok-1"},
|
||
files={"file": ("IMG_2282.MOV", b"fake-mov-data", "video/quicktime")},
|
||
)
|
||
|
||
r1 = _post()
|
||
r2 = _post()
|
||
assert r1.json()["duplicated"] is False
|
||
assert r2.json()["duplicated"] is True
|
||
assert r2.json()["asset_id"] == r1.json()["asset_id"]
|
||
assert len(asset_repo.created) == 1
|
||
assert ingest_repo.created_count == 1
|
||
# OSS 上传只发生一次(第二次在幂等检查处直接返回)
|
||
assert storage.upload_file.call_count == 1
|