"""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, )