# IdP 图标上传与清理契约 ## 1. Scope / Trigger - 适用于创建 SAML IdP 前,先把图标上传到 Identity Domains 公共图片存储的流程。 - 上传先于 IdP 创建,图片在创建成功前是临时远端资源;前端做**尽力清理**, 孤儿(自己租户里一张无入口小图)可接受,不为它建所有权状态机。 ## 2. Signatures ```text POST /api/v1/oci-configs/{id}/idp-icons?domainId= DELETE /api/v1/oci-configs/{id}/idp-icons?domainId=&fileName= ``` ```typescript uploadIdpIcon(id: number, file: File, domainId?: string): Promise deleteIdpIcon(id: number, fileName: string, domainId?: string): Promise interface IdpIconUpload { url: string // 写入 IdP iconUrl 的公网地址 fileName: string // 仅用于清理的存储标识 } ``` 上游 Identity Domains 契约(SDK 未覆盖,走 BaseClient 裸调): ```text POST /storage/v1/Images multipart: file + fileName DELETE /storage/v1/Images query: fileName ``` ## 3. Contracts - POST 的 `file` 是唯一文件字段;文件本体最大 `1 MiB`,由 service 精确限制; 路由级 body limit 为 multipart 封装开销另留余量,两者不是同一契约。 - 客户端原始文件名只用于取扩展名。真正上传名由服务端随机生成 `idp-icon-<32 lowercase hex>`:既避免并发/迟到响应下同名互相 覆盖,又让存储名成为不可猜测的清理凭据。 - 内容校验只做**扩展名白名单 + 文件头魔数嗅探**(png/jpg/jpeg/gif/webp/ico/svg), 不做深度解码与结构校验:图标由管理员为自己租户上传、由 Oracle 域名托管, 传错内容只会让自己登录页图标裂开,深度校验的复杂度与误伤承担不起 (2026-07 曾过度实现完整解码套件后精简)。 - DELETE 只接受单层 `images/idp-icon-<32 lowercase hex>.`, 不得因共享 `images/` 前缀删除其他域资产;上游 404 视为幂等成功,返回 204。 - 前端上传成功时保存 `{cfgId, domainId, url, fileName}` 原始元组作 pendingIcon, 用请求序号丢弃迟到响应;替换、放弃表单或提交未引用它时按元组尽力删除, 失败静默。创建请求引用了它且返回 2xx(含 `201 + setupWarning`)即视为已采用。 - IdP 创建是多步远端事务:创建后 JIT 映射失败先回滚删除刚创建的禁用 IdP; 回滚成功按普通失败,回滚失败/无法确认返回 `201` 和 `setupWarning{code: JIT_SETUP_INCOMPLETE, resourceCreated, requestId}`; 内部原因只按 requestId 写服务端日志,不进响应。 - 前端在身份设置加载完成前禁用提交;后端创建前再次读取域设置, `primaryEmailRequired=true` 强制 JIT 邮箱映射,查询失败时不创建 IdP。 ## 4. Validation & Error Matrix | 条件 | HTTP | | --- | --- | | 缺少 multipart `file`、文件为空或文件名非法 | 400 | | 文件本体超过 1 MiB | 413 | | 扩展名不支持或与文件头魔数不符 | 415 | | DELETE 缺少 `fileName` 或不是本服务生成的图标名 | 400 | | 上游 DELETE 返回 404 | 204 | | IdP 创建后 JIT 失败且回滚无法确认 | 201 + `setupWarning` | | 其余上游/内部错误 | 统一错误边界,不泄露上游响应正文 | ## 5. Tests / Verification Required - 完整路由:恰好 1 MiB → 200、1 MiB+1 → 413、空文件 → 400、伪造扩展名 → 415。 - 存储名:相同原始名连续上传得到不同随机名;熵源失败不调用上游; DELETE 拒绝非 IdP 前缀、子目录、错误 token 长度/大小写与非允许扩展。 - 魔数:各允许格式最小样本通过,扩展名与内容不符、未知扩展名被拒。 - 多步创建:初始创建失败、JIT 失败且回滚成功、回滚失败/ID 缺失三分支; 后两者分别锁定普通失败与 `setupWarning` 契约,响应不泄露底层 cause。 - 域设置:主邮箱必填强制映射、设置查询失败零创建、关闭 JIT 跳过查询。 ## 6. Wrong vs Correct ```typescript // Wrong:用清理时的 props 删旧图标——弹窗可能已切到别的租户/域。 onBeforeUnmount(() => deleteIdpIcon(props.cfgId, uploaded.fileName, props.domainId)) // Correct:上传时捕获原始元组,清理只按元组走,失败静默。 pendingIcon = { ...uploaded, cfgId, domainId } void deleteIdpIcon(pendingIcon.cfgId, pendingIcon.fileName, pendingIcon.domainId).catch(() => {}) ``` 反例(勿复现):为图标这种低价值临时资源实现「冻结所有权 + 结果未知协议 + 卸载钩子」的完整状态机,或对上传内容做完整解码/结构/主动内容校验—— 复杂度与威胁模型不匹配,已于 2026-07 精简移除。