merge: T5 COS 直传预签名(POST /uploads/presign,FR-SYS-03)
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
"""COS 直传预签名的请求/响应契约(FR-SYS-03 / T-P1-27)。
|
||||
|
||||
放在 packages/storage 而非 apps/api/schemas:object_key 布局与场景语义属于
|
||||
存储域,报告、形象等业务域复用直传时不应该反向依赖 API 层。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PresignRequest(BaseModel):
|
||||
"""``POST /uploads/presign`` 请求体:客户端上传前的申报信息。
|
||||
|
||||
size_bytes 只是客户端自报值,仅用于服务端提前拒绝明显超限的请求;
|
||||
真正的强制约束靠预签名 URL 签入的 Content-Type 与 COS 桶侧限制兜底。
|
||||
"""
|
||||
|
||||
scene: str = Field(min_length=1, max_length=64, description="上传场景,如 report_photo/avatar")
|
||||
content_type: str = Field(min_length=1, max_length=128, description="MIME 类型,如 image/jpeg")
|
||||
size_bytes: int = Field(ge=1, description="文件字节数")
|
||||
|
||||
|
||||
class PresignResponse(BaseModel):
|
||||
"""签发结果:客户端用 PUT 直传 upload_url,body 与 Content-Type 必须与申报一致。"""
|
||||
|
||||
upload_url: str = Field(description="带签名的 COS PUT URL,过期前有效")
|
||||
object_key: str = Field(description="COS 对象键(可审计布局:scene/user/日期/uuid)")
|
||||
expires_in: int = Field(description="URL 有效期(秒),与签发时配置一致")
|
||||
max_bytes: int = Field(description="服务端允许的最大字节数,客户端用于前置校验")
|
||||
content_type: str = Field(description="回显申报的 Content-Type,PUT 时必须携带同值")
|
||||
+163
-3
@@ -1,11 +1,171 @@
|
||||
"""storage 域 service — 骨架(T5 任务填充实现)。"""
|
||||
"""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 typing import Any
|
||||
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:
|
||||
def __init__(self, settings: Any, signer: Any | None = None) -> None:
|
||||
"""签发 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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user