# GCS 文件上传与存储设计 ## 背景 当前项目已经具备后端 NestJS 服务、前端 React 页面以及基础的认证能力,但尚未实现统一的文件上传和对象存储能力。现阶段后续多个业务 case 都会依赖文件上传,例如: - 发型模板参考图上传 - 顾客照片上传 - AI 模拟相关中间文件或结果图保存 因此,这个 case 需要提前建设一个“可复用的通用上传能力”,让后续业务不必各自重新设计文件上传流程。 本 case 的目标存储后端为 Google Cloud Storage,当前已明确以下配置: - `STORAGE_DRIVER=gcs` - `GCS_BUCKET=aitori-public` - `GCS_PROJECT_ID=app-aitori-jp` - `GCS_CLIENT_EMAIL=oss-227@app-aitori-jp.iam.gserviceaccount.com` - `GCS_PRIVATE_KEY=...` 用户还明确要求: - 前端未来可以通过 HTTP 接口上传文件 - 后端内部服务也可以直接调用上传能力 - GCS 对象路径格式固定为 `stylist/{type}/{filename}` - `type` 采用自由传字符串的方式,后续由具体业务 case 决定取值 - 最终数据库中优先存“可直接访问的完整 URL”,而不是只存对象路径后半段 ## 目标 - 实现基于 GCS 的通用文件上传能力。 - 提供一个可供前端调用的通用上传 HTTP 接口。 - 提供一个可供后端内部模块直接调用的服务层上传方法。 - 统一对象 key 规则为 `stylist/{type}/{filename}`。 - 服务端负责生成唯一文件名,避免冲突与原文件名污染。 - 上传成功后直接返回完整可访问 URL。 - 后续业务表默认存 `url`,减少前端和业务层自行拼接访问地址。 - 将 GCS 所需配置完整接入 `.env`、配置层和校验层。 ## 非目标 - 本 case 不做前端正式上传页面。 - 本 case 不做签名 URL 上传。 - 本 case 不做分片上传、大文件断点续传。 - 本 case 不实现多云存储切换界面。 - 本 case 不开发文件删除、覆盖、批量迁移。 - 本 case 不做复杂权限隔离或私有签名下载。 ## 设计原则 - 上传能力优先抽象为通用基础设施,而不是绑定单一业务模块。 - 统一由服务端决定最终对象 key 和文件名,不把命名规则散落到前端或业务层。 - `type` 保持自由输入,但必须经过安全校验,避免路径穿越和命名污染。 - 返回体以完整 URL 为主,`objectKey` 作为辅助信息保留。 - 后续业务表默认存 `url`,减少访问拼接逻辑和前后端重复认知。 - GCS SDK 调用细节应隔离在驱动层,业务模块不直接依赖 SDK。 - 未开发的能力显式不做,不提前引入签名 URL、删除策略或私有读链路。 ## 方案选择 本 case 最终采用“通用上传接口 + 可复用服务层 + GCS 驱动”方案。 ### 采用方案 - 对外提供 `POST /api/v1/storage/upload` - 对内提供统一 `StorageService` - 驱动层使用 GCS SDK 完成实际上传 这个方案的优点: - 同时满足前端上传和服务端内部调用 - 后续模板、顾客、模拟相关模块都能复用 - 控制器、服务层、驱动层职责清晰 ### 不采用方案 - 仅做服务层,不暴露 HTTP 接口: - 不能满足未来前端上传需要 - 前端直传 GCS + 签名 URL: - 当前复杂度过高,超出最小可用范围 - 每个业务模块单独做上传: - 会导致命名规则、路径规则、错误处理重复分散 ## 接口设计 ### 上传接口 - 路径: `POST /api/v1/storage/upload` - Content-Type: `multipart/form-data` 请求字段: - `file`: 必填,上传文件 - `type`: 必填,业务类型字符串 说明: - `type` 由具体业务调用方决定,例如未来可以是 `template`、`customer-photo`、`simulation-result` - 本 case 不对 `type` 做业务枚举约束 - 但会对其做安全校验,只允许安全路径片段 ### 成功响应 上传成功后返回统一响应结构: ```json { "ok": true, "data": { "url": "https://storage.googleapis.com/aitori-public/stylist/template/20260628T014500_ab12cd34.png", "objectKey": "stylist/template/20260628T014500_ab12cd34.png", "bucket": "aitori-public", "contentType": "image/png", "size": 123456 }, "error": null } ``` 字段说明: - `url`: 完整可访问 URL,后续业务表默认存这个字段 - `objectKey`: GCS 对象 key,供调试、清理、迁移或未来删除能力使用 - `bucket`: bucket 名称 - `contentType`: 文件内容类型 - `size`: 文件大小 ### 失败响应 失败场景统一走 `{ ok, data, error }` 结构。 示例: ```json { "ok": false, "data": null, "error": { "code": "STORAGE_INVALID_TYPE", "message": "上传类型不合法" } } ``` ## 路径与命名设计 ### 对象路径格式 GCS 对象 key 固定为: ```text stylist/{type}/{filename} ``` 其中: - `stylist` 为固定前缀 - `type` 由调用方提供 - `filename` 由服务端生成 ### type 规则 本 case 中 `type` 采用自由字符串传入,但必须满足安全校验: - 只允许字母 - 只允许数字 - 允许短横线 `-` - 允许下划线 `_` - 不允许空字符串 - 不允许 `../` - 不允许 `/` - 不允许 `\` - 不允许中文斜杠和其他危险分隔符 这样既保留业务灵活性,又避免目录穿越和不可控路径结构。 ### 文件名规则 最终文件名不保留原始文件名,而由服务端生成唯一文件名。 规则: - 保留原文件扩展名 - 文件主体由时间戳 + 随机串组成 - 示例: - `20260628T014500_ab12cd34.png` 这样可以避免: - 原文件名重名冲突 - 特殊字符和编码问题 - 业务表中出现不可控或含隐私的原始文件名 ## URL 存储设计 这是本 case 的关键约束之一。 上传完成后,服务端不仅要生成 `objectKey`,还要同步生成完整可访问 URL: ```text https://storage.googleapis.com/{bucket}/{objectKey} ``` 例如: ```text https://storage.googleapis.com/aitori-public/stylist/template/20260628T014500_ab12cd34.png ``` ### 数据库存储建议 后续业务表默认存: - `url` 不推荐只存: - `objectKey` 原因: - 前端展示时不需要再拼接访问地址 - 业务代码更少重复拼接前缀 - 能避免不同模块对“访问域名前缀”理解不一致 `objectKey` 仍建议作为接口返回值保留,用于: - 后续删除对象 - 数据迁移 - 调试和排错 但在当前阶段,业务表不强制额外存 `objectKey`。 ## 模块设计 建议新增独立 `storage` 模块。 ### StorageController 职责: - 提供 `POST /storage/upload` - 接收 multipart 文件 - 读取 `type` - 调用 `StorageService` - 返回统一响应结构 不负责: - GCS SDK 细节 - 文件路径生成细节 - 具体 URL 生成规则的分散实现 ### StorageService 职责: - 校验 `type` - 生成唯一文件名 - 组装 `objectKey` - 调用具体驱动完成上传 - 生成完整 URL - 返回标准上传结果 同时提供服务层方法,例如: - `uploadFile(...)` - `uploadBuffer(...)` 以便后续后端内部其他模块直接复用,而不必通过 HTTP 再调一次自己。 ### GcsStorageDriver 职责: - 初始化 GCS 客户端 - 基于 bucket 和对象 key 上传内容 - 设置内容类型 - 返回最小必要的底层结果 不负责: - 业务类型校验 - 文件名生成 - API 层错误文案 ## 配置设计 ### 新增环境变量 需要接入以下字段: - `STORAGE_DRIVER` - `GCS_BUCKET` - `GCS_PROJECT_ID` - `GCS_CLIENT_EMAIL` - `GCS_PRIVATE_KEY` ### 写入位置 - 开发环境值写入 `backend/.env` - 键名补充到 `backend/.env.example` ### 配置层要求 需要同步更新: - `backend/src/config/env.schema.ts` - `backend/src/config/app.config.ts` 目标是对外暴露一个统一 `storage` 配置对象,例如: - `storage.driver` - `storage.gcs.bucket` - `storage.gcs.projectId` - `storage.gcs.clientEmail` - `storage.gcs.privateKey` 说明: - `GCS_PRIVATE_KEY` 需要正确处理 `\n` 换行,保证最终传入 SDK 的是合法私钥内容 ## 错误处理语义 ### 参数错误 以下情况返回 `400`: - 缺少文件 - 缺少 `type` - `type` 不合法 - 文件扩展名缺失或无法解析时,如果当前实现要求必须保留扩展名 推荐错误码: - `STORAGE_FILE_REQUIRED` - `STORAGE_TYPE_REQUIRED` - `STORAGE_INVALID_TYPE` ### 配置错误 以下情况返回 `500`: - `STORAGE_DRIVER` 未配置 - 当前驱动值不支持 - GCS 配置缺失 推荐错误码: - `STORAGE_DRIVER_UNSUPPORTED` - `STORAGE_CONFIG_INVALID` ### 上传失败 GCS SDK 上传失败时返回统一上传失败错误。 推荐错误码: - `STORAGE_UPLOAD_FAILED` 要求: - 不把底层敏感错误信息原样透给前端 - 详细原因仅记录在服务端日志中 ## 后端内部调用设计 本 case 不只服务于 HTTP 上传。 后续其他业务模块应可以直接依赖服务层,例如: - 模板模块在保存参考图时直接调用上传 - 顾客模块在保存头像或档案照片时直接调用上传 - 模拟模块在生成结果图后直接调用上传 因此,服务层设计必须脱离 Express `multipart` 语义,支持接收: - buffer - contentType - type 这会让 HTTP 接口只是一个外壳,而不是唯一入口。 ## 测试策略 ### 单元测试 建议覆盖: - `type` 合法性校验 - 对象 key 生成规则 - URL 生成规则 - `GCS_PRIVATE_KEY` 换行处理 - `StorageService` 调用驱动上传的行为 ### e2e 测试 建议覆盖: - `POST /api/v1/storage/upload` 成功上传 - 缺文件返回 `400` - 非法 `type` 返回 `400` 说明: - 测试中 mock GCS 驱动或 GCS SDK - 不直接把测试文件真的传到线上 bucket ## 验收标准 - 后端存在一个可用的通用上传接口 `POST /api/v1/storage/upload` - 前端或任意 HTTP 客户端可以通过该接口成功上传文件 - 服务端内部模块可以直接调用统一上传服务 - `objectKey` 满足 `stylist/{type}/{serverGeneratedFilename}` 规则 - 服务端返回完整可访问 URL,而不是只返回后半段路径 - 后续业务表默认可以直接存 `url` - 非法 `type`、缺文件等错误能返回明确错误 - GCS 所需配置已接入 `.env`、`.env.example`、配置层和校验层 - 整个实现中不需要硬编码 GCS 凭据 ## 推荐的下一步 在本设计确认后,下一步进入实现计划阶段,拆分为: 1. 接入 GCS 依赖和环境变量配置。 2. 新建 `storage` 模块、服务层和 GCS 驱动。 3. 实现 `POST /api/v1/storage/upload`。 4. 为后端内部调用补充统一服务接口。 5. 添加单测和 e2e 测试。 6. 手工上传验证返回的 URL 可直接访问。