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.
Files changed (203) hide show
  1. package/dist/index.js +3 -9
  2. package/package.json +1 -1
  3. package/template-nextjs-vinext-eslint/Dockerfile +4 -4
  4. package/template-nextjs-vinext-eslint/README.md +6 -2
  5. package/template-nextjs-vinext-eslint/docker-compose.yaml +4 -4
  6. package/template-nextjs-vinext-eslint/eslint.config.js +2 -0
  7. package/template-nextjs-vinext-eslint/package.json +28 -26
  8. package/template-nextjs-vinext-eslint/pnpm-workspace.yaml +13 -1
  9. package/template-nextjs-vinext-eslint/src/utils/request/core/index.ts +63 -45
  10. package/template-nextjs-vinext-eslint/src/utils/request/core/utils.ts +12 -6
  11. package/template-nextjs-vinext-eslint/vite.config.ts +1 -1
  12. package/template-react-rsbuild-biome/.agents/rules/SVG/344/275/277/347/224/250.md +8 -0
  13. package/template-react-rsbuild-biome/AGENTS.md +11 -0
  14. package/template-react-rsbuild-biome/index.html +2 -2
  15. package/template-react-rsbuild-biome/package.json +25 -24
  16. package/template-react-rsbuild-biome/pnpm-workspace.yaml +5 -0
  17. package/template-react-rsbuild-biome/rsbuild.config.ts +1 -1
  18. package/template-react-rsbuild-biome/src/assets/icon/logo.svg +1 -1
  19. package/template-react-rsbuild-biome/src/assets/svg/draft.svg +4 -0
  20. package/template-react-rsbuild-biome/src/components/Icon/svg-icon/README.md +37 -0
  21. package/template-react-rsbuild-biome/src/components/Icon/svg-icon/SvgIcon.tsx +27 -0
  22. package/template-react-rsbuild-biome/src/env.d.ts +7 -0
  23. package/template-react-rsbuild-biome/src/utils/request/index.ts +3 -4
  24. package/template-react-rsbuild-biome/tsconfig.json +1 -2
  25. package/template-taro-react-oxc/.oxlintrc.json +0 -1
  26. package/template-taro-react-oxc/package.json +53 -52
  27. package/template-taro-react-oxc/pnpm-workspace.yaml +46 -0
  28. package/template-taro-vue-eslint/package.json +56 -56
  29. package/template-vp-monorepo-react-hono/apps/web/.agents/rules/SVG/344/275/277/347/224/250.md +8 -0
  30. package/template-vp-monorepo-react-hono/apps/web/AGENTS.md +11 -0
  31. package/template-vp-monorepo-react-hono/apps/web/package.json +1 -0
  32. package/template-vp-monorepo-react-hono/apps/web/src/assets/svg/draft.svg +4 -0
  33. package/template-vp-monorepo-react-hono/apps/web/src/components/Icon/svg-icon/README.md +37 -0
  34. package/template-vp-monorepo-react-hono/apps/web/src/components/Icon/svg-icon/SvgIcon.tsx +27 -0
  35. package/template-vp-monorepo-react-hono/apps/web/tsconfig.json +1 -1
  36. package/template-vp-monorepo-react-hono/apps/web/vite.config.ts +2 -0
  37. package/template-vp-monorepo-react-hono/pnpm-workspace.yaml +1 -0
  38. package/template-vp-monorepo-react-nestjs/.agents/rules//344/273/243/347/240/201/350/256/276/350/256/241.md +4 -0
  39. package/template-vp-monorepo-react-nestjs/.agents/rules//345/205/261/344/272/253/344/273/243/347/240/201.md +4 -0
  40. 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
  41. package/template-vp-monorepo-react-nestjs/.spaces/server/04.upload.md +4504 -0
  42. 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
  43. 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
  44. 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
  45. package/template-vp-monorepo-react-nestjs/AGENTS.md +8 -12
  46. package/template-vp-monorepo-react-nestjs/apps/server/.agents/rules/error-handling.md +84 -0
  47. package/template-vp-monorepo-react-nestjs/apps/server/.env.development +60 -0
  48. package/template-vp-monorepo-react-nestjs/apps/server/.env.production +16 -0
  49. package/template-vp-monorepo-react-nestjs/apps/server/AGENTS.md +9 -0
  50. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/0000_military_colonel_america.sql +11 -0
  51. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/0001_loud_siren.sql +27 -0
  52. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/meta/0000_snapshot.json +97 -0
  53. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/meta/0001_snapshot.json +354 -0
  54. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/meta/_journal.json +20 -0
  55. package/template-vp-monorepo-react-nestjs/apps/server/package.json +4 -0
  56. package/template-vp-monorepo-react-nestjs/apps/server/src/app.module.ts +14 -2
  57. package/template-vp-monorepo-react-nestjs/apps/server/src/common/adapters/fastify.adapter.ts +6 -0
  58. package/template-vp-monorepo-react-nestjs/apps/server/src/config/index.ts +2 -0
  59. package/template-vp-monorepo-react-nestjs/apps/server/src/config/queue.config.ts +105 -0
  60. package/template-vp-monorepo-react-nestjs/apps/server/src/config/storage.config.ts +33 -0
  61. package/template-vp-monorepo-react-nestjs/apps/server/src/database/schema.ts +57 -1
  62. package/template-vp-monorepo-react-nestjs/apps/server/src/main.ts +1 -0
  63. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/auth.service.ts +1 -3
  64. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/dto/auth-repsponse.dto.ts +2 -2
  65. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/queue-worker.bootstrap.ts +24 -0
  66. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/task-queue.constants.ts +24 -0
  67. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/task-queue.contracts.ts +35 -0
  68. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/task-queue.module.ts +56 -0
  69. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/task-queue.processor.ts +62 -0
  70. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/task-queue.service.ts +58 -0
  71. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/test/error-catelog.spec.ts +34 -0
  72. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/dto/initiate-mutipart-upload.dto.ts +26 -0
  73. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/dto/upload-params.dto.ts +15 -0
  74. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/storage/exact-size.transform.ts +66 -0
  75. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/storage/minio-storage.adapter.ts +239 -0
  76. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/storage/storage.port.ts +61 -0
  77. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.constants.ts +23 -0
  78. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.controller.ts +109 -0
  79. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.errors.ts +80 -0
  80. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.module.ts +20 -0
  81. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.reponsitory.ts +166 -0
  82. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.service.ts +696 -0
  83. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/utils/object-key.ts +10 -0
  84. package/template-vp-monorepo-react-nestjs/apps/server/test/task-queue.integration.spec.ts +120 -0
  85. package/template-vp-monorepo-react-nestjs/apps/server/test/task-queue.processor.spec.ts +63 -0
  86. package/template-vp-monorepo-react-nestjs/apps/server/test/task-queue.service.spec.ts +106 -0
  87. package/template-vp-monorepo-react-nestjs/apps/web/.agents/rules/SVG/344/275/277/347/224/250.md +8 -0
  88. 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
  89. 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
  90. 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
  91. 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
  92. 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
  93. package/template-vp-monorepo-react-nestjs/apps/web/.env.dev +1 -1
  94. package/template-vp-monorepo-react-nestjs/apps/web/AGENTS.md +16 -0
  95. package/template-vp-monorepo-react-nestjs/apps/web/README.MD +153 -0
  96. package/template-vp-monorepo-react-nestjs/apps/web/index.html +3 -2
  97. package/template-vp-monorepo-react-nestjs/apps/web/package.json +6 -1
  98. package/template-vp-monorepo-react-nestjs/apps/web/public/favicon.svg +9 -1
  99. package/template-vp-monorepo-react-nestjs/apps/web/src/App.module.css +5 -0
  100. package/template-vp-monorepo-react-nestjs/apps/web/src/App.tsx +16 -9
  101. package/template-vp-monorepo-react-nestjs/apps/web/src/api/auth.ts +9 -0
  102. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/draft.svg +4 -0
  103. package/template-vp-monorepo-react-nestjs/apps/web/src/components/DraftProTable/DraftProTable.module.css +78 -0
  104. package/template-vp-monorepo-react-nestjs/apps/web/src/components/DraftProTable/DraftProTable.tsx +99 -0
  105. package/template-vp-monorepo-react-nestjs/apps/web/src/components/DraftProTable/README.md +34 -0
  106. package/template-vp-monorepo-react-nestjs/apps/web/src/components/FullHeightProTable/FullHeightProTable.module.css +54 -0
  107. package/template-vp-monorepo-react-nestjs/apps/web/src/components/FullHeightProTable/FullHeightProTable.tsx +41 -0
  108. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Icon/svg-icon/README.md +37 -0
  109. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Icon/svg-icon/SvgIcon.tsx +27 -0
  110. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Loading/PageLoading.module.css +7 -0
  111. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Loading/PageLoading.tsx +2 -1
  112. package/template-vp-monorepo-react-nestjs/apps/web/src/config/theme.ts +20 -0
  113. package/template-vp-monorepo-react-nestjs/apps/web/src/layouts/BasicLayout.module.css +17 -0
  114. package/template-vp-monorepo-react-nestjs/apps/web/src/layouts/BasicLayout.tsx +106 -0
  115. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/i18n/index.module.css +23 -0
  116. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/i18n/index.tsx +53 -0
  117. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/components/ProjectFormDialog.tsx +132 -0
  118. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/config/columns.tsx +121 -0
  119. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/config/index.ts +77 -0
  120. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/components/DraftProjectFormDialog.tsx +140 -0
  121. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/config/columns.tsx +127 -0
  122. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/config/index.ts +39 -0
  123. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/config/projects.ts +48 -0
  124. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/index.module.css +49 -0
  125. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/index.tsx +163 -0
  126. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/index.module.css +53 -0
  127. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/index.tsx +135 -0
  128. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/home/index.module.css +23 -0
  129. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/home/index.tsx +26 -43
  130. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/login/api.ts +11 -0
  131. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/login/index.tsx +183 -0
  132. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/login/login.css +164 -0
  133. package/template-vp-monorepo-react-nestjs/apps/web/src/router/index.tsx +36 -12
  134. package/template-vp-monorepo-react-nestjs/apps/web/src/styles/index.css +11 -4
  135. package/template-vp-monorepo-react-nestjs/apps/web/src/utils/request/core/index.ts +55 -47
  136. package/template-vp-monorepo-react-nestjs/apps/web/src/utils/request/index.ts +10 -5
  137. package/template-vp-monorepo-react-nestjs/apps/web/src/utils/storage/cookie.ts +50 -0
  138. package/template-vp-monorepo-react-nestjs/apps/web/src/utils/storage/session.ts +33 -0
  139. package/template-vp-monorepo-react-nestjs/apps/web/test/draft-projects.spec.ts +89 -0
  140. package/template-vp-monorepo-react-nestjs/apps/web/test/request.spec.ts +145 -0
  141. package/template-vp-monorepo-react-nestjs/apps/web/test/storage.spec.ts +108 -0
  142. package/template-vp-monorepo-react-nestjs/apps/web/test/tsconfig.json +7 -0
  143. package/template-vp-monorepo-react-nestjs/apps/web/tsconfig.json +1 -1
  144. package/template-vp-monorepo-react-nestjs/apps/web/vite.config.ts +7 -0
  145. package/template-vp-monorepo-react-nestjs/apps/web-vue/tsconfig.tsbuildinfo +1 -0
  146. package/template-vp-monorepo-react-nestjs/docker-compose.yml +19 -0
  147. package/template-vp-monorepo-react-nestjs/mise.toml +1 -1
  148. package/template-vp-monorepo-react-nestjs/package.json +1 -6
  149. package/template-vp-monorepo-react-nestjs/pnpm-workspace.yaml +9 -1
  150. package/template-vp-react/.agents/rules/SVG/344/275/277/347/224/250.md +8 -0
  151. package/template-vp-react/AGENTS.md +11 -0
  152. package/template-vp-react/commitlint.config.js +0 -0
  153. package/template-vp-react/package.json +21 -26
  154. package/template-vp-react/pnpm-workspace.yaml +6 -0
  155. package/template-vp-react/src/assets/svg/draft.svg +4 -0
  156. package/template-vp-react/src/components/Icon/svg-icon/README.md +37 -0
  157. package/template-vp-react/src/components/Icon/svg-icon/SvgIcon.tsx +27 -0
  158. package/template-vp-react/src/utils/request/index.ts +18 -5
  159. package/template-vp-react/tsconfig.json +2 -3
  160. package/template-vp-react/vite.config.ts +2 -0
  161. package/template-vp-react-shadcn/.agents/rules/SVG/344/275/277/347/224/250.md +8 -0
  162. package/template-vp-react-shadcn/.vscode/settings.json +2 -2
  163. package/template-vp-react-shadcn/{agents.md → AGENTS.md} +12 -2
  164. package/template-vp-react-shadcn/README.md +41 -0
  165. package/template-vp-react-shadcn/commitlint.config.js +1 -1
  166. package/template-vp-react-shadcn/components.json +1 -1
  167. package/template-vp-react-shadcn/package.json +25 -32
  168. package/template-vp-react-shadcn/pnpm-workspace.yaml +10 -0
  169. package/template-vp-react-shadcn/src/App.tsx +10 -10
  170. package/template-vp-react-shadcn/src/assets/svg/draft.svg +4 -0
  171. package/template-vp-react-shadcn/src/components/Icon/svg-icon/README.md +37 -0
  172. package/template-vp-react-shadcn/src/components/Icon/svg-icon/SvgIcon.tsx +27 -0
  173. package/template-vp-react-shadcn/src/components/Loading/PageLoading.tsx +4 -4
  174. package/template-vp-react-shadcn/src/components/Player/VideoJS/index.tsx +94 -78
  175. package/template-vp-react-shadcn/src/components/Player/index.ts +1 -1
  176. package/template-vp-react-shadcn/src/components/ui/button.tsx +25 -32
  177. package/template-vp-react-shadcn/src/components/ui/spinner.tsx +8 -7
  178. package/template-vp-react-shadcn/src/components/ui/toast.tsx +229 -0
  179. package/template-vp-react-shadcn/src/lib/utils.ts +3 -3
  180. package/template-vp-react-shadcn/src/main.tsx +5 -4
  181. package/template-vp-react-shadcn/src/pages/home/index.tsx +43 -35
  182. package/template-vp-react-shadcn/src/router/index.tsx +15 -15
  183. package/template-vp-react-shadcn/src/styles/index.css +4 -4
  184. package/template-vp-react-shadcn/src/styles/shadcn.css +2 -2
  185. package/template-vp-react-shadcn/src/utils/env/index.tsx +2 -2
  186. package/template-vp-react-shadcn/src/utils/request/alova-core/index.ts +102 -30
  187. package/template-vp-react-shadcn/src/utils/request/alova-core/utils.ts +5 -2
  188. package/template-vp-react-shadcn/src/utils/request/index.ts +32 -19
  189. package/template-vp-react-shadcn/src/utils/request/readme.md +41 -38
  190. package/template-vp-react-shadcn/tsconfig.json +2 -3
  191. package/template-vp-react-shadcn/vite.config.ts +29 -15
  192. package/template-vp-vue-eslint-vapor/package.json +26 -33
  193. package/template-vp-vue-eslint-vapor/pnpm-workspace.yaml +29 -1
  194. package/template-vue-vp-eslint/README.md +3 -10
  195. package/template-vue-vp-eslint/package.json +27 -34
  196. package/template-vue-vp-eslint/pnpm-workspace.yaml +27 -2
  197. package/template-vue-vp-eslint/src/layout/default/index.vue +4 -3
  198. package/template-vp-monorepo-react-nestjs/.agents/auth.config.ts +0 -0
  199. package/template-vp-monorepo-react-nestjs/.spaces/server/todo.md +0 -991
  200. package/template-vp-monorepo-react-nestjs/apps/server/README.md +0 -3
  201. package/template-vp-react-shadcn/.oxfmtrc.jsonc +0 -8
  202. package/template-vp-react-shadcn/.oxlintrc.jsonc +0 -3
  203. 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
+ }