POST /uploads/presign:场景/类型/大小三重校验 + 可审计 object_key 布局 +
SDK 隔离在 CosSigner Protocol 后(FR-SYS-03,T-P1-27)。
实现:packages/storage/schemas.py(PresignRequest/PresignResponse)、
packages/storage/service.py(CosSigner Protocol / QcloudCosSigner /
StorageService.build_presign,bucket 四别名映射到 cos_bucket_*,
object_key 写死 {scene}/{user_id}/{yyyyMMdd}/{uuid4hex}.{ext})、
apps/api/routers/uploads.py(POST /presign,CurrentUserId 认证 + ok 信封)。
测试:tests/unit/test_upload_presign.py 16 例全部经 FakeSigner 注入,
零网络、零 SDK client 构造;覆盖 key 布局逐段断言、三重校验统一 40010、
bucket 别名映射、白名单字符串解析(空串/空格容错)、配置错误分支、
schema 层 size>=1。门禁全绿:ruff check/format、mypy(宽松 48 文件 +
domain strict 7 文件)、lint-imports 4 契约、pytest 185 passed
(基线 169 + 新增 16)。
偏差说明:
1) SDK 实际无 qcloud_cos.method 模块,Method 按官方文档传字符串 "PUT";
get_presigned_url 参数名为 Expired(非任务文档写的 Expire);配置类
导出名为 CosConfig(非 CosS3Config)。按文档 §3 "以实际 SDK 签名为准"
处理,已用假密钥端到端冒烟验证(纯本地签名计算,零网络)。
2) build_presign 增加可选 keyword-only 参数 now(默认 None=当前 UTC),
供单测注入固定时钟(对齐 DailyBudgetGuard 的 clock 注入惯例),
不改变文档签名的调用兼容性。
3) 两处防御分支文档未规定,均配了单测:场景 bucket 别名非法报
CONFIG_ERROR 而非 40010(配置错误不应让客户端背 4xx);COS 密钥为空
在 SDK 构造前报 CONFIG_ERROR,避免未处理的裸 500。
172 lines
7.3 KiB
Python
172 lines
7.3 KiB
Python
"""storage 域 service — COS 直传 PUT 预签名(FR-SYS-03 / T-P1-27)。
|
||
|
||
只做「签发」,不接收图片字节(图片经客户端直传 COS,应用服务器不中转);
|
||
上传后的内容校验属于报告链路(T-P3),不在本模块职责内。
|
||
|
||
COS SDK(qcloud_cos)被隔离在 :class:`CosSigner` Protocol 后面:业务逻辑与
|
||
单测都只面向 Protocol,:class:`QcloudCosSigner` 是全仓库唯一接触 SDK 的地方,
|
||
测试注入 FakeSigner 即可零网络验证全部校验与布局规则。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import UTC, datetime
|
||
from typing import Protocol
|
||
from uuid import uuid4
|
||
|
||
from qcloud_cos import CosConfig, CosS3Client
|
||
|
||
from packages.shared.config import Settings
|
||
from packages.shared.errors import AppError, ErrorCode
|
||
from packages.shared.logging import get_logger
|
||
from packages.storage.schemas import PresignRequest, PresignResponse
|
||
|
||
logger = get_logger(__name__)
|
||
|
||
# MIME → object_key 扩展名。新增可上传类型时,需同步 upload_allowed_types
|
||
# 默认值与错误码 UPLOAD_REJECTED 的 hint 文案。
|
||
_CONTENT_TYPE_EXT: dict[str, str] = {
|
||
"image/jpeg": "jpg",
|
||
"image/png": "png",
|
||
"image/webp": "webp",
|
||
}
|
||
|
||
# 场景配置里的 bucket 别名 → Settings 桶字段名。临时桶 2 小时生命周期(CR-02),
|
||
# 正式素材由异步链路搬运确认后才落长期桶,所以 V1 两个场景默认都指向 temp。
|
||
_BUCKET_FIELDS: dict[str, str] = {
|
||
"temp": "cos_bucket_temp",
|
||
"user": "cos_bucket_user",
|
||
"kb": "cos_bucket_kb",
|
||
"report": "cos_bucket_report",
|
||
}
|
||
|
||
|
||
class CosSigner(Protocol):
|
||
"""预签名能力的最小抽象:业务只依赖这个接口,不依赖具体云厂商。"""
|
||
|
||
def presign_put(self, bucket: str, key: str, expires: int, content_type: str) -> str:
|
||
"""返回带签名的 PUT URL;content_type 签入签名以锁定上传类型。"""
|
||
...
|
||
|
||
|
||
class QcloudCosSigner:
|
||
""":class:`CosSigner` 的腾讯云 COS 实现。
|
||
|
||
SDK 未提供独立的 Method 枚举模块,按其文档以字符串 ``"PUT"`` 传参;
|
||
``Expired``/``Headers`` 是 ``get_presigned_url`` 的真实参数名。
|
||
"""
|
||
|
||
def __init__(self, *, region: str, secret_id: str, secret_key: str) -> None:
|
||
# SDK 在密钥为空时抛的是裸 CosClientError(会以未处理异常变成 500 Internal),
|
||
# 提前映射为 CONFIG_ERROR,让本地未配 COS 四件套时得到结构化的配置错误。
|
||
if not secret_id or not secret_key:
|
||
raise AppError(ErrorCode.CONFIG_ERROR, message="COS 密钥未配置(COS_SECRET_ID/KEY)")
|
||
self._client = CosS3Client(
|
||
CosConfig(Region=region, SecretId=secret_id, SecretKey=secret_key)
|
||
)
|
||
|
||
def presign_put(self, bucket: str, key: str, expires: int, content_type: str) -> str:
|
||
# 签名是本地计算(不发网络请求);Content-Type 签入后,
|
||
# 客户端用其它类型 PUT 该 URL 会被 COS 直接拒绝。
|
||
# SDK 无类型标注,str() 只为向 mypy 收窄返回类型。
|
||
return str(
|
||
self._client.get_presigned_url(
|
||
Method="PUT",
|
||
Bucket=bucket,
|
||
Key=key,
|
||
Expired=expires,
|
||
Headers={"Content-Type": content_type},
|
||
)
|
||
)
|
||
|
||
|
||
def _parse_allowed_types(raw: str) -> frozenset[str]:
|
||
"""解析逗号分隔的 MIME 白名单,容忍空串与多余空格(配置是人工维护的)。"""
|
||
return frozenset(part.strip() for part in raw.split(",") if part.strip())
|
||
|
||
|
||
class StorageService:
|
||
"""签发 COS 直传预签名 URL,并守住场景/类型/大小三重服务端校验。"""
|
||
|
||
def __init__(self, settings: Settings, signer: CosSigner | None = None) -> None:
|
||
self._settings = settings
|
||
self._signer = signer
|
||
|
||
@property
|
||
def _active_signer(self) -> CosSigner:
|
||
# 惰性构造:只有真实签发请求才创建 COS client,进程启动/测试路径零开销;
|
||
# 测试必须注入 FakeSigner,避免任何 SDK 构造。
|
||
if self._signer is None:
|
||
self._signer = QcloudCosSigner(
|
||
region=self._settings.cos_region,
|
||
secret_id=self._settings.cos_secret_id,
|
||
secret_key=self._settings.cos_secret_key,
|
||
)
|
||
return self._signer
|
||
|
||
def build_presign(
|
||
self,
|
||
user_id: int,
|
||
request: PresignRequest,
|
||
*,
|
||
now: datetime | None = None,
|
||
) -> PresignResponse:
|
||
"""三重校验通过后签发 PUT 预签名 URL。
|
||
|
||
``now`` 仅用于测试注入固定时钟;生产调用不传,按当前 UTC 时间分桶。
|
||
"""
|
||
scene_config = self._settings.upload_scenes.get(request.scene)
|
||
if scene_config is None:
|
||
raise AppError(ErrorCode.UPLOAD_REJECTED, message="不支持的上传场景")
|
||
|
||
# 类型校验:白名单之外一律拒绝(与大小、场景共用错误码 40010)。
|
||
if request.content_type not in _parse_allowed_types(self._settings.upload_allowed_types):
|
||
raise AppError(ErrorCode.UPLOAD_REJECTED)
|
||
# 扩展名映射兜底:白名单被运维改成映射表之外的类型时按客户端错误拒绝,
|
||
# 而不是在拼 object_key 时抛 KeyError 变成 5xx。
|
||
ext = _CONTENT_TYPE_EXT.get(request.content_type)
|
||
if ext is None:
|
||
raise AppError(ErrorCode.UPLOAD_REJECTED)
|
||
|
||
# 大小校验:客户端自报值超限直接拒绝,省一次无谓的签名计算。
|
||
if request.size_bytes > self._settings.upload_max_bytes:
|
||
raise AppError(ErrorCode.UPLOAD_REJECTED)
|
||
|
||
# bucket 别名 → 四桶配置。别名写错属于部署配置问题,报 CONFIG_ERROR
|
||
# 而不是让客户端背 4xx。
|
||
bucket_alias = scene_config.get("bucket")
|
||
bucket_field = _BUCKET_FIELDS.get(bucket_alias) if isinstance(bucket_alias, str) else None
|
||
if bucket_field is None:
|
||
raise AppError(
|
||
ErrorCode.CONFIG_ERROR,
|
||
message=f"上传场景 {request.scene} 的 bucket 配置无效",
|
||
)
|
||
bucket: str = getattr(self._settings, bucket_field)
|
||
|
||
# object_key 布局写死为 {scene}/{user_id}/{yyyyMMdd}/{uuid4hex}.{ext}:
|
||
# 按场景分目录、按用户隔离、按日分组、uuid 防猜——这是 COS 侧做
|
||
# 前缀审计与生命周期规则(如临时桶 2h 清理)的基础,改动需过安全评审。
|
||
# 日期取 UTC,保证多实例写入的分组一致。
|
||
day = (now or datetime.now(UTC)).strftime("%Y%m%d")
|
||
object_key = f"{request.scene}/{user_id}/{day}/{uuid4().hex}.{ext}"
|
||
|
||
expires = self._settings.cos_presign_expire_seconds
|
||
url = self._active_signer.presign_put(
|
||
bucket=bucket, key=object_key, expires=expires, content_type=request.content_type
|
||
)
|
||
logger.info(
|
||
"presign_issued",
|
||
scene=request.scene,
|
||
user_id=user_id,
|
||
bucket=bucket,
|
||
object_key=object_key,
|
||
size_bytes=request.size_bytes,
|
||
)
|
||
return PresignResponse(
|
||
upload_url=url,
|
||
object_key=object_key,
|
||
expires_in=expires,
|
||
max_bytes=self._settings.upload_max_bytes,
|
||
content_type=request.content_type,
|
||
)
|