2026-06-28-gcs-upload-storage-design.md 10 KB

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 做业务枚举约束
  • 但会对其做安全校验,只允许安全路径片段

成功响应

上传成功后返回统一响应结构:

{
  "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 } 结构。

示例:

{
  "ok": false,
  "data": null,
  "error": {
    "code": "STORAGE_INVALID_TYPE",
    "message": "上传类型不合法"
  }
}

路径与命名设计

对象路径格式

GCS 对象 key 固定为:

stylist/{type}/{filename}

其中:

  • stylist 为固定前缀
  • type 由调用方提供
  • filename 由服务端生成

type 规则

本 case 中 type 采用自由字符串传入,但必须满足安全校验:

  • 只允许字母
  • 只允许数字
  • 允许短横线 -
  • 允许下划线 _
  • 不允许空字符串
  • 不允许 ../
  • 不允许 /
  • 不允许 \
  • 不允许中文斜杠和其他危险分隔符

这样既保留业务灵活性,又避免目录穿越和不可控路径结构。

文件名规则

最终文件名不保留原始文件名,而由服务端生成唯一文件名。

规则:

  • 保留原文件扩展名
  • 文件主体由时间戳 + 随机串组成
  • 示例:
    • 20260628T014500_ab12cd34.png

这样可以避免:

  • 原文件名重名冲突
  • 特殊字符和编码问题
  • 业务表中出现不可控或含隐私的原始文件名

URL 存储设计

这是本 case 的关键约束之一。

上传完成后,服务端不仅要生成 objectKey,还要同步生成完整可访问 URL:

https://storage.googleapis.com/{bucket}/{objectKey}

例如:

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 可直接访问。