feat(uploads): 本地磁盘直传(COS 降级路线)+ 报告照片存在性校验
CI / lint / typecheck / import-linter / tests / secrets (push) Canceled after 0s
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:
@@ -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="幂等键;相同键返回同一任务且不重复扣配额"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user