From 6d7f9b5f3fdbe3d60d25449cee03d728c7796907 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 23 Aug 2026 12:50:35 +0800 Subject: [PATCH] =?UTF-8?q?=E5=87=86=E5=A4=87=E8=BF=81=E7=A7=BB1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.mac-docker.example | 6 + .env.server.example | 9 +- .gitignore | 1 + apps/backend/package-lock.json | 686 ++++++++++-------- apps/backend/package.json | 4 +- apps/backend/scripts/migrate-storage.ts | 1 + apps/backend/src/config/defaults.ts | 8 + apps/backend/src/config/env-overrides.ts | 6 + apps/backend/src/config/runtime-validation.ts | 50 +- .../file-storage/object-storage.test.ts | 48 ++ .../services/file-storage/object-storage.ts | 265 ++++++- apps/backend/src/storage-migrate.ts | 176 +++++ apps/backend/src/types/runtime-config.ts | 8 + docker-compose.dev.yml | 6 + docker-compose.yml | 6 + docs/服务器图片迁移到阿里云OSS.md | 58 ++ 16 files changed, 999 insertions(+), 339 deletions(-) create mode 100644 apps/backend/scripts/migrate-storage.ts create mode 100644 apps/backend/src/services/file-storage/object-storage.test.ts create mode 100644 apps/backend/src/storage-migrate.ts create mode 100644 docs/服务器图片迁移到阿里云OSS.md diff --git a/.env.mac-docker.example b/.env.mac-docker.example index 87b1a1d4..0a441ada 100644 --- a/.env.mac-docker.example +++ b/.env.mac-docker.example @@ -33,12 +33,18 @@ LOG_INTEGRATION_LEVEL=warn LOG_RETENTION_DAYS=30 # File Storage (MinIO / S3 compatible) +STORAGE_MODE=minio STORAGE_ENDPOINT=http://minio:9000 STORAGE_BUCKET=order-site STORAGE_ACCESS_KEY_ID=minioadmin STORAGE_SECRET_ACCESS_KEY=minioadmin STORAGE_REGION=us-east-1 STORAGE_MAX_UPLOAD_SIZE_MB=10 +STORAGE_OSS_ENDPOINT= +STORAGE_OSS_BUCKET= +STORAGE_OSS_ACCESS_KEY_ID= +STORAGE_OSS_SECRET_ACCESS_KEY= +STORAGE_OSS_REGION=oss-cn-hangzhou MINIO_API_PORT=19000 MINIO_CONSOLE_PORT=19001 diff --git a/.env.server.example b/.env.server.example index 408d75ae..cd81f424 100644 --- a/.env.server.example +++ b/.env.server.example @@ -40,13 +40,20 @@ LOG_LEVEL=info LOG_INTEGRATION_LEVEL=warn LOG_RETENTION_DAYS=30 -# File Storage (MinIO / S3 compatible) +# 文件存储:服务器迁移期间设 dual;校验通过、停止 MinIO 前改为 oss。 +STORAGE_MODE=minio STORAGE_ENDPOINT=http://minio:9000 STORAGE_BUCKET=order-site STORAGE_ACCESS_KEY_ID=change-me-storage-access-key STORAGE_SECRET_ACCESS_KEY=change-me-storage-secret-key STORAGE_REGION=us-east-1 STORAGE_MAX_UPLOAD_SIZE_MB=10 +# 阿里云 OSS S3 兼容端点(不要填写 bucket 前缀) +STORAGE_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com +STORAGE_OSS_BUCKET=change-me-oss-bucket +STORAGE_OSS_ACCESS_KEY_ID=change-me-oss-access-key +STORAGE_OSS_SECRET_ACCESS_KEY=change-me-oss-secret-key +STORAGE_OSS_REGION=oss-cn-hangzhou # Admin ADMIN_SESSION_SECRET=change-me-long-random-session-secret diff --git a/.gitignore b/.gitignore index f5c1cfdf..7a9fd889 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules .env .env copy .env.local +.env.back* .DS_Store diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index e7d3a162..dcc03e9f 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -11,8 +11,8 @@ "@alicloud/dysmsapi20170525": "^4.6.0", "@alicloud/openapi-client": "^0.4.15", "@alicloud/tea-util": "^1.4.11", + "@aws-sdk/client-s3": "^3.1116.0", "express": "^5.1.0", - "minio": "^8.0.7", "multer": "^2.2.0", "node-pg-migrate": "^8.0.4", "pg": "^8.16.3", @@ -235,6 +235,314 @@ "xml2js": "^0.6.0" } }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.29", + "resolved": "https://registry.npmmirror.com/@aws-sdk/checksums/-/checksums-3.1000.29.tgz", + "integrity": "sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1116.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/client-s3/-/client-s3-3.1116.0.tgz", + "integrity": "sha512-UKRl9qSVW0rZpvSOauQNpYAy8+ONBAVYnpfKVtCyOF+FZVT1tl6MunYuHvuarCrroD2/YJs+tHTALYNgAlec3Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.29", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.81", + "@aws-sdk/middleware-sdk-s3": "^3.972.75", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.9", + "resolved": "https://registry.npmmirror.com/@aws-sdk/core/-/core-3.977.9.tgz", + "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.33.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.70", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz", + "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.72", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz", + "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.15", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz", + "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-login": "^3.972.77", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.77", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz", + "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.81", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.81.tgz", + "integrity": "sha512-Rml+WitoFvXmv6JZ18U/xGdGDGGvB/mOin0ya0lTnTrdC0Z1lrVxTYh7iNklZBcvcRMrs4DoEf6xy1KWyrLQQw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-ini": "^3.973.15", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.70", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz", + "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.14", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz", + "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/token-providers": "3.1116.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.76", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz", + "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.75", + "resolved": "https://registry.npmmirror.com/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.75.tgz", + "integrity": "sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.44", + "resolved": "https://registry.npmmirror.com/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz", + "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.46", + "resolved": "https://registry.npmmirror.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1116.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz", + "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.5", + "resolved": "https://registry.npmmirror.com/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.40", + "resolved": "https://registry.npmmirror.com/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@darabonba/typescript": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@darabonba/typescript/-/typescript-1.0.5.tgz", @@ -1408,17 +1716,86 @@ "node": ">=18" } }, - "node_modules/@nodable/entities": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", - "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT" + "node_modules/@smithy/core": { + "version": "3.33.3", + "resolved": "https://registry.npmmirror.com/@smithy/core/-/core-3.33.3.tgz", + "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmmirror.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.7.2", + "resolved": "https://registry.npmmirror.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.2.tgz", + "integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.11.3", + "resolved": "https://registry.npmmirror.com/@smithy/node-http-handler/-/node-http-handler-4.11.3.tgz", + "integrity": "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.3", + "resolved": "https://registry.npmmirror.com/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.17.2", + "resolved": "https://registry.npmmirror.com/@smithy/types/-/types-4.17.2.tgz", + "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } }, "node_modules/@tootallnate/once": { "version": "2.0.1", @@ -1897,30 +2274,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "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", "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "license": "MIT" - }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1930,15 +2289,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/block-stream2": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/block-stream2/-/block-stream2-2.1.0.tgz", - "integrity": "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==", - "license": "MIT", - "dependencies": { - "readable-stream": "^3.4.0" - } - }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -1963,6 +2313,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmmirror.com/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", @@ -1975,21 +2331,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/browser-or-node": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/browser-or-node/-/browser-or-node-2.1.1.tgz", - "integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==", - "license": "MIT" - }, - "node_modules/buffer-crc32": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", - "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -2163,15 +2504,6 @@ } } }, - "node_modules/decode-uri-component": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", - "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", - "license": "MIT", - "engines": { - "node": ">=0.10" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2496,12 +2828,6 @@ "node": ">= 0.6" } }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "license": "MIT" - }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -2566,45 +2892,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-xml-builder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz", - "integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.6.2", - "xml-naming": "^0.3.0" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.10.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz", - "integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "@nodable/entities": "^3.0.0", - "fast-xml-builder": "^1.2.0", - "is-unsafe": "^2.0.0", - "path-expression-matcher": "^1.6.2", - "strnum": "^2.4.1", - "xml-naming": "^0.3.0" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2636,15 +2923,6 @@ "node": ">=16.0.0" } }, - "node_modules/filter-obj": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", - "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -3055,18 +3333,6 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, - "node_modules/is-unsafe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz", - "integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==", - "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", @@ -3258,60 +3524,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minio": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/minio/-/minio-8.0.7.tgz", - "integrity": "sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==", - "license": "Apache-2.0", - "dependencies": { - "async": "^3.2.4", - "block-stream2": "^2.1.0", - "browser-or-node": "^2.1.1", - "buffer-crc32": "^1.0.0", - "eventemitter3": "^5.0.1", - "fast-xml-parser": "^5.3.4", - "ipaddr.js": "^2.0.1", - "lodash": "^4.17.21", - "mime-types": "^2.1.35", - "query-string": "^7.1.3", - "stream-json": "^1.8.0", - "through2": "^4.0.2", - "xml2js": "^0.5.0 || ^0.6.2" - }, - "engines": { - "node": "^16 || ^18 || >=20" - } - }, - "node_modules/minio/node_modules/ipaddr.js": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", - "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/minio/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/minio/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/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -3559,21 +3771,6 @@ "node": ">=8" } }, - "node_modules/path-expression-matcher": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", - "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -3814,24 +4011,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/query-string": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", - "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", - "license": "MIT", - "dependencies": { - "decode-uri-component": "^0.2.2", - "filter-obj": "^1.1.0", - "split-on-first": "^1.0.0", - "strict-uri-encode": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -4191,15 +4370,6 @@ "node": ">= 10" } }, - "node_modules/split-on-first": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", - "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -4218,21 +4388,6 @@ "node": ">= 0.8" } }, - "node_modules/stream-chain": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", - "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==", - "license": "BSD-3-Clause" - }, - "node_modules/stream-json": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz", - "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==", - "license": "BSD-3-Clause", - "dependencies": { - "stream-chain": "^2.2.5" - } - }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -4241,15 +4396,6 @@ "node": ">=10.0.0" } }, - "node_modules/strict-uri-encode": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", - "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -4285,30 +4431,6 @@ "node": ">=8" } }, - "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/through2": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", - "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", - "license": "MIT", - "dependencies": { - "readable-stream": "3" - } - }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -4352,8 +4474,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.22.3", @@ -4569,21 +4690,6 @@ } } }, - "node_modules/xml-naming": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", - "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/xml2js": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", diff --git a/apps/backend/package.json b/apps/backend/package.json index 313a135f..631cb2f2 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -9,6 +9,8 @@ "db:migrate": "tsx src/db/migrate.ts", "db:migrate:create": "tsx scripts/create-migration.ts", "db:migrate:status": "tsx scripts/migration-status.ts", + "storage:migrate": "tsx scripts/migrate-storage.ts", + "storage:migrate:built": "node dist/storage-migrate.js", "check:sql": "tsx scripts/check-sql-guard.ts", "dev": "tsx watch --clear-screen=false src/index.ts", "format": "prettier --config ../../.prettierrc.json --ignore-path ../../.prettierignore --write \"src/**/*.{ts,js,json}\" \"scripts/**/*.{ts,js}\" eslint.config.js package.json tsconfig.json tsconfig.build.json tsconfig.eslint.json", @@ -33,8 +35,8 @@ "@alicloud/dysmsapi20170525": "^4.6.0", "@alicloud/openapi-client": "^0.4.15", "@alicloud/tea-util": "^1.4.11", + "@aws-sdk/client-s3": "^3.1116.0", "express": "^5.1.0", - "minio": "^8.0.7", "multer": "^2.2.0", "node-pg-migrate": "^8.0.4", "pg": "^8.16.3", diff --git a/apps/backend/scripts/migrate-storage.ts b/apps/backend/scripts/migrate-storage.ts new file mode 100644 index 00000000..1e7bba0b --- /dev/null +++ b/apps/backend/scripts/migrate-storage.ts @@ -0,0 +1 @@ +import '../src/storage-migrate.js' diff --git a/apps/backend/src/config/defaults.ts b/apps/backend/src/config/defaults.ts index 31277c59..74e5766e 100644 --- a/apps/backend/src/config/defaults.ts +++ b/apps/backend/src/config/defaults.ts @@ -32,12 +32,20 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { }, storage: { + mode: 'minio', endpoint: 'http://127.0.0.1:9000', bucket: 'order-site', accessKeyId: 'minioadmin', secretAccessKey: 'minioadmin', region: 'us-east-1', maxUploadSizeMb: 10, + oss: { + endpoint: '', + bucket: '', + accessKeyId: '', + secretAccessKey: '', + region: 'oss-cn-hangzhou', + }, }, orders: { diff --git a/apps/backend/src/config/env-overrides.ts b/apps/backend/src/config/env-overrides.ts index b72ffc98..222387ee 100644 --- a/apps/backend/src/config/env-overrides.ts +++ b/apps/backend/src/config/env-overrides.ts @@ -40,12 +40,18 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [ integerEnv('DATABASE_CONNECTION_TIMEOUT_MS', ['database', 'connectionTimeoutMs']), integerEnv('DATABASE_STATEMENT_TIMEOUT_MS', ['database', 'statementTimeoutMs']), integerEnv('DATABASE_SLOW_QUERY_THRESHOLD_MS', ['database', 'slowQueryThresholdMs']), + stringEnv('STORAGE_MODE', ['storage', 'mode']), stringEnv('STORAGE_ENDPOINT', ['storage', 'endpoint']), stringEnv('STORAGE_BUCKET', ['storage', 'bucket']), stringEnv('STORAGE_ACCESS_KEY_ID', ['storage', 'accessKeyId']), stringEnv('STORAGE_SECRET_ACCESS_KEY', ['storage', 'secretAccessKey']), stringEnv('STORAGE_REGION', ['storage', 'region']), integerEnv('STORAGE_MAX_UPLOAD_SIZE_MB', ['storage', 'maxUploadSizeMb']), + stringEnv('STORAGE_OSS_ENDPOINT', ['storage', 'oss', 'endpoint']), + stringEnv('STORAGE_OSS_BUCKET', ['storage', 'oss', 'bucket']), + stringEnv('STORAGE_OSS_ACCESS_KEY_ID', ['storage', 'oss', 'accessKeyId']), + stringEnv('STORAGE_OSS_SECRET_ACCESS_KEY', ['storage', 'oss', 'secretAccessKey']), + stringEnv('STORAGE_OSS_REGION', ['storage', 'oss', 'region']), integerEnv('CLAIM_TOKEN_TTL_HOURS', ['orders', 'tokenTtlHours']), stringEnv('ADMIN_SESSION_SECRET', ['admin', 'sessionSecret']), integerEnv('ADMIN_SESSION_TTL_HOURS', ['admin', 'sessionTtlHours']), diff --git a/apps/backend/src/config/runtime-validation.ts b/apps/backend/src/config/runtime-validation.ts index 0f956827..355a6064 100644 --- a/apps/backend/src/config/runtime-validation.ts +++ b/apps/backend/src/config/runtime-validation.ts @@ -79,6 +79,13 @@ export function validateRuntimeConfig( min: 1, max: 100, }) + const storageMode = normalizeStorageMode(config.storage?.mode) + if (!storageMode) { + issues.push({ + path: 'storage.mode', + message: '仅支持 minio、dual 或 oss', + }) + } requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 0 }) requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 }) requireInteger( @@ -116,17 +123,17 @@ export function validateRuntimeConfig( validateOptionalHttpUrl(issues, 'orders.claimBaseUrl', config.orders?.claimBaseUrl) validateOptionalStorageEndpoint(issues, 'storage.endpoint', config.storage?.endpoint) + validateOptionalStorageEndpoint(issues, 'storage.oss.endpoint', config.storage?.oss?.endpoint) validateRequiredString(issues, 'data.root', config.data?.root) if (productionLike) { validateRequiredString(issues, 'database.url', config.database?.url) - validateRequiredString(issues, 'storage.endpoint', config.storage?.endpoint) - validateOptionalStorageEndpoint(issues, 'storage.endpoint', config.storage?.endpoint) - validateRequiredString(issues, 'storage.bucket', config.storage?.bucket) - validateRequiredString(issues, 'storage.accessKeyId', config.storage?.accessKeyId) - validateSecret(issues, 'storage.secretAccessKey', config.storage?.secretAccessKey, { - minLength: 8, - }) + if (storageMode === 'minio' || storageMode === 'dual') { + validateStorageCredentials(issues, 'storage', config.storage) + } + if (storageMode === 'oss' || storageMode === 'dual') { + validateStorageCredentials(issues, 'storage.oss', config.storage?.oss) + } validateRequiredHttpUrl(issues, 'orders.claimBaseUrl', config.orders?.claimBaseUrl) validateSecret(issues, 'admin.sessionSecret', config.admin?.sessionSecret, { minLength: 32 }) validateAdminDefaultUsers(issues, config.admin?.defaultUsers) @@ -135,6 +142,35 @@ export function validateRuntimeConfig( return issues } +function normalizeStorageMode(value: unknown): 'minio' | 'dual' | 'oss' | '' { + const mode = String(value || 'minio') + .trim() + .toLowerCase() + return mode === 'minio' || mode === 'dual' || mode === 'oss' ? mode : '' +} + +function validateStorageCredentials( + issues: RuntimeConfigValidationIssue[], + configPath: string, + storage: + | { + endpoint?: unknown + bucket?: unknown + accessKeyId?: unknown + secretAccessKey?: unknown + } + | null + | undefined, +): void { + validateRequiredString(issues, `${configPath}.endpoint`, storage?.endpoint) + validateOptionalStorageEndpoint(issues, `${configPath}.endpoint`, storage?.endpoint) + validateRequiredString(issues, `${configPath}.bucket`, storage?.bucket) + validateRequiredString(issues, `${configPath}.accessKeyId`, storage?.accessKeyId) + validateSecret(issues, `${configPath}.secretAccessKey`, storage?.secretAccessKey, { + minLength: 8, + }) +} + export function isProductionLike(env: RuntimeEnvironment = process.env): boolean { return env.NODE_ENV === 'production' } diff --git a/apps/backend/src/services/file-storage/object-storage.test.ts b/apps/backend/src/services/file-storage/object-storage.test.ts new file mode 100644 index 00000000..6cdae6fb --- /dev/null +++ b/apps/backend/src/services/file-storage/object-storage.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +import test from 'node:test' + +import { ObjectStorage } from './object-storage.js' + +test('S3 签名使用配置的对象存储 region', async () => { + const authorizationHeaders: string[] = [] + const server = createServer((request, response) => { + const authorization = request.headers.authorization + if (authorization) authorizationHeaders.push(authorization) + response.statusCode = 200 + response.end() + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + + try { + const address = server.address() + assert.ok(address && typeof address === 'object') + const storage = new ObjectStorage({ + endpoint: `http://127.0.0.1:${address.port}`, + bucket: 'order-site', + accessKeyId: 'test-access-key', + secretAccessKey: 'test-secret-key', + region: 'oss-cn-hangzhou', + }) + + await storage.putObject({ + key: 'test/example.jpg', + content: Buffer.from('test'), + contentType: 'image/jpeg', + }) + + assert.ok(authorizationHeaders.length >= 2) + assert.match( + authorizationHeaders[0] || '', + /Credential=test-access-key\/\d{8}\/oss-cn-hangzhou\//, + ) + assert.match( + authorizationHeaders.at(-1) || '', + /Credential=test-access-key\/\d{8}\/oss-cn-hangzhou\//, + ) + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ) + } +}) diff --git a/apps/backend/src/services/file-storage/object-storage.ts b/apps/backend/src/services/file-storage/object-storage.ts index 6b78062f..25d09fa7 100644 --- a/apps/backend/src/services/file-storage/object-storage.ts +++ b/apps/backend/src/services/file-storage/object-storage.ts @@ -1,11 +1,19 @@ import { Readable } from 'node:stream' -import * as Minio from 'minio' +import { + CreateBucketCommand, + GetObjectCommand, + HeadBucketCommand, + HeadObjectCommand, + ListObjectsV2Command, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3' import { runtimeConfig } from '../../config/runtime.js' import { createHttpError } from '../../utils/http.js' -type ObjectStorageConfig = { +export type ObjectStorageConfig = { endpoint: string bucket: string accessKeyId: string @@ -13,28 +21,50 @@ type ObjectStorageConfig = { region: string } -type StoredObject = { +export type StorageObjectSummary = { + key: string + size: number + etag: string + lastModified?: Date +} + +export type StoredObjectStat = { + key: string + size: number + etag: string + contentType: string + lastModified?: Date +} + +export type StoredObject = { reader: Readable contentType: string size: number } export class ObjectStorage { - private readonly client: Minio.Client + private readonly client: S3Client private readonly bucket: string - private readonly region: string + private readonly pathStyle: boolean private bucketReady = false + private bucketReadyPromise: Promise | null = null constructor(config: ObjectStorageConfig) { const endpoint = normalizeStorageEndpoint(config.endpoint) this.bucket = String(config.bucket || '').trim() - this.region = String(config.region || 'us-east-1').trim() || 'us-east-1' - this.client = new Minio.Client({ - endPoint: endpoint.endPoint, - port: endpoint.port, - useSSL: endpoint.useSSL, - accessKey: String(config.accessKeyId || '').trim(), - secretKey: String(config.secretAccessKey || '').trim(), + // 阿里云 OSS 强制虚拟主机风格(bucket.endpoint);MinIO 等自建端点走 path-style。 + this.pathStyle = !endpoint.endPoint.toLowerCase().endsWith('.aliyuncs.com') + this.client = new S3Client({ + region: String(config.region || 'us-east-1').trim() || 'us-east-1', + endpoint: buildEndpointUrl(endpoint), + forcePathStyle: this.pathStyle, + credentials: { + accessKeyId: String(config.accessKeyId || '').trim(), + secretAccessKey: String(config.secretAccessKey || '').trim(), + }, + // 默认的 CRC32 请求校验头不被 OSS 的 S3 兼容层支持,关闭以保持兼容。 + requestChecksumCalculation: 'WHEN_REQUIRED', + responseChecksumValidation: 'WHEN_REQUIRED', }) } @@ -45,61 +75,197 @@ export class ObjectStorage { metadata?: Record }): Promise { await this.ensureBucketReady() - await this.client.putObject(this.bucket, input.key, input.content, input.content.length, { - 'Content-Type': input.contentType, - ...sanitizeObjectMetadata(input.metadata), - }) + await this.client.send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: input.key, + Body: input.content, + ContentType: input.contentType, + ...(input.metadata ? { Metadata: sanitizeObjectMetadata(input.metadata) } : {}), + }), + ) } async getObject(key: string): Promise { await this.ensureBucketReady() const objectKey = normalizeObjectKey(key) - const stat = await this.client.statObject(this.bucket, objectKey) - const reader = await this.client.getObject(this.bucket, objectKey) - const metadata = stat.metaData || {} - const contentType = String( - metadata['content-type'] || - metadata['Content-Type'] || - metadata.contentType || - 'application/octet-stream', + const output = await this.client.send( + new GetObjectCommand({ Bucket: this.bucket, Key: objectKey }), ) + const reader = output.Body as Readable + if (!reader) { + throw createHttpError('文件不存在或暂不可访问', { + statusCode: 404, + errorCode: 'file_not_found', + }) + } return { reader, - contentType, - size: Number(stat.size || 0), + contentType: String(output.ContentType || 'application/octet-stream'), + size: Number(output.ContentLength || 0), } } + async statObject(key: string): Promise { + await this.ensureBucketReady() + const objectKey = normalizeObjectKey(key) + const output = await this.client.send( + new HeadObjectCommand({ Bucket: this.bucket, Key: objectKey }), + ) + const stat: StoredObjectStat = { + key: objectKey, + size: Number(output.ContentLength || 0), + etag: String(output.ETag || ''), + contentType: String(output.ContentType || 'application/octet-stream'), + } + if (output.LastModified) { + stat.lastModified = output.LastModified + } + return stat + } + + async listObjects(): Promise { + await this.ensureBucketReady() + const objects: StorageObjectSummary[] = [] + let continuationToken: string | undefined + do { + const output = await this.client.send( + new ListObjectsV2Command({ Bucket: this.bucket, ContinuationToken: continuationToken }), + ) + for (const item of output.Contents || []) { + if (!item.Key) { + continue + } + const summary: StorageObjectSummary = { + key: item.Key, + size: Number(item.Size || 0), + etag: String(item.ETag || ''), + } + if (item.LastModified) { + summary.lastModified = item.LastModified + } + objects.push(summary) + } + continuationToken = output.IsTruncated ? output.NextContinuationToken : undefined + } while (continuationToken) + return objects + } + private async ensureBucketReady(): Promise { if (this.bucketReady) { return } - const exists = await this.client.bucketExists(this.bucket) - if (!exists) { - await this.client.makeBucket(this.bucket, this.region) + if (!this.bucketReadyPromise) { + this.bucketReadyPromise = (async () => { + try { + await this.client.send(new HeadBucketCommand({ Bucket: this.bucket })) + } catch (error) { + if (!isNotFoundError(error)) { + throw error + } + if (!this.pathStyle) { + throw new Error(`OSS bucket ${this.bucket} 不存在,请先在阿里云控制台创建`) + } + await this.client.send(new CreateBucketCommand({ Bucket: this.bucket })) + } + this.bucketReady = true + })() + } + try { + await this.bucketReadyPromise + } catch (error) { + this.bucketReadyPromise = null + throw error } - this.bucketReady = true } } -let storageInstance: ObjectStorage | null = null +export class StorageRouter { + private readonly mode: 'minio' | 'dual' | 'oss' + private readonly legacy: ObjectStorage + private readonly oss: ObjectStorage | null -export function getObjectStorage(): ObjectStorage { + constructor(mode: 'minio' | 'dual' | 'oss', legacy: ObjectStorage, oss: ObjectStorage | null) { + this.mode = mode + this.legacy = legacy + this.oss = oss + if ((mode === 'dual' || mode === 'oss') && !oss) { + throw new Error(`存储模式 ${mode} 缺少 OSS 配置`) + } + } + + async putObject(input: Parameters[0]): Promise { + if (this.mode === 'minio') { + await this.legacy.putObject(input) + return + } + if (this.mode === 'oss') { + await this.oss!.putObject(input) + return + } + // 双写要求两个后端都成功,避免数据库记录指向未完成迁移的对象。 + await Promise.all([this.legacy.putObject(input), this.oss!.putObject(input)]) + } + + async getObject(key: string): Promise { + if (this.mode === 'minio') { + return this.legacy.getObject(key) + } + if (this.mode === 'oss') { + return this.oss!.getObject(key) + } + try { + return await this.legacy.getObject(key) + } catch { + return this.oss!.getObject(key) + } + } +} + +let storageInstance: StorageRouter | null = null + +export function getObjectStorage(): StorageRouter { if (!storageInstance) { - storageInstance = new ObjectStorage({ - endpoint: runtimeConfig.storage.endpoint, - bucket: runtimeConfig.storage.bucket, - accessKeyId: runtimeConfig.storage.accessKeyId, - secretAccessKey: runtimeConfig.storage.secretAccessKey, - region: runtimeConfig.storage.region, - }) + const mode = normalizeStorageMode(runtimeConfig.storage.mode) + const legacy = new ObjectStorage(toObjectStorageConfig(runtimeConfig.storage)) + const oss = + mode === 'minio' ? null : new ObjectStorage(toObjectStorageConfig(runtimeConfig.storage.oss)) + storageInstance = new StorageRouter(mode, legacy, oss) } return storageInstance } +export function createConfiguredObjectStorage(config: ObjectStorageConfig): ObjectStorage { + return new ObjectStorage(config) +} + +function normalizeStorageMode(value: unknown): 'minio' | 'dual' | 'oss' { + const mode = String(value || 'minio') + .trim() + .toLowerCase() + if (mode === 'dual' || mode === 'oss') return mode + return 'minio' +} + +function toObjectStorageConfig(config: { + endpoint: string + bucket: string + accessKeyId: string + secretAccessKey: string + region: string +}): ObjectStorageConfig { + return { + endpoint: config.endpoint, + bucket: config.bucket, + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + region: config.region, + } +} + export function normalizeStorageEndpoint(rawEndpoint: unknown) { const endpoint = String(rawEndpoint || '').trim() const parsed = new URL(/^https?:\/\//i.test(endpoint) ? endpoint : `http://${endpoint}`) @@ -112,7 +278,26 @@ export function normalizeStorageEndpoint(rawEndpoint: unknown) { } } -export function normalizeObjectKey(rawKey: unknown): string { +function buildEndpointUrl(endpoint: { endPoint: string; port: number; useSSL: boolean }): string { + const scheme = endpoint.useSSL ? 'https' : 'http' + const defaultPort = endpoint.useSSL ? 443 : 80 + const suffix = endpoint.port && endpoint.port !== defaultPort ? `:${endpoint.port}` : '' + return `${scheme}://${endpoint.endPoint}${suffix}` +} + +function isNotFoundError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + const candidate = error as { name?: string; $metadata?: { httpStatusCode?: number } } + return ( + candidate.name === 'NotFound' || + candidate.name === 'NoSuchBucket' || + candidate.$metadata?.httpStatusCode === 404 + ) +} + +export function normalizeObjectKey(rawKey: unknown) { const key = String(rawKey || '') .trim() .replace(/^\/+/, '') diff --git a/apps/backend/src/storage-migrate.ts b/apps/backend/src/storage-migrate.ts new file mode 100644 index 00000000..fdb6eddf --- /dev/null +++ b/apps/backend/src/storage-migrate.ts @@ -0,0 +1,176 @@ +import crypto from 'node:crypto' +import process from 'node:process' + +import { + createConfiguredObjectStorage, + type ObjectStorage, +} from './services/file-storage/object-storage.js' + +const command = process.argv[2] || '' +const concurrency = readConcurrency() +if (!['copy', 'verify'].includes(command)) { + console.error('用法:node dist/storage-migrate.js copy|verify') + process.exit(2) +} + +const source = createConfiguredObjectStorage(readStorageConfig('STORAGE')) +const destination = createConfiguredObjectStorage(readStorageConfig('STORAGE_OSS')) + +if (command === 'copy') { + await copyObjects(source, destination) +} else { + await verifyObjects(source, destination) +} + +function readStorageConfig(prefix: 'STORAGE' | 'STORAGE_OSS') { + return { + endpoint: required(`${prefix}_ENDPOINT`), + bucket: required(`${prefix}_BUCKET`), + accessKeyId: required(`${prefix}_ACCESS_KEY_ID`), + secretAccessKey: required(`${prefix}_SECRET_ACCESS_KEY`), + region: process.env[`${prefix}_REGION`] || 'us-east-1', + } +} + +async function copyObjects(sourceStorage: ObjectStorage, destinationStorage: ObjectStorage) { + const objects = await sourceStorage.listObjects() + console.info(`源存储对象数:${objects.length}`) + let copied = 0 + let skipped = 0 + let completed = 0 + await runConcurrent(objects, concurrency, async (object) => { + const existing = await readStat(destinationStorage, object.key) + const sourceEtag = normalizeEtag(object.etag) + const destinationEtag = normalizeEtag(existing?.etag) + if ( + existing && + Number(existing.size || 0) === object.size && + sourceEtag && + destinationEtag && + destinationEtag === sourceEtag + ) { + skipped += 1 + } else { + const stored = await sourceStorage.getObject(object.key) + const content = await readBuffer(stored.reader) + await destinationStorage.putObject({ + key: object.key, + content, + contentType: stored.contentType, + }) + copied += 1 + } + completed += 1 + logProgress('复制', completed, objects.length) + }) + console.info(`复制完成:新增/覆盖 ${copied},已存在且大小、ETag 一致 ${skipped}`) +} + +async function verifyObjects(sourceStorage: ObjectStorage, destinationStorage: ObjectStorage) { + const sourceObjects = await sourceStorage.listObjects() + const destinationObjects = await destinationStorage.listObjects() + const destinationByKey = new Map(destinationObjects.map((item) => [item.key, item])) + const missing = sourceObjects.filter((item) => !destinationByKey.has(item.key)) + const mismatches: string[] = [] + let completed = 0 + await runConcurrent(sourceObjects, concurrency, async (object) => { + const destination = destinationByKey.get(object.key) + if (!destination) { + completed += 1 + logProgress('校验', completed, sourceObjects.length) + return + } + if (Number(destination.size || 0) !== object.size) { + mismatches.push(object.key) + completed += 1 + logProgress('校验', completed, sourceObjects.length) + return + } + const sourceStored = await sourceStorage.getObject(object.key) + const destinationStored = await destinationStorage.getObject(object.key) + const [sourceHash, destinationHash] = await Promise.all([ + hashStream(sourceStored.reader), + hashStream(destinationStored.reader), + ]) + if ( + sourceHash !== destinationHash || + sourceStored.contentType !== destinationStored.contentType || + sourceStored.size !== destinationStored.size + ) { + mismatches.push(object.key) + } + completed += 1 + logProgress('校验', completed, sourceObjects.length) + }) + if (missing.length || mismatches.length) { + console.error(`校验失败:缺失 ${missing.length},内容不一致 ${mismatches.length}`) + if (missing.length) console.error(`缺失对象:${missing.slice(0, 20).join(', ')}`) + if (mismatches.length) console.error(`不一致对象:${mismatches.slice(0, 20).join(', ')}`) + process.exit(1) + } + console.info(`校验通过:${sourceObjects.length} 个源对象全部存在且内容一致`) +} + +async function runConcurrent( + items: T[], + limit: number, + worker: (item: T) => Promise, +): Promise { + let nextIndex = 0 + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (true) { + const index = nextIndex++ + const item = items[index] + if (item === undefined) return + await worker(item) + } + }) + await Promise.all(workers) +} + +function normalizeEtag(value: unknown): string { + return String(value || '') + .trim() + .replace(/^"|"$/g, '') + .toLowerCase() +} + +function logProgress(label: string, completed: number, total: number): void { + if (completed === total || completed % 100 === 0) { + console.info(`${label}进度:${completed}/${total}(并发 ${concurrency})`) + } +} + +async function readStat(storage: ObjectStorage, key: string) { + try { + return await storage.statObject(key) + } catch { + return null + } +} + +async function readBuffer(reader: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = [] + for await (const chunk of reader) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + return Buffer.concat(chunks) +} + +async function hashStream(reader: NodeJS.ReadableStream): Promise { + const hash = crypto.createHash('sha256') + for await (const chunk of reader) hash.update(chunk) + return hash.digest('hex') +} + +function required(name: string): string { + const value = String(process.env[name] || '').trim() + if (!value) { + console.error(`缺少环境变量:${name}`) + process.exit(2) + } + return value +} + +function readConcurrency(): number { + const value = Number(process.env.STORAGE_MIGRATION_CONCURRENCY || 8) + return Number.isInteger(value) && value > 0 ? Math.min(value, 32) : 8 +} diff --git a/apps/backend/src/types/runtime-config.ts b/apps/backend/src/types/runtime-config.ts index e0a2e873..7f1b9667 100644 --- a/apps/backend/src/types/runtime-config.ts +++ b/apps/backend/src/types/runtime-config.ts @@ -43,12 +43,20 @@ export type RuntimeConfig = { slowQueryThresholdMs?: number } storage: { + mode: 'minio' | 'dual' | 'oss' endpoint: string bucket: string accessKeyId: string secretAccessKey: string region: string maxUploadSizeMb: number + oss: { + endpoint: string + bucket: string + accessKeyId: string + secretAccessKey: string + region: string + } } orders: { claimBaseUrl: string diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 333fdd71..d72f5915 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -87,12 +87,18 @@ services: DATA_ROOT: /app/data LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-30} CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim} + STORAGE_MODE: ${STORAGE_MODE:-minio} STORAGE_ENDPOINT: ${STORAGE_ENDPOINT:-http://minio:9000} STORAGE_BUCKET: ${STORAGE_BUCKET:-order-site} STORAGE_ACCESS_KEY_ID: ${STORAGE_ACCESS_KEY_ID:-minioadmin} STORAGE_SECRET_ACCESS_KEY: ${STORAGE_SECRET_ACCESS_KEY:-minioadmin} STORAGE_REGION: ${STORAGE_REGION:-us-east-1} STORAGE_MAX_UPLOAD_SIZE_MB: ${STORAGE_MAX_UPLOAD_SIZE_MB:-10} + STORAGE_OSS_ENDPOINT: ${STORAGE_OSS_ENDPOINT:-} + STORAGE_OSS_BUCKET: ${STORAGE_OSS_BUCKET:-} + STORAGE_OSS_ACCESS_KEY_ID: ${STORAGE_OSS_ACCESS_KEY_ID:-} + STORAGE_OSS_SECRET_ACCESS_KEY: ${STORAGE_OSS_SECRET_ACCESS_KEY:-} + STORAGE_OSS_REGION: ${STORAGE_OSS_REGION:-oss-cn-hangzhou} WORKSPACE_ROOT: /workspace # ADMIN_* 仅走 env_file,避免 JSON 经 ${} 注入时被 YAML 破坏 KUASHOU_INDUSTRY_RATE_LIMIT_MAX: ${KUASHOU_INDUSTRY_RATE_LIMIT_MAX:-120} diff --git a/docker-compose.yml b/docker-compose.yml index 77e13d41..284cea60 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -71,12 +71,18 @@ services: DATA_ROOT: /app/data LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-30} CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim} + STORAGE_MODE: ${STORAGE_MODE:-minio} STORAGE_ENDPOINT: ${STORAGE_ENDPOINT:-http://minio:9000} STORAGE_BUCKET: ${STORAGE_BUCKET:-order-site} STORAGE_ACCESS_KEY_ID: ${STORAGE_ACCESS_KEY_ID:-minioadmin} STORAGE_SECRET_ACCESS_KEY: ${STORAGE_SECRET_ACCESS_KEY:-minioadmin} STORAGE_REGION: ${STORAGE_REGION:-us-east-1} STORAGE_MAX_UPLOAD_SIZE_MB: ${STORAGE_MAX_UPLOAD_SIZE_MB:-10} + STORAGE_OSS_ENDPOINT: ${STORAGE_OSS_ENDPOINT:-} + STORAGE_OSS_BUCKET: ${STORAGE_OSS_BUCKET:-} + STORAGE_OSS_ACCESS_KEY_ID: ${STORAGE_OSS_ACCESS_KEY_ID:-} + STORAGE_OSS_SECRET_ACCESS_KEY: ${STORAGE_OSS_SECRET_ACCESS_KEY:-} + STORAGE_OSS_REGION: ${STORAGE_OSS_REGION:-oss-cn-hangzhou} # ADMIN_SESSION_SECRET / ADMIN_DEFAULT_USERS_JSON 仅走 env_file。 # 勿在此处用 ${ADMIN_DEFAULT_USERS_JSON} 再注入:JSON 会被 YAML 解析破坏,导致生产校验失败。 KUASHOU_INDUSTRY_RATE_LIMIT_MAX: ${KUASHOU_INDUSTRY_RATE_LIMIT_MAX:-120} diff --git a/docs/服务器图片迁移到阿里云OSS.md b/docs/服务器图片迁移到阿里云OSS.md new file mode 100644 index 00000000..e5070278 --- /dev/null +++ b/docs/服务器图片迁移到阿里云OSS.md @@ -0,0 +1,58 @@ +# 服务器图片迁移到阿里云 OSS + +本流程只操作服务器上的 MinIO。Mac 本地继续使用 `.env.mac-docker.example` 的 MinIO 配置,不会把本地图片迁走。 + +## 1. 准备 OSS + +在阿里云 OSS 创建与现有 MinIO bucket 对应的 bucket,并创建只允许该 bucket 的 RAM 账号。建议 OSS 使用 S3 兼容端点,例如 `https://oss-cn-hangzhou.aliyuncs.com`,不要把 bucket 名写进 endpoint。 + +编辑服务器 `.env`,先保留 MinIO 为主存储,并填好 OSS 目标配置: + +```dotenv +STORAGE_MODE=dual +STORAGE_ENDPOINT=http://minio:9000 +STORAGE_BUCKET=order-site +STORAGE_ACCESS_KEY_ID=原 MinIO 账号 +STORAGE_SECRET_ACCESS_KEY=原 MinIO 密钥 +STORAGE_REGION=us-east-1 +STORAGE_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com +STORAGE_OSS_BUCKET=OSS bucket 名 +STORAGE_OSS_ACCESS_KEY_ID=OSS RAM AccessKey ID +STORAGE_OSS_SECRET_ACCESS_KEY=OSS RAM AccessKey Secret +STORAGE_OSS_REGION=oss-cn-hangzhou +``` + +重建后端容器使配置生效。`dual` 模式下新上传的原图和缩略图会同时写入 MinIO、OSS;读取仍优先 MinIO,失败时回退 OSS,所以迁移期间无需停机。 + +## 2. 增量复制与校验 + +在服务器项目目录执行。容器镜像已内置迁移程序,下面命令不会停止现有后端: + +```bash +docker compose exec backend node dist/storage-migrate.js copy +docker compose exec backend node dist/storage-migrate.js verify +``` + +复制可以重复执行,目标中已有且大小、ETag 一致的对象会跳过。程序默认 8 路并发,可通过 `docker compose exec -e STORAGE_MIGRATION_CONCURRENCY=16 ...` 调整(最大 32)。校验会比较源、目标每个对象的 SHA-256、大小和 Content-Type;复制阶段的 ETag 仅用于幂等跳过,最终仍会对所有对象做完整内容校验。复制期间仍有新上传时,复制完成后再次执行 `copy` 和 `verify`,直到校验通过且没有遗漏。 + +## 3. 切换到 OSS + +确认校验通过后,把服务器 `.env` 的 `STORAGE_MODE` 改为 `oss`,重新创建 backend: + +```bash +docker compose up -d --no-deps backend +curl -fsS https://你的域名/health/ready +``` + +此时新旧图片都只从 OSS 读取,数据库中的对象 key 和访问 URL 不需要改动。 + +## 4. 停止 MinIO + +先观察 OSS 模式运行一段时间,确认图片上传、后台预览、打手端验收图片均正常,再停止并移除 MinIO 容器。不要删除 `minio_data` 卷,至少保留一个回滚窗口: + +```bash +docker compose --profile storage stop minio +docker compose --profile storage rm -f minio +``` + +若需要回滚,恢复 `STORAGE_MODE=dual`(或 `minio`),重新启动 MinIO 即可。确认 OSS 已稳定且备份完成后,再单独评估删除 `minio_data` 卷。