Files
xsl 367b610e93 feat(storage): COS presigned PUT URL endpoint for direct upload
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。
2026-08-14 16:17:49 +08:00

32 lines
1.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 时必须携带同值")