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 与幂等重放两条断言语义未动。
95 lines
3.7 KiB
Python
95 lines
3.7 KiB
Python
"""本地磁盘直传 —— 无 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
|