Procházet zdrojové kódy

merge: gcs upload storage

ChenYu před 4 měsíci
rodič
revize
6d9b1d25b1

+ 5 - 0
backend/.env.example

@@ -6,3 +6,8 @@ JWT_SECRET=replace-me
 JWT_EXPIRES_IN=7d
 CORS_ORIGIN=http://127.0.0.1:5173
 NODE_ENV=development
+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=-----BEGIN PRIVATE KEY-----\nreplace-me\n-----END PRIVATE KEY-----\n

+ 629 - 9
backend/package-lock.json

@@ -8,6 +8,7 @@
       "name": "aitori-backend",
       "version": "0.1.0",
       "dependencies": {
+        "@google-cloud/storage": "^7.21.0",
         "@nestjs/common": "^11.0.0",
         "@nestjs/config": "^4.0.2",
         "@nestjs/core": "^11.0.0",
@@ -28,6 +29,7 @@
         "@nestjs/testing": "^11.0.0",
         "@types/express": "^5.0.3",
         "@types/jest": "^30.0.0",
+        "@types/multer": "^2.1.0",
         "@types/node": "^22.0.0",
         "@types/supertest": "^6.0.2",
         "eslint": "^9.0.0",
@@ -926,6 +928,74 @@
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
       }
     },
+    "node_modules/@google-cloud/paginator": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz",
+      "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "arrify": "^2.0.0",
+        "extend": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/@google-cloud/projectify": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz",
+      "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/@google-cloud/promisify": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz",
+      "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/@google-cloud/storage": {
+      "version": "7.21.0",
+      "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.21.0.tgz",
+      "integrity": "sha512-l+IFTkd+6Y5LoAuXyYCKNAKtw/Ci+rAMqgdTB1jv4iZiLhw0rtq+0qjIRbBizXkNzEFmXiXUW0H7sZQQvk1ffA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@google-cloud/paginator": "^5.0.0",
+        "@google-cloud/projectify": "^4.0.0",
+        "@google-cloud/promisify": "<4.1.0",
+        "abort-controller": "^3.0.0",
+        "async-retry": "^1.3.3",
+        "duplexify": "^4.1.3",
+        "fast-xml-parser": "^5.3.4",
+        "gaxios": "^6.0.2",
+        "google-auth-library": "^9.6.3",
+        "html-entities": "^2.5.2",
+        "mime": "^3.0.0",
+        "p-limit": "^3.0.1",
+        "retry-request": "^7.0.0",
+        "teeny-request": "^9.0.0"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/@google-cloud/storage/node_modules/mime": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
+      "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
+      "license": "MIT",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
     "node_modules/@humanfs/core": {
       "version": "0.19.2",
       "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -2426,6 +2496,18 @@
         "url": "https://paulmillr.com/funding/"
       }
     },
+    "node_modules/@nodable/entities": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz",
+      "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/nodable"
+        }
+      ],
+      "license": "MIT"
+    },
     "node_modules/@paralleldrive/cuid2": {
       "version": "2.3.1",
       "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
@@ -2609,6 +2691,15 @@
       "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
       "license": "MIT"
     },
+    "node_modules/@tootallnate/once": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz",
+      "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 10"
+      }
+    },
     "node_modules/@tsconfig/node10": {
       "version": "1.0.12",
       "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
@@ -2704,6 +2795,12 @@
         "@types/node": "*"
       }
     },
+    "node_modules/@types/caseless": {
+      "version": "0.12.5",
+      "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz",
+      "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==",
+      "license": "MIT"
+    },
     "node_modules/@types/connect": {
       "version": "3.4.38",
       "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@@ -2850,6 +2947,16 @@
       "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
       "license": "MIT"
     },
+    "node_modules/@types/multer": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
+      "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/express": "*"
+      }
+    },
     "node_modules/@types/node": {
       "version": "22.20.0",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz",
@@ -2873,6 +2980,56 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@types/request": {
+      "version": "2.48.13",
+      "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz",
+      "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/caseless": "*",
+        "@types/node": "*",
+        "@types/tough-cookie": "*",
+        "form-data": "^2.5.5"
+      }
+    },
+    "node_modules/@types/request/node_modules/form-data": {
+      "version": "2.5.6",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz",
+      "integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==",
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.4",
+        "mime-types": "^2.1.35",
+        "safe-buffer": "^5.2.1"
+      },
+      "engines": {
+        "node": ">= 0.12"
+      }
+    },
+    "node_modules/@types/request/node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/@types/request/node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
     "node_modules/@types/send": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
@@ -2925,6 +3082,12 @@
         "@types/superagent": "^8.1.0"
       }
     },
+    "node_modules/@types/tough-cookie": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
+      "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
+      "license": "MIT"
+    },
     "node_modules/@types/validator": {
       "version": "13.15.10",
       "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
@@ -3725,6 +3888,18 @@
       "dev": true,
       "license": "Apache-2.0"
     },
+    "node_modules/abort-controller": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+      "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+      "license": "MIT",
+      "dependencies": {
+        "event-target-shim": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=6.5"
+      }
+    },
     "node_modules/accepts": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -3787,6 +3962,15 @@
         "node": ">=0.4.0"
       }
     },
+    "node_modules/agent-base": {
+      "version": "7.1.4",
+      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+      "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14"
+      }
+    },
     "node_modules/ajv": {
       "version": "8.18.0",
       "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
@@ -3924,6 +4108,18 @@
         "url": "https://github.com/sponsors/jonschlinkert"
       }
     },
+    "node_modules/anynum": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
+      "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/NaturalIntelligence"
+        }
+      ],
+      "license": "MIT"
+    },
     "node_modules/append-field": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
@@ -3950,6 +4146,15 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/arrify": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz",
+      "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/asap": {
       "version": "2.0.6",
       "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
@@ -3957,11 +4162,19 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/async-retry": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz",
+      "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==",
+      "license": "MIT",
+      "dependencies": {
+        "retry": "0.13.1"
+      }
+    },
     "node_modules/asynckit": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
       "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/babel-jest": {
@@ -4074,7 +4287,6 @@
       "version": "1.5.1",
       "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
       "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
-      "dev": true,
       "funding": [
         {
           "type": "github",
@@ -4113,6 +4325,15 @@
         "bcrypt": "bin/bcrypt"
       }
     },
+    "node_modules/bignumber.js": {
+      "version": "9.3.1",
+      "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
+      "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
+      "license": "MIT",
+      "engines": {
+        "node": "*"
+      }
+    },
     "node_modules/bl": {
       "version": "4.1.0",
       "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@@ -4655,7 +4876,6 @@
       "version": "1.0.8",
       "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
       "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "delayed-stream": "~1.0.0"
@@ -4940,7 +5160,6 @@
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
       "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
-      "dev": true,
       "license": "MIT",
       "engines": {
         "node": ">=0.4.0"
@@ -5055,6 +5274,18 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/duplexify": {
+      "version": "4.1.3",
+      "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
+      "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
+      "license": "MIT",
+      "dependencies": {
+        "end-of-stream": "^1.4.1",
+        "inherits": "^2.0.3",
+        "readable-stream": "^3.1.1",
+        "stream-shift": "^1.0.2"
+      }
+    },
     "node_modules/eastasianwidth": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -5134,6 +5365,15 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/end-of-stream": {
+      "version": "1.4.5",
+      "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+      "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+      "license": "MIT",
+      "dependencies": {
+        "once": "^1.4.0"
+      }
+    },
     "node_modules/enhanced-resolve": {
       "version": "5.24.1",
       "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.1.tgz",
@@ -5199,7 +5439,6 @@
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
       "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "es-errors": "^1.3.0",
@@ -5441,6 +5680,15 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/event-target-shim": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+      "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/events": {
       "version": "3.3.0",
       "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -5560,6 +5808,12 @@
       "devOptional": true,
       "license": "MIT"
     },
+    "node_modules/extend": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+      "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+      "license": "MIT"
+    },
     "node_modules/fast-check": {
       "version": "3.23.2",
       "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
@@ -5644,6 +5898,45 @@
       ],
       "license": "BSD-3-Clause"
     },
+    "node_modules/fast-xml-builder": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
+      "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/NaturalIntelligence"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "path-expression-matcher": "^1.5.0",
+        "xml-naming": "^0.1.0"
+      }
+    },
+    "node_modules/fast-xml-parser": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.9.3.tgz",
+      "integrity": "sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/NaturalIntelligence"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "@nodable/entities": "^2.2.0",
+        "fast-xml-builder": "^1.2.0",
+        "is-unsafe": "^1.0.1",
+        "path-expression-matcher": "^1.5.0",
+        "strnum": "^2.4.1",
+        "xml-naming": "^0.1.0"
+      },
+      "bin": {
+        "fxparser": "src/cli/cli.js"
+      }
+    },
     "node_modules/fb-watchman": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
@@ -5936,6 +6229,36 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/gaxios": {
+      "version": "6.7.1",
+      "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz",
+      "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "extend": "^3.0.2",
+        "https-proxy-agent": "^7.0.1",
+        "is-stream": "^2.0.0",
+        "node-fetch": "^2.6.9",
+        "uuid": "^9.0.1"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/gcp-metadata": {
+      "version": "6.1.1",
+      "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz",
+      "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "gaxios": "^6.1.1",
+        "google-logging-utils": "^0.0.2",
+        "json-bigint": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
     "node_modules/gensync": {
       "version": "1.0.0-beta.2",
       "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -6124,6 +6447,32 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/google-auth-library": {
+      "version": "9.15.1",
+      "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz",
+      "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "base64-js": "^1.3.0",
+        "ecdsa-sig-formatter": "^1.0.11",
+        "gaxios": "^6.1.1",
+        "gcp-metadata": "^6.1.0",
+        "gtoken": "^7.0.0",
+        "jws": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/google-logging-utils": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz",
+      "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=14"
+      }
+    },
     "node_modules/gopd": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -6143,6 +6492,19 @@
       "dev": true,
       "license": "ISC"
     },
+    "node_modules/gtoken": {
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz",
+      "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==",
+      "license": "MIT",
+      "dependencies": {
+        "gaxios": "^6.0.0",
+        "jws": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
     "node_modules/handlebars": {
       "version": "4.7.9",
       "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
@@ -6201,7 +6563,6 @@
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
       "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "has-symbols": "^1.0.3"
@@ -6225,6 +6586,22 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/html-entities": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
+      "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/mdevils"
+        },
+        {
+          "type": "patreon",
+          "url": "https://patreon.com/mdevils"
+        }
+      ],
+      "license": "MIT"
+    },
     "node_modules/html-escaper": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -6252,6 +6629,45 @@
         "url": "https://opencollective.com/express"
       }
     },
+    "node_modules/http-proxy-agent": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
+      "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==",
+      "license": "MIT",
+      "dependencies": {
+        "@tootallnate/once": "2",
+        "agent-base": "6",
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/http-proxy-agent/node_modules/agent-base": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+      "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6.0.0"
+      }
+    },
+    "node_modules/https-proxy-agent": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+      "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "^7.1.2",
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
     "node_modules/human-signals": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
@@ -6474,7 +6890,6 @@
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
       "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
-      "dev": true,
       "license": "MIT",
       "engines": {
         "node": ">=8"
@@ -6496,6 +6911,18 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/is-unsafe": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-1.0.1.tgz",
+      "integrity": "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/NaturalIntelligence"
+        }
+      ],
+      "license": "MIT"
+    },
     "node_modules/isexe": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -7376,6 +7803,15 @@
         "node": ">=6"
       }
     },
+    "node_modules/json-bigint": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
+      "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "bignumber.js": "^9.0.0"
+      }
+    },
     "node_modules/json-buffer": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -7983,6 +8419,26 @@
         "lodash": "^4.17.21"
       }
     },
+    "node_modules/node-fetch": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+      "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+      "license": "MIT",
+      "dependencies": {
+        "whatwg-url": "^5.0.0"
+      },
+      "engines": {
+        "node": "4.x || >=6.0.0"
+      },
+      "peerDependencies": {
+        "encoding": "^0.1.0"
+      },
+      "peerDependenciesMeta": {
+        "encoding": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/node-fetch-native": {
       "version": "1.6.7",
       "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
@@ -8166,7 +8622,6 @@
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
       "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "yocto-queue": "^0.1.0"
@@ -8262,6 +8717,21 @@
         "node": ">=8"
       }
     },
+    "node_modules/path-expression-matcher": {
+      "version": "1.6.1",
+      "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.1.tgz",
+      "integrity": "sha512-h7bxdzhHk8Knyc4Tj+jMaa7fEEoUJy7p1qtbVgkYg1Uhpe5Np5VuGXCRZnkZvU+Q42M1vStt0ifa3ueykRJPmQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/NaturalIntelligence"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
     "node_modules/path-is-absolute": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -8785,6 +9255,29 @@
       "dev": true,
       "license": "ISC"
     },
+    "node_modules/retry": {
+      "version": "0.13.1",
+      "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
+      "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 4"
+      }
+    },
+    "node_modules/retry-request": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz",
+      "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/request": "^2.48.8",
+        "extend": "^3.0.2",
+        "teeny-request": "^9.0.0"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
     "node_modules/router": {
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -9146,6 +9639,21 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/stream-events": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz",
+      "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==",
+      "license": "MIT",
+      "dependencies": {
+        "stubs": "^3.0.0"
+      }
+    },
+    "node_modules/stream-shift": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
+      "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
+      "license": "MIT"
+    },
     "node_modules/streamsearch": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
@@ -9268,6 +9776,21 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/strnum": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz",
+      "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/NaturalIntelligence"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "anynum": "^1.0.1"
+      }
+    },
     "node_modules/strtok3": {
       "version": "10.3.5",
       "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
@@ -9284,6 +9807,12 @@
         "url": "https://github.com/sponsors/Borewit"
       }
     },
+    "node_modules/stubs": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz",
+      "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==",
+      "license": "MIT"
+    },
     "node_modules/superagent": {
       "version": "10.3.0",
       "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
@@ -9398,6 +9927,47 @@
         "url": "https://opencollective.com/webpack"
       }
     },
+    "node_modules/teeny-request": {
+      "version": "9.0.0",
+      "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz",
+      "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "http-proxy-agent": "^5.0.0",
+        "https-proxy-agent": "^5.0.0",
+        "node-fetch": "^2.6.9",
+        "stream-events": "^1.0.5",
+        "uuid": "^9.0.0"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/teeny-request/node_modules/agent-base": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+      "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6.0.0"
+      }
+    },
+    "node_modules/teeny-request/node_modules/https-proxy-agent": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+      "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "6",
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
     "node_modules/terser": {
       "version": "5.48.0",
       "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz",
@@ -9673,6 +10243,12 @@
         "url": "https://github.com/sponsors/Borewit"
       }
     },
+    "node_modules/tr46": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+      "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+      "license": "MIT"
+    },
     "node_modules/ts-api-utils": {
       "version": "2.5.0",
       "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -10102,6 +10678,20 @@
       "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
       "license": "MIT"
     },
+    "node_modules/uuid": {
+      "version": "9.0.1",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
+      "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
+      "deprecated": "uuid@10 and below is no longer supported.  For ESM codebases, update to uuid@latest.  For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
+      "funding": [
+        "https://github.com/sponsors/broofa",
+        "https://github.com/sponsors/ctavan"
+      ],
+      "license": "MIT",
+      "bin": {
+        "uuid": "dist/bin/uuid"
+      }
+    },
     "node_modules/v8-compile-cache-lib": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
@@ -10175,6 +10765,12 @@
         "defaults": "^1.0.3"
       }
     },
+    "node_modules/webidl-conversions": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+      "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+      "license": "BSD-2-Clause"
+    },
     "node_modules/webpack": {
       "version": "5.106.2",
       "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz",
@@ -10305,6 +10901,16 @@
         "url": "https://opencollective.com/webpack"
       }
     },
+    "node_modules/whatwg-url": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+      "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+      "license": "MIT",
+      "dependencies": {
+        "tr46": "~0.0.3",
+        "webidl-conversions": "^3.0.0"
+      }
+    },
     "node_modules/which": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -10392,6 +10998,21 @@
         "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
       }
     },
+    "node_modules/xml-naming": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
+      "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/NaturalIntelligence"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=16.0.0"
+      }
+    },
     "node_modules/y18n": {
       "version": "5.0.8",
       "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@@ -10452,7 +11073,6 @@
       "version": "0.1.0",
       "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
       "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
-      "dev": true,
       "license": "MIT",
       "engines": {
         "node": ">=10"

+ 4 - 2
backend/package.json

@@ -29,12 +29,13 @@
     }
   },
   "dependencies": {
+    "@google-cloud/storage": "^7.21.0",
     "@nestjs/common": "^11.0.0",
     "@nestjs/config": "^4.0.2",
     "@nestjs/core": "^11.0.0",
     "@nestjs/jwt": "^11.0.1",
-    "@nestjs/swagger": "^11.2.0",
     "@nestjs/platform-express": "^11.0.0",
+    "@nestjs/swagger": "^11.2.0",
     "@prisma/client": "^6.10.1",
     "bcryptjs": "^3.0.2",
     "class-transformer": "^0.5.1",
@@ -49,14 +50,15 @@
     "@nestjs/testing": "^11.0.0",
     "@types/express": "^5.0.3",
     "@types/jest": "^30.0.0",
+    "@types/multer": "^2.1.0",
     "@types/node": "^22.0.0",
     "@types/supertest": "^6.0.2",
     "eslint": "^9.0.0",
     "jest": "^30.0.0",
     "prettier": "^3.3.3",
     "prisma": "^6.10.1",
-    "swagger-ui-express": "^5.0.1",
     "supertest": "^7.0.0",
+    "swagger-ui-express": "^5.0.1",
     "ts-jest": "^29.2.5",
     "ts-node": "^10.9.2",
     "typescript": "^5.6.3",

+ 2 - 0
backend/src/app.module.ts

@@ -6,6 +6,7 @@ import { AuthModule } from './modules/auth/auth.module';
 import { CustomersModule } from './modules/customers/customers.module';
 import { HealthModule } from './modules/health/health.module';
 import { SimulationsModule } from './modules/simulations/simulations.module';
+import { StorageModule } from './modules/storage/storage.module';
 import { TemplatesModule } from './modules/templates/templates.module';
 import { PrismaModule } from './prisma/prisma.module';
 import { RedisModule } from './redis/redis.module';
@@ -23,6 +24,7 @@ import { RedisModule } from './redis/redis.module';
     AuthModule,
     CustomersModule,
     HealthModule,
+    StorageModule,
     TemplatesModule,
     SimulationsModule,
   ],

+ 37 - 0
backend/src/config/app.config.spec.ts

@@ -0,0 +1,37 @@
+import appConfig from './app.config';
+import { envSchema } from './env.schema';
+
+describe('appConfig storage', () => {
+  it('validates gcs env keys and normalizes the private key newlines', () => {
+    const parsed = envSchema.parse({
+      PORT: '3000',
+      APP_BASE_URL: 'http://localhost:3000',
+      DATABASE_URL: 'mysql://root:123456@localhost:3306/aitori_stylist',
+      REDIS_URL: 'redis://:123456@localhost:6379',
+      JWT_SECRET: 'replace-me',
+      JWT_EXPIRES_IN: '7d',
+      CORS_ORIGIN: 'http://127.0.0.1:5173',
+      NODE_ENV: 'development',
+      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: 'line1\\nline2',
+    });
+
+    expect(parsed.STORAGE_DRIVER).toBe('gcs');
+    expect(parsed.GCS_BUCKET).toBe('aitori-public');
+
+    process.env.STORAGE_DRIVER = 'gcs';
+    process.env.GCS_BUCKET = 'aitori-public';
+    process.env.GCS_PROJECT_ID = 'app-aitori-jp';
+    process.env.GCS_CLIENT_EMAIL = 'oss-227@app-aitori-jp.iam.gserviceaccount.com';
+    process.env.GCS_PRIVATE_KEY = 'line1\\nline2';
+
+    const config = appConfig();
+
+    expect(config.storage.driver).toBe('gcs');
+    expect(config.storage.gcs.bucket).toBe('aitori-public');
+    expect(config.storage.gcs.privateKey).toBe('line1\nline2');
+  });
+});

+ 13 - 0
backend/src/config/app.config.ts

@@ -1,3 +1,7 @@
+function normalizePrivateKey(value: string) {
+  return value.replace(/\\n/g, '\n');
+}
+
 export default () => ({
   port: Number(process.env.PORT ?? 3000),
   appBaseUrl: process.env.APP_BASE_URL ?? 'http://localhost:3000',
@@ -9,4 +13,13 @@ export default () => ({
     'mysql://root:123456@localhost:3306/aitori_stylist',
   redisUrl:
     process.env.REDIS_URL ?? 'redis://:123456@localhost:6379',
+  storage: {
+    driver: process.env.STORAGE_DRIVER ?? 'gcs',
+    gcs: {
+      bucket: process.env.GCS_BUCKET ?? '',
+      projectId: process.env.GCS_PROJECT_ID ?? '',
+      clientEmail: process.env.GCS_CLIENT_EMAIL ?? '',
+      privateKey: normalizePrivateKey(process.env.GCS_PRIVATE_KEY ?? ''),
+    },
+  },
 });

+ 5 - 0
backend/src/config/env.schema.ts

@@ -9,6 +9,11 @@ export const envSchema = z.object({
   JWT_EXPIRES_IN: z.string().default('7d'),
   CORS_ORIGIN: z.string().default('http://127.0.0.1:5173'),
   NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
+  STORAGE_DRIVER: z.enum(['gcs']).default('gcs'),
+  GCS_BUCKET: z.string().min(1),
+  GCS_PROJECT_ID: z.string().min(1),
+  GCS_CLIENT_EMAIL: z.string().email(),
+  GCS_PRIVATE_KEY: z.string().min(1),
 });
 
 export function validateEnv(config: Record<string, unknown>) {

+ 10 - 0
backend/src/modules/storage/dto/upload-file.dto.ts

@@ -0,0 +1,10 @@
+import { IsNotEmpty, Matches } from 'class-validator';
+import { STORAGE_TYPE_PATTERN } from '../storage.constants';
+
+export class UploadFileDto {
+  @IsNotEmpty()
+  @Matches(STORAGE_TYPE_PATTERN, {
+    message: '上传类型不合法',
+  })
+  type!: string;
+}

+ 7 - 0
backend/src/modules/storage/dto/upload-result.dto.ts

@@ -0,0 +1,7 @@
+export class UploadResultDto {
+  url!: string;
+  objectKey!: string;
+  bucket!: string;
+  contentType!: string;
+  size!: number;
+}

+ 31 - 0
backend/src/modules/storage/gcs-storage.driver.ts

@@ -0,0 +1,31 @@
+import { Storage as GoogleCloudStorage } from '@google-cloud/storage';
+import { Injectable } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { StorageDriver } from './storage.driver';
+import { DriverUploadInput } from './storage.types';
+
+@Injectable()
+export class GcsStorageDriver implements StorageDriver {
+  private readonly storage: GoogleCloudStorage;
+  private readonly bucket: string;
+
+  constructor(private readonly configService: ConfigService) {
+    this.bucket = this.configService.getOrThrow<string>('storage.gcs.bucket');
+    this.storage = new GoogleCloudStorage({
+      projectId: this.configService.getOrThrow<string>('storage.gcs.projectId'),
+      credentials: {
+        client_email: this.configService.getOrThrow<string>(
+          'storage.gcs.clientEmail',
+        ),
+        private_key: this.configService.getOrThrow<string>('storage.gcs.privateKey'),
+      },
+    });
+  }
+
+  async upload(input: DriverUploadInput): Promise<void> {
+    await this.storage.bucket(this.bucket).file(input.objectKey).save(input.buffer, {
+      resumable: false,
+      contentType: input.contentType,
+    });
+  }
+}

+ 3 - 0
backend/src/modules/storage/storage.constants.ts

@@ -0,0 +1,3 @@
+export const STORAGE_TYPE_PATTERN = /^[A-Za-z0-9_-]+$/;
+export const GCS_PUBLIC_BASE_URL = 'https://storage.googleapis.com';
+export const STORAGE_DRIVER = Symbol('STORAGE_DRIVER');

+ 45 - 0
backend/src/modules/storage/storage.controller.ts

@@ -0,0 +1,45 @@
+import {
+  BadRequestException,
+  Body,
+  Controller,
+  Post,
+  UploadedFile,
+  UseInterceptors,
+} from '@nestjs/common';
+import { FileInterceptor } from '@nestjs/platform-express';
+import { memoryStorage } from 'multer';
+import { UploadFileDto } from './dto/upload-file.dto';
+import { StorageService } from './storage.service';
+
+type UploadedMemoryFile = {
+  buffer: Buffer;
+  mimetype: string;
+  originalname: string;
+};
+
+@Controller('storage')
+export class StorageController {
+  constructor(private readonly storageService: StorageService) {}
+
+  @Post('upload')
+  @UseInterceptors(
+    FileInterceptor('file', {
+      storage: memoryStorage(),
+    }),
+  )
+  upload(
+    @Body() body: UploadFileDto,
+    @UploadedFile() file?: UploadedMemoryFile,
+  ) {
+    if (!file) {
+      throw new BadRequestException('请选择要上传的文件');
+    }
+
+    return this.storageService.uploadBuffer({
+      buffer: file.buffer,
+      contentType: file.mimetype,
+      type: body.type,
+      originalFilename: file.originalname,
+    });
+  }
+}

+ 5 - 0
backend/src/modules/storage/storage.driver.ts

@@ -0,0 +1,5 @@
+import { DriverUploadInput } from './storage.types';
+
+export interface StorageDriver {
+  upload(input: DriverUploadInput): Promise<void>;
+}

+ 19 - 0
backend/src/modules/storage/storage.module.ts

@@ -0,0 +1,19 @@
+import { Module } from '@nestjs/common';
+import { GcsStorageDriver } from './gcs-storage.driver';
+import { STORAGE_DRIVER } from './storage.constants';
+import { StorageController } from './storage.controller';
+import { StorageService } from './storage.service';
+
+@Module({
+  controllers: [StorageController],
+  providers: [
+    GcsStorageDriver,
+    StorageService,
+    {
+      provide: STORAGE_DRIVER,
+      useExisting: GcsStorageDriver,
+    },
+  ],
+  exports: [StorageService, STORAGE_DRIVER],
+})
+export class StorageModule {}

+ 69 - 0
backend/src/modules/storage/storage.service.spec.ts

@@ -0,0 +1,69 @@
+import { BadRequestException } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { Test } from '@nestjs/testing';
+import { STORAGE_DRIVER } from './storage.constants';
+import { StorageService } from './storage.service';
+
+describe('StorageService', () => {
+  let service: StorageService;
+  const upload = jest.fn();
+
+  beforeEach(async () => {
+    upload.mockReset();
+
+    const moduleRef = await Test.createTestingModule({
+      providers: [
+        StorageService,
+        {
+          provide: STORAGE_DRIVER,
+          useValue: { upload },
+        },
+        {
+          provide: ConfigService,
+          useValue: {
+            getOrThrow: jest.fn((key: string) => {
+              const map: Record<string, string> = {
+                'storage.gcs.bucket': 'aitori-public',
+              };
+
+              return map[key];
+            }),
+          },
+        },
+      ],
+    }).compile();
+
+    service = moduleRef.get(StorageService);
+  });
+
+  it('uploads a buffer and returns a full public url', async () => {
+    const result = await service.uploadBuffer({
+      buffer: Buffer.from('hello'),
+      contentType: 'image/png',
+      type: 'template',
+      originalFilename: 'look.png',
+    });
+
+    expect(upload).toHaveBeenCalledWith(
+      expect.objectContaining({
+        objectKey: expect.stringMatching(/^stylist\/template\/.+\.png$/),
+        contentType: 'image/png',
+      }),
+    );
+    expect(result.bucket).toBe('aitori-public');
+    expect(result.objectKey).toMatch(/^stylist\/template\/.+\.png$/);
+    expect(result.url).toBe(`https://storage.googleapis.com/aitori-public/${result.objectKey}`);
+    expect(result.size).toBe(5);
+  });
+
+  it('rejects an invalid type', async () => {
+    await expect(
+      service.uploadBuffer({
+        buffer: Buffer.from('hello'),
+        contentType: 'image/png',
+        type: '../template',
+        originalFilename: 'look.png',
+      }),
+    ).rejects.toBeInstanceOf(BadRequestException);
+  });
+});

+ 44 - 0
backend/src/modules/storage/storage.service.ts

@@ -0,0 +1,44 @@
+import { BadRequestException, Inject, Injectable } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { randomUUID } from 'crypto';
+import { extname } from 'path';
+import {
+  GCS_PUBLIC_BASE_URL,
+  STORAGE_DRIVER,
+  STORAGE_TYPE_PATTERN,
+} from './storage.constants';
+import { StorageDriver } from './storage.driver';
+import { UploadResultDto } from './dto/upload-result.dto';
+import { UploadBufferInput } from './storage.types';
+
+@Injectable()
+export class StorageService {
+  constructor(
+    @Inject(STORAGE_DRIVER) private readonly storageDriver: StorageDriver,
+    private readonly configService: ConfigService,
+  ) {}
+
+  async uploadBuffer(input: UploadBufferInput): Promise<UploadResultDto> {
+    if (!STORAGE_TYPE_PATTERN.test(input.type)) {
+      throw new BadRequestException('上传类型不合法');
+    }
+
+    const bucket = this.configService.getOrThrow<string>('storage.gcs.bucket');
+    const extension = extname(input.originalFilename);
+    const objectKey = `stylist/${input.type}/${randomUUID()}${extension}`;
+
+    await this.storageDriver.upload({
+      objectKey,
+      buffer: input.buffer,
+      contentType: input.contentType,
+    });
+
+    return {
+      url: `${GCS_PUBLIC_BASE_URL}/${bucket}/${objectKey}`,
+      objectKey,
+      bucket,
+      contentType: input.contentType,
+      size: input.buffer.length,
+    };
+  }
+}

+ 12 - 0
backend/src/modules/storage/storage.types.ts

@@ -0,0 +1,12 @@
+export type UploadBufferInput = {
+  buffer: Buffer;
+  contentType: string;
+  type: string;
+  originalFilename: string;
+};
+
+export type DriverUploadInput = {
+  objectKey: string;
+  buffer: Buffer;
+  contentType: string;
+};

+ 71 - 1
backend/test/app.e2e-spec.ts

@@ -3,14 +3,25 @@ import { Test } from '@nestjs/testing';
 import * as request from 'supertest';
 import { AppModule } from '../src/app.module';
 import { configureApp } from '../src/app.setup';
+import { GcsStorageDriver } from '../src/modules/storage/gcs-storage.driver';
+import { STORAGE_DRIVER } from '../src/modules/storage/storage.constants';
 
 describe('App (e2e)', () => {
   let app: INestApplication;
+  const upload = jest.fn();
 
   beforeAll(async () => {
+    upload.mockReset();
+    upload.mockResolvedValue(undefined);
+
     const moduleRef = await Test.createTestingModule({
       imports: [AppModule],
-    }).compile();
+    })
+      .overrideProvider(STORAGE_DRIVER)
+      .useValue({ upload })
+      .overrideProvider(GcsStorageDriver)
+      .useValue({ upload })
+      .compile();
 
     app = moduleRef.createNestApplication();
     configureApp(app);
@@ -114,4 +125,63 @@ describe('App (e2e)', () => {
       .send({})
       .expect(400);
   });
+
+  it('/api/v1/storage/upload (POST) uploads a file and returns a public url', async () => {
+    const response = await request(app.getHttpServer())
+      .post('/api/v1/storage/upload')
+      .field('type', 'template')
+      .attach('file', Buffer.from('hello'), {
+        filename: 'look.png',
+        contentType: 'image/png',
+      })
+      .expect(201);
+
+    expect(upload).toHaveBeenCalledWith(
+      expect.objectContaining({
+        objectKey: expect.stringMatching(/^stylist\/template\/.+\.png$/),
+        contentType: 'image/png',
+      }),
+    );
+    expect(response.body.ok).toBe(true);
+    expect(response.body.data.objectKey).toMatch(/^stylist\/template\/.+\.png$/);
+    expect(response.body.data.url).toBe(
+      `https://storage.googleapis.com/aitori-public/${response.body.data.objectKey}`,
+    );
+  });
+
+  it('/api/v1/storage/upload (POST) rejects requests without a file', async () => {
+    const response = await request(app.getHttpServer())
+      .post('/api/v1/storage/upload')
+      .field('type', 'template')
+      .expect(400);
+
+    expect(response.body).toEqual({
+      ok: false,
+      data: null,
+      error: {
+        code: 'HTTP_400',
+        message: '请选择要上传的文件',
+      },
+    });
+  });
+
+  it('/api/v1/storage/upload (POST) validates type', async () => {
+    const response = await request(app.getHttpServer())
+      .post('/api/v1/storage/upload')
+      .field('type', '../template')
+      .attach('file', Buffer.from('hello'), {
+        filename: 'look.png',
+        contentType: 'image/png',
+      })
+      .expect(400);
+
+    expect(response.body).toEqual({
+      ok: false,
+      data: null,
+      error: {
+        code: 'HTTP_400',
+        message: '上传类型不合法',
+      },
+    });
+  });
 });

+ 760 - 0
docs/superpowers/plans/2026-06-28-gcs-upload-storage.md

@@ -0,0 +1,760 @@
+# GCS Upload Storage Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build a reusable GCS-backed upload capability with one HTTP upload endpoint, one internal storage service, validated GCS configuration, and tests that prove uploaded files return full public URLs.
+
+**Architecture:** Add a dedicated `storage` module to the NestJS backend. `StorageController` accepts `multipart/form-data`, `StorageService` validates `type`, generates `stylist/{type}/{filename}` object keys plus full public URLs, and `GcsStorageDriver` isolates Google Cloud Storage SDK calls so later business modules can reuse the service layer without touching the SDK directly.
+
+**Tech Stack:** NestJS 11, `@google-cloud/storage`, Multer via `@nestjs/platform-express`, Zod env validation, Jest, Supertest
+
+## Global Constraints
+
+- 上传能力优先抽象为通用基础设施,而不是绑定单一业务模块。
+- 统一由服务端决定最终对象 key 和文件名,不把命名规则散落到前端或业务层。
+- `type` 保持自由输入,但必须经过安全校验,避免路径穿越和命名污染。
+- 返回体以完整 URL 为主,`objectKey` 作为辅助信息保留。
+- 后续业务表默认存 `url`,减少访问拼接逻辑和前后端重复认知。
+- GCS SDK 调用细节应隔离在驱动层,业务模块不直接依赖 SDK。
+- 未开发的能力显式不做,不提前引入签名 URL、删除策略或私有读链路。
+- 本 case 不做前端正式上传页面。
+- 本 case 不做签名 URL 上传。
+- 本 case 不做分片上传、大文件断点续传。
+- 本 case 不开发文件删除、覆盖、批量迁移。
+- 本 case 不做复杂权限隔离或私有签名下载。
+- 整个实现中不需要硬编码 GCS 凭据。
+- 禁止在代码中使用硬编码的绝对路径。
+
+---
+
+## File Structure
+
+- `backend/package.json`: add the Google Cloud Storage dependency used by the new driver.
+- `backend/.env`: add the provided GCS runtime values for local execution.
+- `backend/.env.example`: document the new storage env keys for future environments.
+- `backend/src/config/env.schema.ts`: validate `STORAGE_DRIVER`, `GCS_BUCKET`, `GCS_PROJECT_ID`, `GCS_CLIENT_EMAIL`, and `GCS_PRIVATE_KEY`.
+- `backend/src/config/app.config.ts`: expose a normalized `storage` config object, including `privateKey` newline normalization.
+- `backend/src/app.module.ts`: register the new `StorageModule`.
+- `backend/src/modules/storage/dto/upload-file.dto.ts`: validate the HTTP `type` form field.
+- `backend/src/modules/storage/dto/upload-result.dto.ts`: define the standard upload response payload.
+- `backend/src/modules/storage/storage.types.ts`: centralize reusable upload input/output interfaces used by controller, service, and driver.
+- `backend/src/modules/storage/storage.constants.ts`: hold the `type` validation regex and the public GCS base URL.
+- `backend/src/modules/storage/storage.driver.ts`: define the driver interface consumed by the service.
+- `backend/src/modules/storage/gcs-storage.driver.ts`: implement the GCS SDK upload behavior.
+- `backend/src/modules/storage/storage.service.ts`: validate `type`, generate unique filenames and object keys, call the driver, and return the final URL-first payload.
+- `backend/src/modules/storage/storage.controller.ts`: expose `POST /api/v1/storage/upload` using Multer memory storage.
+- `backend/src/modules/storage/storage.module.ts`: wire controller, service, driver provider, and config usage.
+- `backend/src/modules/storage/storage.service.spec.ts`: cover `type` validation, object key generation, URL generation, and internal `uploadBuffer()` behavior.
+- `backend/test/app.e2e-spec.ts`: add HTTP upload success and validation failure tests using a mocked storage driver.
+
+### Task 1: Add Storage Dependencies And Configuration
+
+**Files:**
+- Modify: `backend/package.json`
+- Modify: `backend/.env`
+- Modify: `backend/.env.example`
+- Modify: `backend/src/config/env.schema.ts`
+- Modify: `backend/src/config/app.config.ts`
+
+**Interfaces:**
+- Produces: env keys `STORAGE_DRIVER`, `GCS_BUCKET`, `GCS_PROJECT_ID`, `GCS_CLIENT_EMAIL`, `GCS_PRIVATE_KEY`
+- Produces: config shape `storage: { driver: string; gcs: { bucket: string; projectId: string; clientEmail: string; privateKey: string } }`
+
+- [ ] **Step 1: Add the GCS SDK dependency and write the failing env-schema test inline by extending the schema usage**
+
+```json
+// backend/package.json
+{
+  "dependencies": {
+    "@google-cloud/storage": "^7.13.0"
+  }
+}
+```
+
+```ts
+// backend/src/config/env.schema.ts
+const parsed = envSchema.parse({
+  PORT: '3000',
+  APP_BASE_URL: 'http://localhost:3000',
+  DATABASE_URL: 'mysql://root:123456@localhost:3306/aitori_stylist',
+  REDIS_URL: 'redis://:123456@localhost:6379',
+  JWT_SECRET: 'replace-me',
+  JWT_EXPIRES_IN: '7d',
+  CORS_ORIGIN: 'http://127.0.0.1:5173',
+  NODE_ENV: 'development',
+  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: 'line1\\nline2',
+});
+
+expect(parsed.STORAGE_DRIVER).toBe('gcs');
+expect(parsed.GCS_BUCKET).toBe('aitori-public');
+```
+
+- [ ] **Step 2: Install dependencies and confirm the config keys are still missing**
+
+Run:
+
+```bash
+npm install
+npm test -- --runInBand
+```
+
+Expected: tests or TypeScript fail because the schema/config does not yet include the new storage keys.
+
+- [ ] **Step 3: Extend env validation and app config**
+
+```ts
+// backend/src/config/env.schema.ts
+export const envSchema = z.object({
+  PORT: z.coerce.number().int().positive().default(3000),
+  APP_BASE_URL: z.string().default('http://localhost:3000'),
+  DATABASE_URL: z.string().default('mysql://root:root@localhost:3306/aitori_backend'),
+  REDIS_URL: z.string().default('redis://localhost:6379'),
+  JWT_SECRET: z.string().default('replace-me'),
+  JWT_EXPIRES_IN: z.string().default('7d'),
+  CORS_ORIGIN: z.string().default('http://127.0.0.1:5173'),
+  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
+  STORAGE_DRIVER: z.enum(['gcs']).default('gcs'),
+  GCS_BUCKET: z.string().min(1),
+  GCS_PROJECT_ID: z.string().min(1),
+  GCS_CLIENT_EMAIL: z.string().email(),
+  GCS_PRIVATE_KEY: z.string().min(1),
+});
+```
+
+```ts
+// backend/src/config/app.config.ts
+function normalizePrivateKey(value: string) {
+  return value.replace(/\\n/g, '\n');
+}
+
+export default () => ({
+  port: Number(process.env.PORT ?? 3000),
+  appBaseUrl: process.env.APP_BASE_URL ?? 'http://localhost:3000',
+  corsOrigin: process.env.CORS_ORIGIN ?? 'http://127.0.0.1:5173',
+  jwtSecret: process.env.JWT_SECRET ?? 'replace-me',
+  jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '7d',
+  databaseUrl:
+    process.env.DATABASE_URL ??
+    'mysql://root:123456@localhost:3306/aitori_stylist',
+  redisUrl:
+    process.env.REDIS_URL ?? 'redis://:123456@localhost:6379',
+  storage: {
+    driver: process.env.STORAGE_DRIVER ?? 'gcs',
+    gcs: {
+      bucket: process.env.GCS_BUCKET ?? '',
+      projectId: process.env.GCS_PROJECT_ID ?? '',
+      clientEmail: process.env.GCS_CLIENT_EMAIL ?? '',
+      privateKey: normalizePrivateKey(process.env.GCS_PRIVATE_KEY ?? ''),
+    },
+  },
+});
+```
+
+- [ ] **Step 4: Add the new keys to the env files**
+
+```env
+# backend/.env.example
+PORT=3000
+APP_BASE_URL=http://localhost:3000
+DATABASE_URL=mysql://root:123456@localhost:3306/aitori_stylist
+REDIS_URL=redis://:123456@localhost:6379
+JWT_SECRET=replace-me
+JWT_EXPIRES_IN=7d
+CORS_ORIGIN=http://127.0.0.1:5173
+NODE_ENV=development
+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=-----BEGIN PRIVATE KEY-----\nreplace-me\n-----END PRIVATE KEY-----\n
+```
+
+```env
+# backend/.env
+PORT=3000
+APP_BASE_URL=http://localhost:3000
+DATABASE_URL=mysql://root:123456@localhost:3306/aitori_stylist
+REDIS_URL=redis://:123456@localhost:6379
+JWT_SECRET=replace-me
+JWT_EXPIRES_IN=7d
+CORS_ORIGIN=http://127.0.0.1:5173
+NODE_ENV=development
+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=-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCGi3DHDN4CT4+R\nxJ/N2i5wgAK80J0sUH8mLsq5HA93JUyGUpD4SyL55X6VxKje1enHr8LvjRQVr2IB\nXhHa0n0GwubBj658kO+fpmDPObCQVcB/ODIsgwAeUiPnD0ypHXhKRtAnEqb/dKiw\nieuHPzbo/pTFN1HOb8XFgRJ1ks+P9F5cd+9ydCfChDsAzvDbbbAKLqV+SHPSs2rO\nO+yByCgnODTYwsgV0GA9V29y7ubtG2fSPIksGs5DSCDvVWGEIW1zPmLJcAY1XTSO\nL6305FBrGKjJSDj4cchMP9mv04pMCgMol7GCGFjEeJwOd5huImtPF1YjJyPm3Dku\nUP91eJz9AgMBAAECggEAAS3oBIKj6bxfS1bzhj2L/yklsDIaH2LEPoyV8wOGzWCB\nEdzxT6lyI/0dCMlWW2YS6SpsyIzbLBAWiWFujoiEsO8hg5K6FylURF+HUgGumJFI\np7gEu15zMF2mQJ0u3f03yigVPoAIgTNshoGeLb6odIHfKKku7FX1k2EQGgI6E6Gv\n0Iyryo1YQt6YiCuZHzkYU6kD3U5GsL+zyr4nR6dp55A6ZchDBEhJbkMGVaM+imxQ\nS7ABbhOfsOc0dy7Cfn5P1t/wyMNpol9W7fqaUjsTeRJCtvQWDUc83khtBC3bz61R\nY3e9bLf9jq4HBTbn3C3BfV169L3iZRKZN/2sfo61CQKBgQC5l02g5JnFCkdG9Ma2\nYQxxiwErRhpLhjmfm4R4yaqrZRhhfMG+kcS0meQn8Ud0go3XGEak5oJWXW+gZnNG\n6rEdpqVuzHBZUdPaQHYY2jV5fgYV614RzQB3n2xed7CPLwXr2AHj0UBMcEkUAVbD\nyckuAqyUlA/ymJ5ACg7DHGPX1QKBgQC5ln2Opk2ItFXooZ8PWkN/W55v5LzarcNU\nPFg1NuGiM9w9Jot/xRukFd3saU8hR28vtZID4Dt0YzUCZpoZJk3TmrG/x4XcFtHK\njZI7Z0pSrrZJgfSq9l8ALsGRGSGJb6AU6infORe9xm7m40r/9yoVBnbeP6L5h7ur\nvPCIlWusiQKBgFLk2a97a5MuvBddTUQ4pK4yQBtr/GKvWR2JAOpSaQCU7wbcCk1s\nHkfsVJPimN/ZUDE6jpMowh6BLVKfb+3E9ccgn9OVTLkYa1+YWydOj4PsXht8PVAG\nGKdVdbncC+hoQGTO3kk7NVTu8jUVRAO/JTR/zUWvvE/HCyffViifjZjhAoGANsIx\nj8p/7AsRog82CGigq85TpD5AfuFwVcVsXV8xGhPlMuHRnUSCUFrnXR8BaxdRtikD\nyUL7J+8xr+CoLS2BHyNwuN5dH8BX7ETzVbW3V0gXnv7KOztwcnRZRr+8hnm6KRGi\ngyusVqFbDaMY+QdrkkKTBiaOpsOm2xhnfFViLMkCgYA5rlv3xugTD3gt/O9W+2Vb\nuRvApi4fdC+h1XqU9ywwz70N9x+4xxUY0hBCU23y6O38rHNC7y6K5buvdd1zOQ62\n3maUGjv07ltxlgmpFeRbSb4pqoWsQtWvowRlDPmYpGheD1j6W4pvqx8z2AABK/sz\nMS/7pytLUKmgTFdWX6zMaA==\n-----END PRIVATE KEY-----\n
+```
+
+- [ ] **Step 5: Re-run the backend test suite entry point and confirm config still boots**
+
+Run:
+
+```bash
+npm test -- --runInBand
+```
+
+Expected: the app boots with the new env/config keys and no schema errors.
+
+- [ ] **Step 6: Commit the storage configuration foundation**
+
+```bash
+git add backend/package.json backend/package-lock.json backend/.env backend/.env.example backend/src/config/env.schema.ts backend/src/config/app.config.ts
+git commit -m "chore: add gcs storage configuration"
+```
+
+### Task 2: Build The Reusable Storage Module And Unit Tests
+
+**Files:**
+- Create: `backend/src/modules/storage/dto/upload-file.dto.ts`
+- Create: `backend/src/modules/storage/dto/upload-result.dto.ts`
+- Create: `backend/src/modules/storage/storage.types.ts`
+- Create: `backend/src/modules/storage/storage.constants.ts`
+- Create: `backend/src/modules/storage/storage.driver.ts`
+- Create: `backend/src/modules/storage/gcs-storage.driver.ts`
+- Create: `backend/src/modules/storage/storage.service.ts`
+- Create: `backend/src/modules/storage/storage.module.ts`
+- Test: `backend/src/modules/storage/storage.service.spec.ts`
+- Modify: `backend/src/app.module.ts`
+
+**Interfaces:**
+- Produces: `StorageService.uploadBuffer(input: { buffer: Buffer; contentType: string; type: string; originalFilename: string }): Promise<UploadResultDto>`
+- Produces: `StorageDriver.upload(input: { objectKey: string; buffer: Buffer; contentType: string }): Promise<void>`
+- Produces: `UploadResultDto = { url: string; objectKey: string; bucket: string; contentType: string; size: number }`
+
+- [ ] **Step 1: Write the failing storage service unit test**
+
+```ts
+// backend/src/modules/storage/storage.service.spec.ts
+import { BadRequestException } from '@nestjs/common';
+import { Test } from '@nestjs/testing';
+import { ConfigService } from '@nestjs/config';
+import { StorageService } from './storage.service';
+import { STORAGE_DRIVER } from './storage.module';
+
+describe('StorageService', () => {
+  let service: StorageService;
+  const upload = jest.fn();
+
+  beforeEach(async () => {
+    upload.mockReset();
+    const moduleRef = await Test.createTestingModule({
+      providers: [
+        StorageService,
+        {
+          provide: STORAGE_DRIVER,
+          useValue: { upload },
+        },
+        {
+          provide: ConfigService,
+          useValue: {
+            getOrThrow: jest.fn((key: string) => {
+              const map: Record<string, string> = {
+                'storage.gcs.bucket': 'aitori-public',
+              };
+              return map[key];
+            }),
+          },
+        },
+      ],
+    }).compile();
+
+    service = moduleRef.get(StorageService);
+  });
+
+  it('uploads a buffer and returns a full public url', async () => {
+    const result = await service.uploadBuffer({
+      buffer: Buffer.from('hello'),
+      contentType: 'image/png',
+      type: 'template',
+      originalFilename: 'look.png',
+    });
+
+    expect(upload).toHaveBeenCalledWith(
+      expect.objectContaining({
+        objectKey: expect.stringMatching(/^stylist\/template\/.+\.png$/),
+        contentType: 'image/png',
+      }),
+    );
+    expect(result.bucket).toBe('aitori-public');
+    expect(result.objectKey).toMatch(/^stylist\/template\/.+\.png$/);
+    expect(result.url).toBe(`https://storage.googleapis.com/aitori-public/${result.objectKey}`);
+    expect(result.size).toBe(5);
+  });
+
+  it('rejects an invalid type', async () => {
+    await expect(
+      service.uploadBuffer({
+        buffer: Buffer.from('hello'),
+        contentType: 'image/png',
+        type: '../template',
+        originalFilename: 'look.png',
+      }),
+    ).rejects.toBeInstanceOf(BadRequestException);
+  });
+});
+```
+
+- [ ] **Step 2: Run the unit test to verify the module does not exist yet**
+
+Run:
+
+```bash
+npm test -- storage.service.spec.ts --runInBand
+```
+
+Expected: FAIL because `storage.service.ts` and related files do not exist yet.
+
+- [ ] **Step 3: Create the storage types, constants, and DTOs**
+
+```ts
+// backend/src/modules/storage/storage.types.ts
+export type UploadBufferInput = {
+  buffer: Buffer;
+  contentType: string;
+  type: string;
+  originalFilename: string;
+};
+
+export type DriverUploadInput = {
+  objectKey: string;
+  buffer: Buffer;
+  contentType: string;
+};
+```
+
+```ts
+// backend/src/modules/storage/storage.constants.ts
+export const STORAGE_TYPE_PATTERN = /^[A-Za-z0-9_-]+$/;
+export const GCS_PUBLIC_BASE_URL = 'https://storage.googleapis.com';
+```
+
+```ts
+// backend/src/modules/storage/dto/upload-file.dto.ts
+import { IsNotEmpty, Matches } from 'class-validator';
+import { STORAGE_TYPE_PATTERN } from '../storage.constants';
+
+export class UploadFileDto {
+  @IsNotEmpty()
+  @Matches(STORAGE_TYPE_PATTERN, {
+    message: '上传类型不合法',
+  })
+  type!: string;
+}
+```
+
+```ts
+// backend/src/modules/storage/dto/upload-result.dto.ts
+export class UploadResultDto {
+  url!: string;
+  objectKey!: string;
+  bucket!: string;
+  contentType!: string;
+  size!: number;
+}
+```
+
+- [ ] **Step 4: Create the driver abstraction, GCS driver, and service**
+
+```ts
+// backend/src/modules/storage/storage.driver.ts
+import { DriverUploadInput } from './storage.types';
+
+export interface StorageDriver {
+  upload(input: DriverUploadInput): Promise<void>;
+}
+```
+
+```ts
+// backend/src/modules/storage/gcs-storage.driver.ts
+import { Injectable } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { Storage } from '@google-cloud/storage';
+import { StorageDriver } from './storage.driver';
+import { DriverUploadInput } from './storage.types';
+
+@Injectable()
+export class GcsStorageDriver implements StorageDriver {
+  private readonly client: Storage;
+  private readonly bucketName: string;
+
+  constructor(private readonly configService: ConfigService) {
+    this.bucketName = this.configService.getOrThrow<string>('storage.gcs.bucket');
+    this.client = new Storage({
+      projectId: this.configService.getOrThrow<string>('storage.gcs.projectId'),
+      credentials: {
+        client_email: this.configService.getOrThrow<string>('storage.gcs.clientEmail'),
+        private_key: this.configService.getOrThrow<string>('storage.gcs.privateKey'),
+      },
+    });
+  }
+
+  async upload(input: DriverUploadInput): Promise<void> {
+    await this.client.bucket(this.bucketName).file(input.objectKey).save(input.buffer, {
+      contentType: input.contentType,
+      resumable: false,
+    });
+  }
+}
+```
+
+```ts
+// backend/src/modules/storage/storage.service.ts
+import { BadRequestException, Inject, Injectable } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { extname } from 'node:path';
+import { randomBytes } from 'node:crypto';
+import { UploadResultDto } from './dto/upload-result.dto';
+import { GCS_PUBLIC_BASE_URL, STORAGE_TYPE_PATTERN } from './storage.constants';
+import { StorageDriver } from './storage.driver';
+import { UploadBufferInput } from './storage.types';
+import { STORAGE_DRIVER } from './storage.module';
+
+@Injectable()
+export class StorageService {
+  constructor(
+    @Inject(STORAGE_DRIVER) private readonly driver: StorageDriver,
+    private readonly configService: ConfigService,
+  ) {}
+
+  async uploadBuffer(input: UploadBufferInput): Promise<UploadResultDto> {
+    if (!input.type) {
+      throw new BadRequestException({
+        code: 'STORAGE_TYPE_REQUIRED',
+        message: '上传类型不能为空',
+      });
+    }
+
+    if (!STORAGE_TYPE_PATTERN.test(input.type)) {
+      throw new BadRequestException({
+        code: 'STORAGE_INVALID_TYPE',
+        message: '上传类型不合法',
+      });
+    }
+
+    const extension = extname(input.originalFilename || '').toLowerCase();
+    if (!extension) {
+      throw new BadRequestException({
+        code: 'STORAGE_FILE_EXTENSION_REQUIRED',
+        message: '文件扩展名不能为空',
+      });
+    }
+
+    const filename = this.createFilename(extension);
+    const objectKey = `stylist/${input.type}/${filename}`;
+    await this.driver.upload({
+      objectKey,
+      buffer: input.buffer,
+      contentType: input.contentType,
+    });
+
+    const bucket = this.configService.getOrThrow<string>('storage.gcs.bucket');
+    return {
+      url: `${GCS_PUBLIC_BASE_URL}/${bucket}/${objectKey}`,
+      objectKey,
+      bucket,
+      contentType: input.contentType,
+      size: input.buffer.length,
+    };
+  }
+
+  private createFilename(extension: string) {
+    const now = new Date();
+    const timestamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z').replace('T', 'T');
+    return `${timestamp}_${randomBytes(4).toString('hex')}${extension}`;
+  }
+}
+```
+
+- [ ] **Step 5: Wire the module and app import**
+
+```ts
+// backend/src/modules/storage/storage.module.ts
+import { Module } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { GcsStorageDriver } from './gcs-storage.driver';
+import { StorageController } from './storage.controller';
+import { StorageService } from './storage.service';
+
+export const STORAGE_DRIVER = Symbol('STORAGE_DRIVER');
+
+@Module({
+  controllers: [StorageController],
+  providers: [
+    StorageService,
+    GcsStorageDriver,
+    {
+      provide: STORAGE_DRIVER,
+      inject: [ConfigService, GcsStorageDriver],
+      useFactory: (configService: ConfigService, gcsStorageDriver: GcsStorageDriver) => {
+        const driver = configService.get<string>('storage.driver');
+        if (driver !== 'gcs') {
+          throw new Error(`Unsupported storage driver: ${driver}`);
+        }
+        return gcsStorageDriver;
+      },
+    },
+  ],
+  exports: [StorageService],
+})
+export class StorageModule {}
+```
+
+```ts
+// backend/src/app.module.ts
+import { StorageModule } from './modules/storage/storage.module';
+
+@Module({
+  imports: [
+    ConfigModule.forRoot({
+      isGlobal: true,
+      cache: true,
+      load: [appConfig],
+      validate: validateEnv,
+    }),
+    PrismaModule,
+    RedisModule,
+    AuthModule,
+    CustomersModule,
+    HealthModule,
+    TemplatesModule,
+    SimulationsModule,
+    StorageModule,
+  ],
+})
+export class AppModule {}
+```
+
+- [ ] **Step 6: Re-run the unit tests to confirm the service works**
+
+Run:
+
+```bash
+npm test -- storage.service.spec.ts --runInBand
+```
+
+Expected: PASS with one success case and one invalid-type case.
+
+- [ ] **Step 7: Commit the reusable storage module**
+
+```bash
+git add backend/src/app.module.ts backend/src/modules/storage backend/src/modules/storage/storage.service.spec.ts
+git commit -m "feat: add reusable gcs storage module"
+```
+
+### Task 3: Expose The HTTP Upload Endpoint And e2e Tests
+
+**Files:**
+- Create: `backend/src/modules/storage/storage.controller.ts`
+- Modify: `backend/test/app.e2e-spec.ts`
+
+**Interfaces:**
+- Produces: `POST /api/v1/storage/upload`
+- Consumes: multipart field `file`
+- Consumes: multipart field `type`
+- Produces: `{ ok: true, data: UploadResultDto, error: null }`
+
+- [ ] **Step 1: Write the failing e2e tests for HTTP upload**
+
+```ts
+// backend/test/app.e2e-spec.ts
+it('/api/v1/storage/upload (POST) uploads a file and returns a full url', async () => {
+  const response = await request(app.getHttpServer())
+    .post('/api/v1/storage/upload')
+    .field('type', 'template')
+    .attach('file', Buffer.from('hello-image'), 'look.png')
+    .expect(201);
+
+  expect(response.body.ok).toBe(true);
+  expect(response.body.data.objectKey).toMatch(/^stylist\/template\/.+\.png$/);
+  expect(response.body.data.url).toBe(
+    `https://storage.googleapis.com/aitori-public/${response.body.data.objectKey}`,
+  );
+});
+
+it('/api/v1/storage/upload (POST) rejects a missing file', async () => {
+  const response = await request(app.getHttpServer())
+    .post('/api/v1/storage/upload')
+    .field('type', 'template')
+    .expect(400);
+
+  expect(response.body).toEqual({
+    ok: false,
+    data: null,
+    error: {
+      code: 'STORAGE_FILE_REQUIRED',
+      message: '上传文件不能为空',
+    },
+  });
+});
+
+it('/api/v1/storage/upload (POST) rejects an invalid type', async () => {
+  const response = await request(app.getHttpServer())
+    .post('/api/v1/storage/upload')
+    .field('type', '../template')
+    .attach('file', Buffer.from('hello-image'), 'look.png')
+    .expect(400);
+
+  expect(response.body).toEqual({
+    ok: false,
+    data: null,
+    error: {
+      code: 'STORAGE_INVALID_TYPE',
+      message: '上传类型不合法',
+    },
+  });
+});
+```
+
+- [ ] **Step 2: Run the e2e file and confirm the new route is not wired yet**
+
+Run:
+
+```bash
+npm run test:e2e -- app.e2e-spec.ts
+```
+
+Expected: FAIL because `/api/v1/storage/upload` does not exist yet.
+
+- [ ] **Step 3: Implement the controller with Multer memory storage**
+
+```ts
+// backend/src/modules/storage/storage.controller.ts
+import {
+  BadRequestException,
+  Body,
+  Controller,
+  HttpCode,
+  HttpStatus,
+  Post,
+  UploadedFile,
+  UseInterceptors,
+} from '@nestjs/common';
+import { FileInterceptor } from '@nestjs/platform-express';
+import multer from 'multer';
+import { UploadFileDto } from './dto/upload-file.dto';
+import { UploadResultDto } from './dto/upload-result.dto';
+import { StorageService } from './storage.service';
+
+@Controller('storage')
+export class StorageController {
+  constructor(private readonly storageService: StorageService) {}
+
+  @Post('upload')
+  @HttpCode(HttpStatus.CREATED)
+  @UseInterceptors(FileInterceptor('file', { storage: multer.memoryStorage() }))
+  async upload(
+    @UploadedFile() file: Express.Multer.File | undefined,
+    @Body() body: UploadFileDto,
+  ): Promise<UploadResultDto> {
+    if (!file) {
+      throw new BadRequestException({
+        code: 'STORAGE_FILE_REQUIRED',
+        message: '上传文件不能为空',
+      });
+    }
+
+    return this.storageService.uploadBuffer({
+      buffer: file.buffer,
+      contentType: file.mimetype || 'application/octet-stream',
+      type: body.type,
+      originalFilename: file.originalname,
+    });
+  }
+}
+```
+
+- [ ] **Step 4: Re-run the e2e file and confirm the new endpoint now passes**
+
+Run:
+
+```bash
+npm run test:e2e -- app.e2e-spec.ts
+```
+
+Expected: PASS for the health/auth tests plus the new upload success and failure scenarios.
+
+- [ ] **Step 5: Commit the HTTP upload endpoint**
+
+```bash
+git add backend/src/modules/storage/storage.controller.ts backend/test/app.e2e-spec.ts
+git commit -m "feat: add storage upload endpoint"
+```
+
+### Task 4: Final Verification And Branch Hygiene
+
+**Files:**
+- Modify: `docs/superpowers/specs/2026-06-28-gcs-upload-storage-design.md` (only if the implementation forces a spec wording adjustment)
+
+**Interfaces:**
+- Consumes: storage module, config, controller, and tests from Tasks 1-3
+- Produces: verified implementation ready for the next business case to call
+
+- [ ] **Step 1: Run the targeted backend verification commands**
+
+Run:
+
+```bash
+npm run lint
+npm test -- storage.service.spec.ts auth.service.spec.ts --runInBand
+npm run test:e2e -- app.e2e-spec.ts auth.e2e-spec.ts
+```
+
+Expected: lint passes, unit tests pass, and e2e passes including the upload route.
+
+- [ ] **Step 2: Check diagnostics on all changed files**
+
+Files to inspect:
+
+```text
+backend/src/config/env.schema.ts
+backend/src/config/app.config.ts
+backend/src/app.module.ts
+backend/src/modules/storage/dto/upload-file.dto.ts
+backend/src/modules/storage/dto/upload-result.dto.ts
+backend/src/modules/storage/storage.types.ts
+backend/src/modules/storage/storage.constants.ts
+backend/src/modules/storage/storage.driver.ts
+backend/src/modules/storage/gcs-storage.driver.ts
+backend/src/modules/storage/storage.service.ts
+backend/src/modules/storage/storage.controller.ts
+backend/src/modules/storage/storage.module.ts
+backend/src/modules/storage/storage.service.spec.ts
+backend/test/app.e2e-spec.ts
+```
+
+Expected: no newly introduced diagnostics remain unresolved.
+
+- [ ] **Step 3: Update the spec only if implementation details changed materially**
+
+```md
+## 验收标准
+
+- 服务端返回完整可访问 URL,而不是只返回后半段路径
+- `objectKey` 满足 `stylist/{type}/{serverGeneratedFilename}` 规则
+- 服务端内部模块可以直接调用统一上传服务
+```
+
+Expected: skip this step entirely if the implementation already matches the existing spec wording.
+
+- [ ] **Step 4: Commit the final doc sync only if Step 3 modified the spec**
+
+```bash
+git add docs/superpowers/specs/2026-06-28-gcs-upload-storage-design.md
+git commit -m "docs: align gcs upload spec with implementation"
+```
+
+- [ ] **Step 5: Confirm the branch is clean and capture the final status**
+
+Run:
+
+```bash
+git status --short
+git --no-pager log --oneline --decorate -n 5
+```
+
+Expected: `git status --short` is empty and the latest commits cover config, storage module, and upload endpoint work.

+ 430 - 0
docs/superpowers/specs/2026-06-28-gcs-upload-storage-design.md

@@ -0,0 +1,430 @@
+# 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 可直接访问。