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
+6 -1
View File
@@ -17,7 +17,12 @@ class ReportCreateRequest(BaseModel):
default_factory=dict, description="问卷答案;字段结构由产品定稿后在路由层收紧"
)
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(
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.logging import get_logger
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.service import TaskService
@@ -101,6 +102,14 @@ class ReportService:
if await repo.find_succeeded_basic(self._session, user_id=user_id) is not None:
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_id=next_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