feat(uploads): 本地磁盘直传(COS 降级路线)+ 报告照片存在性校验
CI / lint / typecheck / import-linter / tests / secrets (push) Canceled after 0s

- POST /uploads/direct:原始字节 body + Content-Type 申报 + scene query(默认 report_photo),场景/类型/大小三重校验复用 storage 既有规则,落盘 var/uploads/<scene>/<user_id>/<uuid>.<ext>(config 新增 upload_dir),key 布局与 COS 同构;不加 python-multipart 依赖
- storage/local.py:save_direct_upload + require_local_upload(resolve 归并 + is_relative_to 归属收敛 + 存在性校验,越界/模拟/不存在一律 404)
- report/service.create_report:资格校验后、配额预扣前插入照片存在性校验(模拟 key 被拦且不消耗额度;regenerate 复用已验 photo_meta 不复核)
- 测试:test_upload_direct 14 单测 + test_upload_direct_flow 4 集成(真 key 全链路/假 key 404 零流水/401/40010)

偏差说明:test_report_flow._create_report 改走 /uploads/direct 拿真 key(存在性校验必然封死假 key),40920 与幂等重放两条断言语义未动。
This commit is contained in:
xsl
2026-09-12 00:24:01 +08:00
parent 76a0502ea3
commit a1d0171fc5
7 changed files with 499 additions and 5 deletions
+24 -2
View File
@@ -4,14 +4,20 @@
字节流由客户端直传 COS。真实联调需在 `.env` 配 COS 四件套 字节流由客户端直传 COS。真实联调需在 `.env` 配 COS 四件套
(COS_REGION / COS_SECRET_ID / COS_SECRET_KEY + 场景桶配置 (COS_REGION / COS_SECRET_ID / COS_SECRET_KEY + 场景桶配置
UPLOAD_SCENES_JSON);本地单测注入 FakeSigner,不依赖任何真实 COS。 UPLOAD_SCENES_JSON);本地单测注入 FakeSigner,不依赖任何真实 COS。
``POST /uploads/direct`` — 本地磁盘直传(无 COS 四件套的降级路线,docs
提交 9228f67 决策):body 即图片原始字节,Content-Type 头申报类型,
scene 走 query(默认 report_photo)。校验口径与 presign 完全一致,
落盘 ``settings.upload_dir``,返回的 object_key 供建报告等接口回传。
""" """
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter from fastapi import APIRouter, Query, Request
from apps.api.deps import CurrentUserId, StorageServiceDep from apps.api.deps import CurrentUserId, SettingsDep, StorageServiceDep
from packages.shared.response import ok from packages.shared.response import ok
from packages.storage.local import save_direct_upload
from packages.storage.schemas import PresignRequest from packages.storage.schemas import PresignRequest
router = APIRouter(prefix="/uploads", tags=["uploads"]) router = APIRouter(prefix="/uploads", tags=["uploads"])
@@ -25,3 +31,19 @@ async def presign_upload(
) -> dict[str, object]: ) -> dict[str, object]:
presigned = storage.build_presign(user_id=user_id, request=request) presigned = storage.build_presign(user_id=user_id, request=request)
return ok(presigned.model_dump()) return ok(presigned.model_dump())
@router.post("/direct", summary="本地磁盘直传图片(无 COS 降级路线):body 为原始字节")
async def direct_upload(
user_id: CurrentUserId,
settings: SettingsDep,
request: Request,
scene: str = Query(default="report_photo", min_length=1, max_length=64),
) -> dict[str, object]:
data = await request.body()
# Content-Type 头可能带参数(如 "image/jpeg; boundary=…"),只取主类型。
content_type = request.headers.get("content-type", "").split(";")[0].strip().lower()
result = save_direct_upload(
settings, user_id=user_id, scene=scene, content_type=content_type, data=data
)
return ok(result.model_dump())
+6 -1
View File
@@ -17,7 +17,12 @@ class ReportCreateRequest(BaseModel):
default_factory=dict, description="问卷答案;字段结构由产品定稿后在路由层收紧" default_factory=dict, description="问卷答案;字段结构由产品定稿后在路由层收紧"
) )
photo_object_key: str = Field( photo_object_key: str = Field(
min_length=1, max_length=255, description="临时桶里已上传照片的对象键(report_photo 场景)" min_length=1,
max_length=255,
description=(
"已上传照片的对象键(report_photo 场景);本地降级路线下为 "
"POST /uploads/direct 返回的相对路径,建报告时做存在性校验"
),
) )
request_key: str | None = Field( request_key: str | None = Field(
default=None, max_length=128, description="幂等键;相同键返回同一任务且不重复扣配额" default=None, max_length=128, description="幂等键;相同键返回同一任务且不重复扣配额"
+9
View File
@@ -22,6 +22,7 @@ from packages.shared.config import Settings
from packages.shared.errors import AppError, ErrorCode, ForbiddenError from packages.shared.errors import AppError, ErrorCode, ForbiddenError
from packages.shared.logging import get_logger from packages.shared.logging import get_logger
from packages.shared.models import RpInput, RpReport, Task from packages.shared.models import RpInput, RpReport, Task
from packages.storage.local import require_local_upload
from packages.task import repo as task_repo from packages.task import repo as task_repo
from packages.task.service import TaskService from packages.task.service import TaskService
@@ -101,6 +102,14 @@ class ReportService:
if await repo.find_succeeded_basic(self._session, user_id=user_id) is not None: if await repo.find_succeeded_basic(self._session, user_id=user_id) is not None:
raise AppError(ErrorCode.REPORT_ALREADY_EXISTS) raise AppError(ErrorCode.REPORT_ALREADY_EXISTS)
# 照片存在性校验(本地磁盘降级路线):key 必须落在
# upload_dir/report_photo/<本人>/ 下且文件真实存在——模拟 key 与越界
# key 一律 404。校验在配额预扣之前,不合格的照片不消耗额度。
# regenerate 不复核:它复用的是上一版已验过的 photo_meta(T-P3-09)。
require_local_upload(
self._settings, user_id=user_id, scene="report_photo", object_key=photo_object_key
)
report = RpReport( report = RpReport(
report_id=next_id(), report_id=next_id(),
user_id=user_id, user_id=user_id,
+94
View File
@@ -0,0 +1,94 @@
"""本地磁盘直传 —— 无 COS 四件套时的降级路线(docs 提交 9228f67 决策)。
与 presign 共用场景/类型/大小三重校验口径(同 40010 错误码),但字节流经
应用服务器落盘 ``settings.upload_dir``;object_key 为相对该目录的 POSIX 路径
``{scene}/{user_id}/{uuid4hex}.{ext}``,与 COS 布局同构——后续切回 COS 直传
只换写入实现,key 语义与下游(报告链路的 photo_object_key)不变。
依赖纪律:仓库未引入 python-multipart(且不加新依赖),服务端不收
multipart 表单;路由层以「body 即图片字节、Content-Type 头申报类型、
scene 走 query」的原始字节口径对接本模块。
"""
from __future__ import annotations
from pathlib import Path
from uuid import uuid4
from pydantic import BaseModel, Field
from packages.shared.config import Settings
from packages.shared.errors import AppError, ErrorCode, NotFoundError
from packages.shared.logging import get_logger
from packages.storage.service import _CONTENT_TYPE_EXT, _parse_allowed_types
logger = get_logger(__name__)
class DirectUploadResponse(BaseModel):
"""本地直传结果:object_key 交给后续业务接口(如建报告)原样回传。"""
object_key: str = Field(description="相对 upload_dir 的对象键(scene/user_id/uuid.ext)")
size_bytes: int = Field(description="实际落盘字节数(服务端实测,非客户端自报)")
content_type: str = Field(description="回显申报的 Content-Type")
def save_direct_upload(
settings: Settings,
*,
user_id: int,
scene: str,
content_type: str,
data: bytes,
) -> DirectUploadResponse:
"""三重校验(场景/类型/大小)通过后落盘,与 presign 同码拒绝(40010)。
presign 只能信客户端自报的 size_bytes;直传拿到的是真实字节数,
空文件与超限在此一并拒绝。
"""
if scene not in settings.upload_scenes:
raise AppError(ErrorCode.UPLOAD_REJECTED, message="不支持的上传场景")
if content_type not in _parse_allowed_types(settings.upload_allowed_types):
raise AppError(ErrorCode.UPLOAD_REJECTED)
ext = _CONTENT_TYPE_EXT.get(content_type)
if ext is None:
raise AppError(ErrorCode.UPLOAD_REJECTED)
if not data or len(data) > settings.upload_max_bytes:
raise AppError(ErrorCode.UPLOAD_REJECTED)
# 文件名由服务端生成(uuid 防猜),object_key 不含任何客户端输入,
# 落盘路径因此天然无穿越面。
object_key = f"{scene}/{user_id}/{uuid4().hex}.{ext}"
path = Path(settings.upload_dir).resolve() / object_key
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
logger.info(
"direct_upload_saved",
scene=scene,
user_id=user_id,
object_key=object_key,
size_bytes=len(data),
)
return DirectUploadResponse(
object_key=object_key, size_bytes=len(data), content_type=content_type
)
def require_local_upload(
settings: Settings,
*,
user_id: int,
scene: str,
object_key: str,
) -> Path:
"""把 object_key 收敛到 upload_dir 内并校验存在性,挡模拟/越界 key。
「不存在」与「不是本人的」一律同报 404,不向调用方泄露目录结构;
resolve() 先归并 ``..`` 与绝对路径,再用 is_relative_to 判定归属目录。
"""
base = Path(settings.upload_dir).resolve()
owner_dir = base / scene / str(user_id)
target = (base / object_key).resolve()
if not target.is_relative_to(owner_dir) or not target.is_file():
raise NotFoundError("照片不存在或已过期,请重新上传")
return target
+17 -2
View File
@@ -133,6 +133,20 @@ async def _login(client: Any) -> tuple[dict[str, str], int]:
return {"Authorization": f"Bearer {data['access_token']}"}, int(data["user_id"]) return {"Authorization": f"Bearer {data['access_token']}"}, int(data["user_id"])
async def _upload_photo(client: Any, headers: dict[str, str]) -> str:
"""直传一张假 JPEG 拿真实 object_key(建报告对 photo_object_key 做存在性校验)。
字节内容不需要是真图片:上传链路只校验类型/大小,不嗅探内容。
"""
resp = await client.post(
"/api/v1/uploads/direct?scene=report_photo",
headers={**headers, "Content-Type": "image/jpeg"},
content=b"\xff\xd8\xff\xe0" + b"\x00" * 64,
)
assert resp.status_code == 200
return str(resp.json()["data"]["object_key"])
async def _create_report( async def _create_report(
client: Any, headers: dict[str, str], *, request_key: str | None = None client: Any, headers: dict[str, str], *, request_key: str | None = None
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -141,7 +155,7 @@ async def _create_report(
headers=headers, headers=headers,
json={ json={
"questionnaire": {"style": "通勤", "height_cm": 168}, "questionnaire": {"style": "通勤", "height_cm": 168},
"photo_object_key": "report_photo/u1.jpg", "photo_object_key": await _upload_photo(client, headers),
"request_key": request_key or f"rk-{uuid.uuid4().hex}", "request_key": request_key or f"rk-{uuid.uuid4().hex}",
}, },
) )
@@ -223,7 +237,8 @@ async def test_create_reserves_lifetime_quota_and_creates_pending_task(
report = await _fetch_report(report_id) report = await _fetch_report(report_id)
assert report.status == "pending" assert report.status == "pending"
rp_input = await _fetch_input(report_id) rp_input = await _fetch_input(report_id)
assert rp_input.photo_meta == {"object_key": "report_photo/u1.jpg"} # photo_object_key 已由直传接口真实落盘(布局 report_photo/{user_id}/{uuid}.jpg)
assert str(rp_input.photo_meta["object_key"]).startswith(f"report_photo/{user_id}/")
assert rp_input.derived_features == {} assert rp_input.derived_features == {}
rows = await _ledger_rows(user_id, "report_basic") rows = await _ledger_rows(user_id, "report_basic")
@@ -0,0 +1,168 @@
"""本地直传 → 建报告全链路(无 COS 降级路线)。
dev-token 登录 → POST /uploads/direct 上传原始字节 → 拿真实 object_key →
POST /reports 建报告走通;模拟 key 被存在性校验以 404 挡住,且不消耗配额。
不启真 celery:API 投递钩子替换为 no-op(与 test_report_flow 同口径)。
"""
from __future__ import annotations
import os
import uuid
from collections.abc import AsyncIterator
from typing import Any
import pytest
def _infra_available() -> bool:
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
not _infra_available(),
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
),
]
JPEG_BYTES = b"\xff\xd8\xff\xe0" + b"\x00" * 64
@pytest.fixture
def no_enqueue(monkeypatch: pytest.MonkeyPatch) -> None:
"""不向真 Redis 投递:本用例只验证「上传 → 建报告」入口,不跑 worker。"""
monkeypatch.setattr("apps.api.routers.reports._enqueue_report", lambda task_id: None)
@pytest.fixture
async def client(no_enqueue: None) -> AsyncIterator[Any]:
import httpx
from apps.api.main import create_app
from packages.shared.config import get_settings
app = create_app(get_settings())
transport = httpx.ASGITransport(app=app)
async with (
httpx.AsyncClient(transport=transport, base_url="http://test") as http,
app.router.lifespan_context(app),
):
yield http
async def _login(client: Any) -> tuple[dict[str, str], int]:
"""dev-token:每个用例独立 device_key,拿到的就是全新用户。"""
resp = await client.post(
"/api/v1/auth/dev-token", json={"device_key": f"upload-it-{uuid.uuid4().hex[:8]}"}
)
assert resp.json()["code"] == 0
data = resp.json()["data"]
return {"Authorization": f"Bearer {data['access_token']}"}, int(data["user_id"])
async def _upload(client: Any, headers: dict[str, str]) -> str:
resp = await client.post(
"/api/v1/uploads/direct?scene=report_photo",
headers={**headers, "Content-Type": "image/jpeg"},
content=JPEG_BYTES,
)
assert resp.status_code == 200
return str(resp.json()["data"]["object_key"])
async def test_direct_upload_then_create_report_with_real_key(client: Any) -> None:
headers, user_id = await _login(client)
object_key = await _upload(client, headers)
assert object_key.startswith(f"report_photo/{user_id}/")
assert object_key.endswith(".jpg")
resp = await client.post(
"/api/v1/reports",
headers=headers,
json={
"questionnaire": {"style": "通勤"},
"photo_object_key": object_key,
"request_key": f"rk-{uuid.uuid4().hex}",
},
)
assert resp.status_code == 200
body = resp.json()["data"]
assert body["status"] == "pending"
# 落盘的输入行应记录真实 object_key。
from sqlalchemy import select
from packages.shared.db import session_scope
from packages.shared.models import RpInput
async with session_scope() as session:
result = await session.execute(
select(RpInput).where(RpInput.report_id == int(body["report_id"]))
)
rp_input = result.scalar_one()
assert rp_input.photo_meta == {"object_key": object_key}
async def test_create_report_with_mock_key_is_rejected_404_without_reserving_quota(
client: Any,
) -> None:
headers, user_id = await _login(client)
resp = await client.post(
"/api/v1/reports",
headers=headers,
json={
"questionnaire": {},
"photo_object_key": f"report_photo/{user_id}/mock123.jpg", # 布局合法的模拟键
"request_key": f"rk-{uuid.uuid4().hex}",
},
)
assert resp.status_code == 404
assert resp.json()["code"] == 40400
# 拒绝发生在配额预扣之前:终身配额流水必须为空。
from sqlalchemy import func, select
from packages.shared.db import session_scope
from packages.shared.models import Ledger
async with session_scope() as session:
result = await session.execute(
select(func.count())
.select_from(Ledger)
.where(Ledger.user_id == user_id, Ledger.quota_key == "report_basic")
)
assert int(result.scalar_one()) == 0
async def test_direct_upload_requires_auth(client: Any) -> None:
resp = await client.post(
"/api/v1/uploads/direct?scene=report_photo",
headers={"Content-Type": "image/jpeg"},
content=JPEG_BYTES,
)
assert resp.status_code == 401
assert resp.json()["code"] == 40100
async def test_direct_upload_rejects_disallowed_type_and_scene(client: Any) -> None:
headers, _ = await _login(client)
bad_type = await client.post(
"/api/v1/uploads/direct?scene=report_photo",
headers={**headers, "Content-Type": "application/pdf"},
content=JPEG_BYTES,
)
assert bad_type.status_code == 400
assert bad_type.json()["code"] == 40010
bad_scene = await client.post(
"/api/v1/uploads/direct?scene=evil",
headers={**headers, "Content-Type": "image/jpeg"},
content=JPEG_BYTES,
)
assert bad_scene.status_code == 400
assert bad_scene.json()["code"] == 40010
+181
View File
@@ -0,0 +1,181 @@
"""本地磁盘直传(无 COS 降级路线):三重校验 + 落盘布局 + 存在性校验。
零网络零 SDK:落盘用 tmp_path 隔离。require_local_upload 是报告链路挡
模拟/越界 photo_object_key 的关卡,穿越、他人目录、不存在三类逐条锁死。
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from packages.shared.config import Settings
from packages.shared.errors import AppError, ErrorCode
from packages.storage.local import require_local_upload, save_direct_upload
JPEG_BYTES = b"\xff\xd8\xff\xe0" + b"\x00" * 32
def make_settings(tmp_path: Path, **overrides: Any) -> Settings:
"""显式 kwargs 构造(与 test_upload_presign 同口径),测试不受 .env 影响。
upload_scenes 定义了 alias 且未开启 populate_by_name,必须按
``UPLOAD_SCENES_JSON`` 传入——按字段名传会被静默忽略。
"""
options: dict[str, Any] = {
"_env_file": None,
"upload_dir": str(tmp_path / "uploads"),
"upload_max_bytes": 1024,
"upload_allowed_types": "image/jpeg,image/png,image/webp",
"UPLOAD_SCENES_JSON": {"report_photo": {"bucket": "temp"}, "avatar": {"bucket": "temp"}},
}
options.update(overrides)
return Settings(**options)
# --- save_direct_upload -----------------------------------------------------
def test_save_writes_file_and_returns_auditable_key(tmp_path: Path) -> None:
settings = make_settings(tmp_path)
result = save_direct_upload(
settings, user_id=7, scene="report_photo", content_type="image/jpeg", data=JPEG_BYTES
)
# key 布局:{scene}/{user_id}/{uuid4hex}.{ext}(与 presign 同构,无日期段——
# 本地落盘没有 COS 生命周期规则需要按日分组)
prefix = "report_photo/7/"
assert result.object_key.startswith(prefix)
stem, ext = result.object_key.removeprefix(prefix).split(".")
assert ext == "jpg"
assert len(stem) == 32
assert all(char in "0123456789abcdef" for char in stem)
written = tmp_path / "uploads" / result.object_key
assert written.read_bytes() == JPEG_BYTES
assert result.size_bytes == len(JPEG_BYTES)
assert result.content_type == "image/jpeg"
def test_save_maps_content_type_to_extension(tmp_path: Path) -> None:
settings = make_settings(tmp_path)
result = save_direct_upload(
settings, user_id=1, scene="avatar", content_type="image/webp", data=JPEG_BYTES
)
assert result.object_key.endswith(".webp")
def test_save_rejects_unknown_scene(tmp_path: Path) -> None:
settings = make_settings(tmp_path)
with pytest.raises(AppError) as excinfo:
save_direct_upload(
settings, user_id=1, scene="evil", content_type="image/jpeg", data=JPEG_BYTES
)
assert excinfo.value.code is ErrorCode.UPLOAD_REJECTED
assert excinfo.value.message == "不支持的上传场景"
def test_save_rejects_disallowed_content_type(tmp_path: Path) -> None:
settings = make_settings(tmp_path)
with pytest.raises(AppError) as excinfo:
save_direct_upload(
settings,
user_id=1,
scene="report_photo",
content_type="application/pdf",
data=JPEG_BYTES,
)
assert excinfo.value.code is ErrorCode.UPLOAD_REJECTED
def test_save_rejects_oversize_and_empty(tmp_path: Path) -> None:
settings = make_settings(tmp_path) # upload_max_bytes=1024
with pytest.raises(AppError) as excinfo:
save_direct_upload(
settings,
user_id=1,
scene="report_photo",
content_type="image/jpeg",
data=b"\x00" * 1025,
)
assert excinfo.value.code is ErrorCode.UPLOAD_REJECTED
with pytest.raises(AppError) as excinfo:
save_direct_upload(
settings, user_id=1, scene="report_photo", content_type="image/jpeg", data=b""
)
assert excinfo.value.code is ErrorCode.UPLOAD_REJECTED
# 拒绝路径不落任何文件
assert not (tmp_path / "uploads").exists()
def test_save_size_equal_to_limit_is_allowed(tmp_path: Path) -> None:
settings = make_settings(tmp_path)
result = save_direct_upload(
settings,
user_id=1,
scene="report_photo",
content_type="image/png",
data=b"\x00" * 1024,
)
assert result.size_bytes == 1024
# --- require_local_upload ---------------------------------------------------
def test_require_roundtrip_returns_existing_file(tmp_path: Path) -> None:
settings = make_settings(tmp_path)
saved = save_direct_upload(
settings, user_id=7, scene="report_photo", content_type="image/jpeg", data=JPEG_BYTES
)
path = require_local_upload(
settings, user_id=7, scene="report_photo", object_key=saved.object_key
)
assert path.read_bytes() == JPEG_BYTES
def test_require_rejects_missing_file(tmp_path: Path) -> None:
settings = make_settings(tmp_path)
with pytest.raises(AppError) as excinfo:
require_local_upload(
settings, user_id=7, scene="report_photo", object_key="report_photo/7/ghost.jpg"
)
assert excinfo.value.code is ErrorCode.NOT_FOUND
def test_require_rejects_mock_key_without_upload(tmp_path: Path) -> None:
# 端侧历史模拟键 report_photo/{userId}/{随机}.jpg:布局合法但无落盘文件。
settings = make_settings(tmp_path)
with pytest.raises(AppError) as excinfo:
require_local_upload(
settings, user_id=7, scene="report_photo", object_key="report_photo/7/abc123.jpg"
)
assert excinfo.value.code is ErrorCode.NOT_FOUND
def test_require_rejects_other_users_key(tmp_path: Path) -> None:
settings = make_settings(tmp_path)
saved = save_direct_upload(
settings, user_id=8, scene="report_photo", content_type="image/jpeg", data=JPEG_BYTES
)
with pytest.raises(AppError) as excinfo:
require_local_upload(settings, user_id=7, scene="report_photo", object_key=saved.object_key)
assert excinfo.value.code is ErrorCode.NOT_FOUND
@pytest.mark.parametrize(
"evil_key",
[
"report_photo/7/../../avatar/8/x.jpg", # 路径穿越到他人场景
"report_photo/7/../../../../etc/passwd", # 穿越出 upload_dir
"/etc/passwd", # 绝对路径吞噬 base
"avatar/7/x.jpg", # 场景不匹配
],
)
def test_require_rejects_out_of_bounds_keys(tmp_path: Path, evil_key: str) -> None:
settings = make_settings(tmp_path)
with pytest.raises(AppError) as excinfo:
require_local_upload(settings, user_id=7, scene="report_photo", object_key=evil_key)
assert excinfo.value.code is ErrorCode.NOT_FOUND