"""本地磁盘直传 —— 无 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