Files
meida_backend/packages/storage/local.py
T
xsl a1d0171fc5
CI / lint / typecheck / import-linter / tests / secrets (push) Canceled after 0s
feat(uploads): 本地磁盘直传(COS 降级路线)+ 报告照片存在性校验
- 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 与幂等重放两条断言语义未动。
2026-09-12 00:24:01 +08:00

95 lines
3.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""本地磁盘直传 —— 无 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