create-bubbles 0.1.25 → 0.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +3 -9
- package/package.json +1 -1
- package/template-nextjs-vinext-eslint/Dockerfile +4 -4
- package/template-nextjs-vinext-eslint/README.md +6 -2
- package/template-nextjs-vinext-eslint/docker-compose.yaml +4 -4
- package/template-nextjs-vinext-eslint/eslint.config.js +2 -0
- package/template-nextjs-vinext-eslint/package.json +28 -26
- package/template-nextjs-vinext-eslint/pnpm-workspace.yaml +13 -1
- package/template-nextjs-vinext-eslint/src/utils/request/core/index.ts +63 -45
- package/template-nextjs-vinext-eslint/src/utils/request/core/utils.ts +12 -6
- package/template-nextjs-vinext-eslint/vite.config.ts +1 -1
- package/template-react-rsbuild-biome/.agents/rules/SVG/344/275/277/347/224/250.md +8 -0
- package/template-react-rsbuild-biome/AGENTS.md +11 -0
- package/template-react-rsbuild-biome/index.html +2 -2
- package/template-react-rsbuild-biome/package.json +25 -24
- package/template-react-rsbuild-biome/pnpm-workspace.yaml +5 -0
- package/template-react-rsbuild-biome/rsbuild.config.ts +1 -1
- package/template-react-rsbuild-biome/src/assets/icon/logo.svg +1 -1
- package/template-react-rsbuild-biome/src/assets/svg/draft.svg +4 -0
- package/template-react-rsbuild-biome/src/components/Icon/svg-icon/README.md +37 -0
- package/template-react-rsbuild-biome/src/components/Icon/svg-icon/SvgIcon.tsx +27 -0
- package/template-react-rsbuild-biome/src/env.d.ts +7 -0
- package/template-react-rsbuild-biome/src/utils/request/index.ts +3 -4
- package/template-react-rsbuild-biome/tsconfig.json +1 -2
- package/template-taro-react-oxc/.oxlintrc.json +0 -1
- package/template-taro-react-oxc/package.json +53 -52
- package/template-taro-react-oxc/pnpm-workspace.yaml +46 -0
- package/template-taro-vue-eslint/package.json +56 -56
- package/template-vp-monorepo-react-hono/apps/web/.agents/rules/SVG/344/275/277/347/224/250.md +8 -0
- package/template-vp-monorepo-react-hono/apps/web/AGENTS.md +11 -0
- package/template-vp-monorepo-react-hono/apps/web/package.json +1 -0
- package/template-vp-monorepo-react-hono/apps/web/src/assets/svg/draft.svg +4 -0
- package/template-vp-monorepo-react-hono/apps/web/src/components/Icon/svg-icon/README.md +37 -0
- package/template-vp-monorepo-react-hono/apps/web/src/components/Icon/svg-icon/SvgIcon.tsx +27 -0
- package/template-vp-monorepo-react-hono/apps/web/tsconfig.json +1 -1
- package/template-vp-monorepo-react-hono/apps/web/vite.config.ts +2 -0
- package/template-vp-monorepo-react-hono/pnpm-workspace.yaml +1 -0
- package/template-vp-monorepo-react-nestjs/.agents/rules//344/273/243/347/240/201/350/256/276/350/256/241.md +4 -0
- package/template-vp-monorepo-react-nestjs/.agents/rules//345/205/261/344/272/253/344/273/243/347/240/201.md +4 -0
- package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//351/234/200/346/261/202/346/226/207/346/241/243.md +177 -0
- package/template-vp-monorepo-react-nestjs/.spaces/server/04.upload.md +4504 -0
- package/template-vp-monorepo-react-nestjs/.spaces/server/05./344/270/212/344/274/240/346/224/271/351/200/240/346/226/271/346/241/210/README.md +858 -0
- package/template-vp-monorepo-react-nestjs/.spaces/server/06./345/205/254/345/205/261/351/230/237/345/210/227/346/216/245/345/205/245/346/226/271/346/241/210/README.md +1890 -0
- package/template-vp-monorepo-react-nestjs/.spaces/server/06./345/205/254/345/205/261/351/230/237/345/210/227/346/216/245/345/205/245/346/226/271/346/241/210//344/270/212/344/274/240/346/270/205/347/220/206.md +1465 -0
- package/template-vp-monorepo-react-nestjs/AGENTS.md +8 -12
- package/template-vp-monorepo-react-nestjs/apps/server/.agents/rules/error-handling.md +84 -0
- package/template-vp-monorepo-react-nestjs/apps/server/.env.development +60 -0
- package/template-vp-monorepo-react-nestjs/apps/server/.env.production +16 -0
- package/template-vp-monorepo-react-nestjs/apps/server/AGENTS.md +9 -0
- package/template-vp-monorepo-react-nestjs/apps/server/drizzle/0000_military_colonel_america.sql +11 -0
- package/template-vp-monorepo-react-nestjs/apps/server/drizzle/0001_loud_siren.sql +27 -0
- package/template-vp-monorepo-react-nestjs/apps/server/drizzle/meta/0000_snapshot.json +97 -0
- package/template-vp-monorepo-react-nestjs/apps/server/drizzle/meta/0001_snapshot.json +354 -0
- package/template-vp-monorepo-react-nestjs/apps/server/drizzle/meta/_journal.json +20 -0
- package/template-vp-monorepo-react-nestjs/apps/server/package.json +4 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/app.module.ts +14 -2
- package/template-vp-monorepo-react-nestjs/apps/server/src/common/adapters/fastify.adapter.ts +6 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/config/index.ts +2 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/config/queue.config.ts +105 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/config/storage.config.ts +33 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/database/schema.ts +57 -1
- package/template-vp-monorepo-react-nestjs/apps/server/src/main.ts +1 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/auth.service.ts +1 -3
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/dto/auth-repsponse.dto.ts +2 -2
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/queue-worker.bootstrap.ts +24 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/task-queue.constants.ts +24 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/task-queue.contracts.ts +35 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/task-queue.module.ts +56 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/task-queue.processor.ts +62 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/task-queue.service.ts +58 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/test/error-catelog.spec.ts +34 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/dto/initiate-mutipart-upload.dto.ts +26 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/dto/upload-params.dto.ts +15 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/storage/exact-size.transform.ts +66 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/storage/minio-storage.adapter.ts +239 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/storage/storage.port.ts +61 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.constants.ts +23 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.controller.ts +109 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.errors.ts +80 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.module.ts +20 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.reponsitory.ts +166 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.service.ts +696 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/utils/object-key.ts +10 -0
- package/template-vp-monorepo-react-nestjs/apps/server/test/task-queue.integration.spec.ts +120 -0
- package/template-vp-monorepo-react-nestjs/apps/server/test/task-queue.processor.spec.ts +63 -0
- package/template-vp-monorepo-react-nestjs/apps/server/test/task-queue.service.spec.ts +106 -0
- package/template-vp-monorepo-react-nestjs/apps/web/.agents/rules/SVG/344/275/277/347/224/250.md +8 -0
- package/template-vp-monorepo-react-nestjs/apps/web/.agents/rules//345/274/271/347/252/227/347/273/204/344/273/266.md +4 -0
- package/template-vp-monorepo-react-nestjs/apps/web/.agents/rules//346/226/207/344/273/266/346/213/206/345/210/206.md +4 -0
- package/template-vp-monorepo-react-nestjs/apps/web/.agents/rules//347/273/204/344/273/266/345/221/275/345/220/215.md +3 -0
- package/template-vp-monorepo-react-nestjs/apps/web/.agents/rules//350/241/250/346/240/274/351/241/265/351/235/242.md +11 -0
- package/template-vp-monorepo-react-nestjs/apps/web/.agents/rules//351/241/265/351/235/242/346/216/245/345/217/243.md +3 -0
- package/template-vp-monorepo-react-nestjs/apps/web/.env.dev +1 -1
- package/template-vp-monorepo-react-nestjs/apps/web/AGENTS.md +16 -0
- package/template-vp-monorepo-react-nestjs/apps/web/README.MD +153 -0
- package/template-vp-monorepo-react-nestjs/apps/web/index.html +3 -2
- package/template-vp-monorepo-react-nestjs/apps/web/package.json +6 -1
- package/template-vp-monorepo-react-nestjs/apps/web/public/favicon.svg +9 -1
- package/template-vp-monorepo-react-nestjs/apps/web/src/App.module.css +5 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/App.tsx +16 -9
- package/template-vp-monorepo-react-nestjs/apps/web/src/api/auth.ts +9 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/draft.svg +4 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/components/DraftProTable/DraftProTable.module.css +78 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/components/DraftProTable/DraftProTable.tsx +99 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/components/DraftProTable/README.md +34 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/components/FullHeightProTable/FullHeightProTable.module.css +54 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/components/FullHeightProTable/FullHeightProTable.tsx +41 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/components/Icon/svg-icon/README.md +37 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/components/Icon/svg-icon/SvgIcon.tsx +27 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/components/Loading/PageLoading.module.css +7 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/components/Loading/PageLoading.tsx +2 -1
- package/template-vp-monorepo-react-nestjs/apps/web/src/config/theme.ts +20 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/layouts/BasicLayout.module.css +17 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/layouts/BasicLayout.tsx +106 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/i18n/index.module.css +23 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/i18n/index.tsx +53 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/components/ProjectFormDialog.tsx +132 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/config/columns.tsx +121 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/config/index.ts +77 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/components/DraftProjectFormDialog.tsx +140 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/config/columns.tsx +127 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/config/index.ts +39 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/config/projects.ts +48 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/index.module.css +49 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/index.tsx +163 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/index.module.css +53 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/index.tsx +135 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/home/index.module.css +23 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/home/index.tsx +26 -43
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/login/api.ts +11 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/login/index.tsx +183 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/login/login.css +164 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/router/index.tsx +36 -12
- package/template-vp-monorepo-react-nestjs/apps/web/src/styles/index.css +11 -4
- package/template-vp-monorepo-react-nestjs/apps/web/src/utils/request/core/index.ts +55 -47
- package/template-vp-monorepo-react-nestjs/apps/web/src/utils/request/index.ts +10 -5
- package/template-vp-monorepo-react-nestjs/apps/web/src/utils/storage/cookie.ts +50 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/utils/storage/session.ts +33 -0
- package/template-vp-monorepo-react-nestjs/apps/web/test/draft-projects.spec.ts +89 -0
- package/template-vp-monorepo-react-nestjs/apps/web/test/request.spec.ts +145 -0
- package/template-vp-monorepo-react-nestjs/apps/web/test/storage.spec.ts +108 -0
- package/template-vp-monorepo-react-nestjs/apps/web/test/tsconfig.json +7 -0
- package/template-vp-monorepo-react-nestjs/apps/web/tsconfig.json +1 -1
- package/template-vp-monorepo-react-nestjs/apps/web/vite.config.ts +7 -0
- package/template-vp-monorepo-react-nestjs/apps/web-vue/tsconfig.tsbuildinfo +1 -0
- package/template-vp-monorepo-react-nestjs/docker-compose.yml +19 -0
- package/template-vp-monorepo-react-nestjs/mise.toml +1 -1
- package/template-vp-monorepo-react-nestjs/package.json +1 -6
- package/template-vp-monorepo-react-nestjs/pnpm-workspace.yaml +9 -1
- package/template-vp-react/.agents/rules/SVG/344/275/277/347/224/250.md +8 -0
- package/template-vp-react/AGENTS.md +11 -0
- package/template-vp-react/commitlint.config.js +0 -0
- package/template-vp-react/package.json +21 -26
- package/template-vp-react/pnpm-workspace.yaml +6 -0
- package/template-vp-react/src/assets/svg/draft.svg +4 -0
- package/template-vp-react/src/components/Icon/svg-icon/README.md +37 -0
- package/template-vp-react/src/components/Icon/svg-icon/SvgIcon.tsx +27 -0
- package/template-vp-react/src/utils/request/index.ts +18 -5
- package/template-vp-react/tsconfig.json +2 -3
- package/template-vp-react/vite.config.ts +2 -0
- package/template-vp-react-shadcn/.agents/rules/SVG/344/275/277/347/224/250.md +8 -0
- package/template-vp-react-shadcn/.vscode/settings.json +2 -2
- package/template-vp-react-shadcn/{agents.md → AGENTS.md} +12 -2
- package/template-vp-react-shadcn/README.md +41 -0
- package/template-vp-react-shadcn/commitlint.config.js +1 -1
- package/template-vp-react-shadcn/components.json +1 -1
- package/template-vp-react-shadcn/package.json +25 -32
- package/template-vp-react-shadcn/pnpm-workspace.yaml +10 -0
- package/template-vp-react-shadcn/src/App.tsx +10 -10
- package/template-vp-react-shadcn/src/assets/svg/draft.svg +4 -0
- package/template-vp-react-shadcn/src/components/Icon/svg-icon/README.md +37 -0
- package/template-vp-react-shadcn/src/components/Icon/svg-icon/SvgIcon.tsx +27 -0
- package/template-vp-react-shadcn/src/components/Loading/PageLoading.tsx +4 -4
- package/template-vp-react-shadcn/src/components/Player/VideoJS/index.tsx +94 -78
- package/template-vp-react-shadcn/src/components/Player/index.ts +1 -1
- package/template-vp-react-shadcn/src/components/ui/button.tsx +25 -32
- package/template-vp-react-shadcn/src/components/ui/spinner.tsx +8 -7
- package/template-vp-react-shadcn/src/components/ui/toast.tsx +229 -0
- package/template-vp-react-shadcn/src/lib/utils.ts +3 -3
- package/template-vp-react-shadcn/src/main.tsx +5 -4
- package/template-vp-react-shadcn/src/pages/home/index.tsx +43 -35
- package/template-vp-react-shadcn/src/router/index.tsx +15 -15
- package/template-vp-react-shadcn/src/styles/index.css +4 -4
- package/template-vp-react-shadcn/src/styles/shadcn.css +2 -2
- package/template-vp-react-shadcn/src/utils/env/index.tsx +2 -2
- package/template-vp-react-shadcn/src/utils/request/alova-core/index.ts +102 -30
- package/template-vp-react-shadcn/src/utils/request/alova-core/utils.ts +5 -2
- package/template-vp-react-shadcn/src/utils/request/index.ts +32 -19
- package/template-vp-react-shadcn/src/utils/request/readme.md +41 -38
- package/template-vp-react-shadcn/tsconfig.json +2 -3
- package/template-vp-react-shadcn/vite.config.ts +29 -15
- package/template-vp-vue-eslint-vapor/package.json +26 -33
- package/template-vp-vue-eslint-vapor/pnpm-workspace.yaml +29 -1
- package/template-vue-vp-eslint/README.md +3 -10
- package/template-vue-vp-eslint/package.json +27 -34
- package/template-vue-vp-eslint/pnpm-workspace.yaml +27 -2
- package/template-vue-vp-eslint/src/layout/default/index.vue +4 -3
- package/template-vp-monorepo-react-nestjs/.agents/auth.config.ts +0 -0
- package/template-vp-monorepo-react-nestjs/.spaces/server/todo.md +0 -991
- package/template-vp-monorepo-react-nestjs/apps/server/README.md +0 -3
- package/template-vp-react-shadcn/.oxfmtrc.jsonc +0 -8
- package/template-vp-react-shadcn/.oxlintrc.jsonc +0 -3
- package/template-vp-react-shadcn/src/components/ui/sonner.tsx +0 -43
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
import { AppException } from '@/common/exceptions/app.exception'
|
|
2
|
+
import { Inject, Injectable, Logger } from '@nestjs/common'
|
|
3
|
+
import { ConfigService } from '@nestjs/config'
|
|
4
|
+
import { randomUUID } from 'crypto'
|
|
5
|
+
import { Readable } from 'stream'
|
|
6
|
+
import { InitiateMultipartUploadDto } from './dto/initiate-mutipart-upload.dto'
|
|
7
|
+
import { ExactSizeTransform, findExactSizeError } from './storage/exact-size.transform'
|
|
8
|
+
import {
|
|
9
|
+
MultipartIdentity,
|
|
10
|
+
STORAGE_PORT,
|
|
11
|
+
StorageMultipartNotFoundError,
|
|
12
|
+
StoragePart,
|
|
13
|
+
type StoragePort,
|
|
14
|
+
} from './storage/storage.port'
|
|
15
|
+
import {
|
|
16
|
+
calculateExpectedPartSize,
|
|
17
|
+
calculateTotalParts,
|
|
18
|
+
UPLOAD_MAX_FILE_SIZE,
|
|
19
|
+
UPLOAD_MAX_PARTS,
|
|
20
|
+
UPLOAD_PART_SIZE,
|
|
21
|
+
} from './upload.constants'
|
|
22
|
+
import { UPLOAD_ERRORS } from './upload.errors'
|
|
23
|
+
import { UploadRepository, UploadSession } from './upload.reponsitory'
|
|
24
|
+
import { buildUploadObjectKey } from './utils/object-key'
|
|
25
|
+
|
|
26
|
+
export interface UploadedPartResponse {
|
|
27
|
+
partNumber: number
|
|
28
|
+
size: number
|
|
29
|
+
etag: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface UploadStatusResponse {
|
|
33
|
+
uploadSessionId: string
|
|
34
|
+
fileName: string
|
|
35
|
+
fileSize: number
|
|
36
|
+
contentType: string
|
|
37
|
+
partSize: number
|
|
38
|
+
totalParts: number
|
|
39
|
+
status: UploadSession['status']
|
|
40
|
+
expiresAt: string
|
|
41
|
+
uploadedParts: UploadedPartResponse[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface UploadPartCommandInput {
|
|
45
|
+
ownerId: string
|
|
46
|
+
uploadSessionId: string
|
|
47
|
+
partNumber: number
|
|
48
|
+
contentLength: string | undefined
|
|
49
|
+
contentEncoding: string | undefined
|
|
50
|
+
body: Readable
|
|
51
|
+
abortSignal: AbortSignal
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CompletedUploadResponse {
|
|
55
|
+
uploadSessionId: string
|
|
56
|
+
status: 'completed'
|
|
57
|
+
objectKey: string
|
|
58
|
+
etag: string | null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@Injectable()
|
|
62
|
+
export class UploadService {
|
|
63
|
+
private readonly logger = new Logger(UploadService.name)
|
|
64
|
+
private readonly bucket: string
|
|
65
|
+
private readonly sessionTtlMs: number
|
|
66
|
+
|
|
67
|
+
constructor(
|
|
68
|
+
private readonly uploadRepository: UploadRepository,
|
|
69
|
+
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
|
|
70
|
+
config: ConfigService,
|
|
71
|
+
) {
|
|
72
|
+
this.bucket = config.getOrThrow<string>('storage.bucket')
|
|
73
|
+
this.sessionTtlMs = config.getOrThrow<number>('storage.sessionTtlMs')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async initiate(ownerId: string, input: InitiateMultipartUploadDto) {
|
|
77
|
+
this.validateNewFile(input.fileSize)
|
|
78
|
+
|
|
79
|
+
const existing = await this.uploadRepository.findByClientUploadId(ownerId, input.clientUploadId)
|
|
80
|
+
|
|
81
|
+
if (existing) {
|
|
82
|
+
this.assertSameClientFile(existing, input)
|
|
83
|
+
return this.getStatus(ownerId, existing.id)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const totalParts = calculateTotalParts(input.fileSize)
|
|
87
|
+
|
|
88
|
+
if (totalParts > UPLOAD_MAX_PARTS) {
|
|
89
|
+
throw new AppException(UPLOAD_ERRORS.FILE_TOO_LARGE)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const uploadSessionId = randomUUID()
|
|
93
|
+
const objectKey = buildUploadObjectKey(ownerId, uploadSessionId)
|
|
94
|
+
const storageResult = await this.callStorage(() =>
|
|
95
|
+
this.storage.createMultipartUpload({
|
|
96
|
+
bucket: this.bucket,
|
|
97
|
+
objectKey,
|
|
98
|
+
contentType: input.contentType.trim(),
|
|
99
|
+
metadata: {
|
|
100
|
+
'upload-session-id': uploadSessionId,
|
|
101
|
+
'owner-id': ownerId,
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
const identity: MultipartIdentity = {
|
|
107
|
+
bucket: this.bucket,
|
|
108
|
+
objectKey,
|
|
109
|
+
storageUploadId: storageResult.storageUploadId,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const created = await this.uploadRepository.create({
|
|
114
|
+
id: uploadSessionId,
|
|
115
|
+
ownerId,
|
|
116
|
+
clientUploadId: input.clientUploadId,
|
|
117
|
+
bucket: this.bucket,
|
|
118
|
+
objectKey,
|
|
119
|
+
storageUploadId: storageResult.storageUploadId,
|
|
120
|
+
originalName: input.fileName.trim(),
|
|
121
|
+
contentType: input.contentType.trim(),
|
|
122
|
+
fileSize: input.fileSize,
|
|
123
|
+
partSize: UPLOAD_PART_SIZE,
|
|
124
|
+
totalParts,
|
|
125
|
+
status: 'uploading',
|
|
126
|
+
expiresAt: new Date(Date.now() + this.sessionTtlMs),
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
if (created) {
|
|
130
|
+
return this.toStatusResponse(created, [])
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const winner = await this.uploadRepository.findByClientUploadId(ownerId, input.clientUploadId)
|
|
134
|
+
|
|
135
|
+
await this.safeAbort(identity)
|
|
136
|
+
|
|
137
|
+
if (!winner) {
|
|
138
|
+
throw new Error('Upload session conflict winner was not found')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
this.assertSameClientFile(winner, input)
|
|
142
|
+
return this.getStatus(ownerId, winner.id)
|
|
143
|
+
} catch (cause: unknown) {
|
|
144
|
+
await this.safeAbort(identity)
|
|
145
|
+
throw cause
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private validateNewFile(fileSize: number) {
|
|
150
|
+
if (fileSize === 0) {
|
|
151
|
+
throw new AppException(UPLOAD_ERRORS.FILE_EMPTY)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (fileSize > UPLOAD_MAX_FILE_SIZE) {
|
|
155
|
+
throw new AppException(UPLOAD_ERRORS.FILE_TOO_LARGE)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private assertSameClientFile(session: UploadSession, input: InitiateMultipartUploadDto): void {
|
|
160
|
+
if (
|
|
161
|
+
session.originalName !== input.fileName.trim() ||
|
|
162
|
+
session.fileSize !== input.fileSize ||
|
|
163
|
+
session.contentType !== input.contentType.trim()
|
|
164
|
+
) {
|
|
165
|
+
throw new AppException(UPLOAD_ERRORS.CLIENT_UPLOAD_ID_CONFLICT)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async requireSession(ownerId: string, uploadSessionId: string): Promise<UploadSession> {
|
|
170
|
+
const session = await this.uploadRepository.findById(ownerId, uploadSessionId)
|
|
171
|
+
|
|
172
|
+
if (!session) {
|
|
173
|
+
throw new AppException(UPLOAD_ERRORS.SESSION_NOT_FOUND)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return session
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private async safeAbort(identity: MultipartIdentity): Promise<void> {
|
|
180
|
+
try {
|
|
181
|
+
await this.storage.abortMultipartUpload(identity)
|
|
182
|
+
} catch (cause: unknown) {
|
|
183
|
+
const message = cause instanceof Error ? cause.message : String(cause)
|
|
184
|
+
this.logger.warn(`Compensating AbortMultipartUpload failed: ${message}`)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
private toMultipartIdentity(session: UploadSession): MultipartIdentity {
|
|
188
|
+
return {
|
|
189
|
+
bucket: session.bucket,
|
|
190
|
+
objectKey: session.objectKey,
|
|
191
|
+
storageUploadId: session.storageUploadId,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private async ensureNotExpired(session: UploadSession): Promise<void> {
|
|
196
|
+
if (session.expiresAt.getTime() > Date.now()) {
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const expired = await this.uploadRepository.markExpired(session.ownerId, session.id)
|
|
201
|
+
|
|
202
|
+
if (expired) {
|
|
203
|
+
await this.safeAbort(this.toMultipartIdentity(expired))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
throw new AppException(UPLOAD_ERRORS.UPLOAD_EXPIRED)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private isAbortError(cause: unknown): boolean {
|
|
210
|
+
let current = cause
|
|
211
|
+
|
|
212
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
213
|
+
if (
|
|
214
|
+
typeof current === 'object' &&
|
|
215
|
+
current !== null &&
|
|
216
|
+
'name' in current &&
|
|
217
|
+
current.name === 'AbortError'
|
|
218
|
+
) {
|
|
219
|
+
return true
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (typeof current !== 'object' || current === null || !('cause' in current)) {
|
|
223
|
+
return false
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
current = current.cause
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return false
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private async callStorage<T>(operation: () => Promise<T>): Promise<T> {
|
|
233
|
+
try {
|
|
234
|
+
return await operation()
|
|
235
|
+
} catch (cause: unknown) {
|
|
236
|
+
if (cause instanceof AppException) {
|
|
237
|
+
throw cause
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (findExactSizeError(cause)) {
|
|
241
|
+
throw new AppException(UPLOAD_ERRORS.PART_SIZE_MISMATCH)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (cause instanceof StorageMultipartNotFoundError) {
|
|
245
|
+
throw new AppException(UPLOAD_ERRORS.STORAGE_UPLOAD_MISSING)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (this.isAbortError(cause)) {
|
|
249
|
+
throw new AppException(UPLOAD_ERRORS.PART_UPLOAD_ABORTED)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
throw new AppException(UPLOAD_ERRORS.STORAGE_UNAVAILABLE, { cause })
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private async recoverCompletedObject(session: UploadSession): Promise<UploadSession | null> {
|
|
257
|
+
const object = await this.callStorage(() =>
|
|
258
|
+
this.storage.headObject({
|
|
259
|
+
bucket: session.bucket,
|
|
260
|
+
objectKey: session.objectKey,
|
|
261
|
+
}),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if (
|
|
265
|
+
!object ||
|
|
266
|
+
object.metadata['upload-session-id'] !== session.id ||
|
|
267
|
+
object.contentLength !== session.fileSize
|
|
268
|
+
) {
|
|
269
|
+
return null
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const updated = await this.uploadRepository.markCompleted(
|
|
273
|
+
session.ownerId,
|
|
274
|
+
session.id,
|
|
275
|
+
object.etag,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
if (updated) {
|
|
279
|
+
return updated
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const latest = await this.uploadRepository.findById(session.ownerId, session.id)
|
|
283
|
+
|
|
284
|
+
return latest?.status === 'completed' ? latest : null
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private toStatusResponse(
|
|
288
|
+
session: UploadSession,
|
|
289
|
+
parts: readonly StoragePart[],
|
|
290
|
+
): UploadStatusResponse {
|
|
291
|
+
return {
|
|
292
|
+
uploadSessionId: session.id,
|
|
293
|
+
fileName: session.originalName,
|
|
294
|
+
fileSize: session.fileSize,
|
|
295
|
+
contentType: session.contentType,
|
|
296
|
+
partSize: session.partSize,
|
|
297
|
+
totalParts: session.totalParts,
|
|
298
|
+
status: session.status,
|
|
299
|
+
expiresAt: session.expiresAt.toISOString(),
|
|
300
|
+
uploadedParts: parts.map((part) => ({
|
|
301
|
+
partNumber: part.partNumber,
|
|
302
|
+
size: part.size,
|
|
303
|
+
etag: part.etag,
|
|
304
|
+
})),
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async getStatus(ownerId: string, uploadSessionId: string): Promise<UploadStatusResponse> {
|
|
309
|
+
let session = await this.requireSession(ownerId, uploadSessionId)
|
|
310
|
+
|
|
311
|
+
if (session.status === 'expired') {
|
|
312
|
+
throw new AppException(UPLOAD_ERRORS.UPLOAD_EXPIRED)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (session.status === 'uploading') {
|
|
316
|
+
await this.ensureNotExpired(session)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (session.status === 'completing') {
|
|
320
|
+
const recovered = await this.recoverCompletedObject(session)
|
|
321
|
+
|
|
322
|
+
if (recovered) {
|
|
323
|
+
session = recovered
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (session.status !== 'uploading' && session.status !== 'completing') {
|
|
328
|
+
return this.toStatusResponse(session, [])
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const parts = await this.callStorage(() =>
|
|
332
|
+
this.storage.listParts(this.toMultipartIdentity(session)),
|
|
333
|
+
)
|
|
334
|
+
return this.toStatusResponse(session, parts)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async initate(ownerId: string, input: InitiateMultipartUploadDto): Promise<UploadStatusResponse> {
|
|
338
|
+
this.validateNewFile(input.fileSize)
|
|
339
|
+
|
|
340
|
+
const existing = await this.uploadRepository.findByClientUploadId(ownerId, input.clientUploadId)
|
|
341
|
+
|
|
342
|
+
if (existing) {
|
|
343
|
+
this.assertSameClientFile(existing, input)
|
|
344
|
+
return this.getStatus(ownerId, existing.id)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const totalParts = calculateTotalParts(input.fileSize)
|
|
348
|
+
|
|
349
|
+
if (totalParts > UPLOAD_MAX_PARTS) {
|
|
350
|
+
throw new AppException(UPLOAD_ERRORS.FILE_TOO_LARGE)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const uploadSessionId = randomUUID()
|
|
354
|
+
const objectKey = buildUploadObjectKey(ownerId, uploadSessionId)
|
|
355
|
+
|
|
356
|
+
const storageResult = await this.callStorage(() =>
|
|
357
|
+
this.storage.createMultipartUpload({
|
|
358
|
+
bucket: this.bucket,
|
|
359
|
+
objectKey,
|
|
360
|
+
contentType: input.contentType.trim(),
|
|
361
|
+
metadata: {
|
|
362
|
+
'upload-session-id': uploadSessionId,
|
|
363
|
+
'owner-id': ownerId,
|
|
364
|
+
},
|
|
365
|
+
}),
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
const identity: MultipartIdentity = {
|
|
369
|
+
bucket: this.bucket,
|
|
370
|
+
objectKey,
|
|
371
|
+
storageUploadId: storageResult.storageUploadId,
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
const created = await this.uploadRepository.create({
|
|
376
|
+
id: uploadSessionId,
|
|
377
|
+
ownerId,
|
|
378
|
+
clientUploadId: input.clientUploadId,
|
|
379
|
+
bucket: this.bucket,
|
|
380
|
+
objectKey,
|
|
381
|
+
storageUploadId: storageResult.storageUploadId,
|
|
382
|
+
originalName: input.fileName.trim(),
|
|
383
|
+
contentType: input.contentType.trim(),
|
|
384
|
+
fileSize: input.fileSize,
|
|
385
|
+
partSize: UPLOAD_PART_SIZE,
|
|
386
|
+
totalParts,
|
|
387
|
+
status: 'uploading',
|
|
388
|
+
expiresAt: new Date(Date.now() + this.sessionTtlMs),
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
if (created) {
|
|
392
|
+
return this.toStatusResponse(created, [])
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const winner = await this.uploadRepository.findByClientUploadId(ownerId, input.clientUploadId)
|
|
396
|
+
|
|
397
|
+
await this.safeAbort(identity)
|
|
398
|
+
|
|
399
|
+
if (!winner) {
|
|
400
|
+
throw new Error('Upload session conflict winner was not found')
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
this.assertSameClientFile(winner, input)
|
|
404
|
+
return this.getStatus(ownerId, winner.id)
|
|
405
|
+
} catch (cause: unknown) {
|
|
406
|
+
await this.safeAbort(identity)
|
|
407
|
+
throw cause
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private parseContentLength(value: string | undefined): number {
|
|
412
|
+
if (!value) {
|
|
413
|
+
throw new AppException(UPLOAD_ERRORS.PART_LENGTH_REQUIRED)
|
|
414
|
+
}
|
|
415
|
+
const parsed = Number(value)
|
|
416
|
+
|
|
417
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
|
418
|
+
throw new AppException(UPLOAD_ERRORS.PART_LENGTH_REQUIRED)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return parsed
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async uploadPart(input: UploadPartCommandInput) {
|
|
425
|
+
const session = await this.requireSession(input.ownerId, input.uploadSessionId)
|
|
426
|
+
|
|
427
|
+
if (session.status !== 'uploading') {
|
|
428
|
+
throw new AppException(UPLOAD_ERRORS.INVALID_STATE)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
await this.ensureNotExpired(session)
|
|
432
|
+
|
|
433
|
+
if (input.partNumber < 1 || input.partNumber > session.totalParts) {
|
|
434
|
+
throw new AppException(UPLOAD_ERRORS.PART_NUMBER_INVALID)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (input.contentEncoding && input.contentEncoding.toLowerCase() !== 'identity') {
|
|
438
|
+
throw new AppException(UPLOAD_ERRORS.UNSUPPORTED_CONTENT_ENCODING)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const expectedSize = calculateExpectedPartSize(
|
|
442
|
+
session.fileSize,
|
|
443
|
+
session.totalParts,
|
|
444
|
+
input.partNumber,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
const declaredSize = this.parseContentLength(input.contentLength)
|
|
448
|
+
|
|
449
|
+
if (declaredSize !== expectedSize) {
|
|
450
|
+
throw new AppException(UPLOAD_ERRORS.PART_SIZE_MISMATCH)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const exactSizeStream = new ExactSizeTransform(expectedSize)
|
|
454
|
+
const onSourceError = (cause: Error) => exactSizeStream.destroy(cause)
|
|
455
|
+
input.body.once('error', onSourceError)
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
const guardedBody = input.body.pipe(exactSizeStream)
|
|
459
|
+
const result = await this.callStorage(() =>
|
|
460
|
+
this.storage.uploadPart({
|
|
461
|
+
...this.toMultipartIdentity(session),
|
|
462
|
+
partNumber: input.partNumber,
|
|
463
|
+
body: guardedBody,
|
|
464
|
+
contentLength: expectedSize,
|
|
465
|
+
abortSignal: input.abortSignal,
|
|
466
|
+
}),
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
partNumber: input.partNumber,
|
|
471
|
+
size: expectedSize,
|
|
472
|
+
etag: result.etag,
|
|
473
|
+
}
|
|
474
|
+
} catch (cause: unknown) {
|
|
475
|
+
input.body.unpipe(exactSizeStream)
|
|
476
|
+
exactSizeStream.destroy()
|
|
477
|
+
|
|
478
|
+
if (!input.body.destroyed) {
|
|
479
|
+
input.body.resume()
|
|
480
|
+
}
|
|
481
|
+
throw cause
|
|
482
|
+
} finally {
|
|
483
|
+
input.body.unpipe(exactSizeStream)
|
|
484
|
+
input.body.off('error', onSourceError)
|
|
485
|
+
|
|
486
|
+
if (!exactSizeStream.destroyed) {
|
|
487
|
+
exactSizeStream.destroy()
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private toCompletedResponse(session: UploadSession): CompletedUploadResponse {
|
|
493
|
+
return {
|
|
494
|
+
uploadSessionId: session.id,
|
|
495
|
+
status: 'completed',
|
|
496
|
+
objectKey: session.objectKey,
|
|
497
|
+
etag: session.objectEtag,
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
private assertCompleteParts(session: UploadSession, parts: readonly StoragePart[]): void {
|
|
502
|
+
if (parts.length !== session.totalParts) {
|
|
503
|
+
throw new AppException(UPLOAD_ERRORS.PARTS_INCOMPLETE)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
let totalBytes = 0
|
|
507
|
+
|
|
508
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
509
|
+
const part = parts[index]
|
|
510
|
+
const expectedPartNumber = index + 1
|
|
511
|
+
|
|
512
|
+
if (!part || part.partNumber !== expectedPartNumber || !part.etag) {
|
|
513
|
+
throw new AppException(UPLOAD_ERRORS.PARTS_INCOMPLETE)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const expectedSize = calculateExpectedPartSize(
|
|
517
|
+
session.fileSize,
|
|
518
|
+
session.totalParts,
|
|
519
|
+
expectedPartNumber,
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
if (part.size !== expectedSize) {
|
|
523
|
+
throw new AppException(UPLOAD_ERRORS.PARTS_INCOMPLETE)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
totalBytes += part.size
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (totalBytes !== session.fileSize) {
|
|
530
|
+
throw new AppException(UPLOAD_ERRORS.PARTS_INCOMPLETE)
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async complete(ownerId: string, uploadSessionId: string): Promise<CompletedUploadResponse> {
|
|
535
|
+
let session = await this.requireSession(ownerId, uploadSessionId)
|
|
536
|
+
|
|
537
|
+
if (session.status === 'completed') {
|
|
538
|
+
return this.toCompletedResponse(session)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (session.status === 'completing') {
|
|
542
|
+
const recovered = await this.recoverCompletedObject(session)
|
|
543
|
+
|
|
544
|
+
if (recovered) {
|
|
545
|
+
return this.toCompletedResponse(recovered)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
throw new AppException(UPLOAD_ERRORS.INVALID_STATE)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (session.status !== 'uploading') {
|
|
552
|
+
throw new AppException(UPLOAD_ERRORS.INVALID_STATE)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// 从上传中 到 completing
|
|
556
|
+
await this.ensureNotExpired(session)
|
|
557
|
+
|
|
558
|
+
const claimed = await this.uploadRepository.claimCompleting(ownerId, uploadSessionId)
|
|
559
|
+
|
|
560
|
+
if (!claimed) {
|
|
561
|
+
session = await this.requireSession(ownerId, uploadSessionId)
|
|
562
|
+
|
|
563
|
+
if (session.status === 'completed') {
|
|
564
|
+
return this.toCompletedResponse(session)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
throw new AppException(UPLOAD_ERRORS.INVALID_STATE)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
let completeWasAttempted = false
|
|
571
|
+
|
|
572
|
+
try {
|
|
573
|
+
const parts = await this.callStorage(() =>
|
|
574
|
+
this.storage.listParts(this.toMultipartIdentity(claimed)),
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
this.assertCompleteParts(claimed, parts)
|
|
578
|
+
|
|
579
|
+
completeWasAttempted = true
|
|
580
|
+
const completed = await this.callStorage(() =>
|
|
581
|
+
this.storage.completeMultipartUpload({
|
|
582
|
+
...this.toMultipartIdentity(claimed),
|
|
583
|
+
parts,
|
|
584
|
+
}),
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
const updated = await this.uploadRepository.markCompleted(
|
|
588
|
+
ownerId,
|
|
589
|
+
uploadSessionId,
|
|
590
|
+
completed.etag,
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
if (!updated) {
|
|
594
|
+
const recovered = await this.recoverCompletedObject(claimed)
|
|
595
|
+
|
|
596
|
+
if (recovered) {
|
|
597
|
+
return this.toCompletedResponse(recovered)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
throw new Error('Completed object could not be persisted')
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return this.toCompletedResponse(updated)
|
|
604
|
+
} catch (cause: unknown) {
|
|
605
|
+
if (!completeWasAttempted) {
|
|
606
|
+
if (
|
|
607
|
+
cause instanceof AppException &&
|
|
608
|
+
cause.definition.code === UPLOAD_ERRORS.STORAGE_UPLOAD_MISSING.code
|
|
609
|
+
) {
|
|
610
|
+
// UploadId 已经不存在,不能确定是外部清理还是曾经完成过。
|
|
611
|
+
// 先尝试 HeadObject;仍无法恢复时不重新开放上传,交给后台清理流程判定。
|
|
612
|
+
try {
|
|
613
|
+
const recovered = await this.recoverCompletedObject(claimed)
|
|
614
|
+
|
|
615
|
+
if (recovered) {
|
|
616
|
+
return this.toCompletedResponse(recovered)
|
|
617
|
+
}
|
|
618
|
+
} catch {
|
|
619
|
+
// 保留原始 STORAGE_UPLOAD_MISSING 错误,并继续保持 completing。
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
throw cause
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ListParts 失败或分片不完整时还没有发送 Complete,
|
|
626
|
+
// 因此可以安全恢复为 uploading,让调用方修正后重试。
|
|
627
|
+
await this.uploadRepository.resetCompleting(ownerId, uploadSessionId)
|
|
628
|
+
throw cause
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
try {
|
|
632
|
+
const recovered = await this.recoverCompletedObject(claimed)
|
|
633
|
+
|
|
634
|
+
if (recovered) {
|
|
635
|
+
return this.toCompletedResponse(recovered)
|
|
636
|
+
}
|
|
637
|
+
} catch {
|
|
638
|
+
// 无法确认 MinIO 是否已完成时保持 completing,
|
|
639
|
+
// 避免错误地重新开放分片上传。
|
|
640
|
+
throw cause
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Complete 已经发出后,即使 HeadObject 暂时还看不到对象,也不能证明
|
|
644
|
+
// MinIO 没有完成。保持 completing,由 GET 状态恢复或后台任务继续判定。
|
|
645
|
+
throw cause
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async abort(ownerId: string, uploadSessionId: string) {
|
|
650
|
+
let session = await this.requireSession(ownerId, uploadSessionId)
|
|
651
|
+
if (session.status === 'aborted') {
|
|
652
|
+
return {
|
|
653
|
+
uploadSessionId,
|
|
654
|
+
status: 'aborted' as const,
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (session.status === 'completed' || session.status === 'completing') {
|
|
659
|
+
throw new AppException(UPLOAD_ERRORS.INVALID_STATE)
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const claimed = await this.uploadRepository.claimAborting(ownerId, uploadSessionId)
|
|
663
|
+
|
|
664
|
+
if (!claimed) {
|
|
665
|
+
session = await this.requireSession(ownerId, uploadSessionId)
|
|
666
|
+
|
|
667
|
+
if (session.status === 'aborted') {
|
|
668
|
+
return {
|
|
669
|
+
uploadSessionId,
|
|
670
|
+
status: 'aborted' as const,
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
throw new AppException(UPLOAD_ERRORS.INVALID_STATE)
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
await this.callStorage(() =>
|
|
678
|
+
this.storage.abortMultipartUpload(this.toMultipartIdentity(claimed)),
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
const aborted = await this.uploadRepository.markAborted(ownerId, uploadSessionId)
|
|
682
|
+
|
|
683
|
+
if (!aborted) {
|
|
684
|
+
const latest = await this.uploadRepository.findById(ownerId, uploadSessionId)
|
|
685
|
+
|
|
686
|
+
if (latest?.status === 'aborted') {
|
|
687
|
+
return {
|
|
688
|
+
uploadSessionId,
|
|
689
|
+
status: 'aborted' as const,
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
throw new Error('Aborted upload could not be persisted')
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function buildUploadObjectKey(
|
|
2
|
+
ownerId: string,
|
|
3
|
+
uploadSessionId: string,
|
|
4
|
+
now = new Date(),
|
|
5
|
+
): string {
|
|
6
|
+
const year = String(now.getUTCFullYear())
|
|
7
|
+
const month = String(now.getUTCMonth() + 1).padStart(2, '0')
|
|
8
|
+
|
|
9
|
+
return `users/${ownerId}/${year}/${month}/${uploadSessionId}`
|
|
10
|
+
}
|