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
@@ -2,7 +2,7 @@ import { createZodDto } from 'nestjs-zod'
2
2
  import z from 'zod'
3
3
 
4
4
  const authUserSchema = z.object({
5
- id: z.string().uuid(),
5
+ id: z.uuid(),
6
6
  account: z.string(),
7
7
  name: z.string(),
8
8
  })
@@ -14,7 +14,7 @@ export class LoginResultDto extends createZodDto(
14
14
  accessToken: z.string().min(1),
15
15
  tokenType: z.literal('Bearer'),
16
16
  idleExpiresIn: z.number().int().positive(),
17
- absoluteExpiresAt: z.string().datetime(),
17
+ absoluteExpiresAt: z.iso.datetime(),
18
18
  }),
19
19
  ) {}
20
20
 
@@ -0,0 +1,24 @@
1
+ import { BullRegistrar } from '@nestjs/bullmq'
2
+ import { Injectable, type OnApplicationBootstrap } from '@nestjs/common'
3
+ import { ConfigService } from '@nestjs/config'
4
+
5
+ @Injectable()
6
+ export class QueueWorkerBootstrap implements OnApplicationBootstrap {
7
+ private registered = false
8
+
9
+ constructor(
10
+ private readonly config: ConfigService,
11
+ private readonly registrar: BullRegistrar,
12
+ ) {}
13
+
14
+ onApplicationBootstrap() {
15
+ const workerEnabled = this.config.getOrThrow<boolean>('queue.workerEnabled')
16
+
17
+ if (!workerEnabled || this.registered) {
18
+ return
19
+ }
20
+
21
+ this.registrar.register()
22
+ this.registered = true
23
+ }
24
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * BullMQ 队列名称。
3
+ *
4
+ * 在 BullModule.registerQueue 注册、@InjectQueue 注入与 @Processor 绑定时共用,
5
+ * 同时作为契约层的队列标识(见 EnqueuedTask.queueName)。
6
+ */
7
+ export const TASK_QUEUE_NAME = 'server-tasks'
8
+
9
+ /**
10
+ * Worker 并发数:单个 Processor 实例同时处理的任务数上限。
11
+ */
12
+ export const TASK_QUEUE_CONCURRENCY = 5
13
+
14
+ /**
15
+ * 任务名称注册表(全部任务的唯一来源)。
16
+ *
17
+ * 命名遵循 `域.动作.版本` 格式(如 `system.queue-smoke.v1`)。
18
+ * 新增任务时在此登记,并需在 contracts 中同步补充对应的 payload 契约
19
+ * (TaskPayloadMap 与 TASK_PAYLOAD_SCHEMAS)。
20
+ */
21
+ export const TASK_JOB_NAMES = {
22
+ /** 队列连通性冒烟测试任务。 */
23
+ QUEUE_SMOKE: 'system.queue-smoke.v1',
24
+ } as const
@@ -0,0 +1,35 @@
1
+ import { z } from 'zod'
2
+ import { TASK_JOB_NAMES, TASK_QUEUE_NAME } from './task-queue.constants'
3
+
4
+ export const QueueSmokePayloadSchema = z.object({
5
+ marker: z.string().min(1).max(100),
6
+ enqueuedAt: z.iso.datetime(),
7
+ })
8
+
9
+ export type TaskName = (typeof TASK_JOB_NAMES)[keyof typeof TASK_JOB_NAMES]
10
+
11
+ export interface TaskPayloadMap {
12
+ [TASK_JOB_NAMES.QUEUE_SMOKE]: z.infer<typeof QueueSmokePayloadSchema>
13
+ }
14
+
15
+ export const TASK_PAYLOAD_SCHEMAS = {
16
+ [TASK_JOB_NAMES.QUEUE_SMOKE]: QueueSmokePayloadSchema,
17
+ } satisfies Record<TaskName, z.ZodType>
18
+
19
+ export function parseTaskPayload<Name extends TaskName>(
20
+ name: Name,
21
+ payload: unknown,
22
+ ): TaskPayloadMap[Name] {
23
+ return TASK_PAYLOAD_SCHEMAS[name].parse(payload) as TaskPayloadMap[Name]
24
+ }
25
+
26
+ export interface EnqueueTaskOptions {
27
+ jobId?: string
28
+ delayMs?: number
29
+ }
30
+
31
+ export interface EnqueuedTask<Name extends TaskName = TaskName> {
32
+ queueName: typeof TASK_QUEUE_NAME
33
+ jobId: string
34
+ name: Name
35
+ }
@@ -0,0 +1,56 @@
1
+ import { BullModule } from '@nestjs/bullmq'
2
+ import { Module } from '@nestjs/common'
3
+ import { ConfigModule, ConfigService } from '@nestjs/config'
4
+ import type { QueueConfig } from '@/config/queue.config'
5
+ import { QueueWorkerBootstrap } from './queue-worker.bootstrap'
6
+ import { TASK_QUEUE_NAME } from './task-queue.constants'
7
+ import { TaskQueueProcessor } from './task-queue.processor'
8
+ import { TaskQueueService } from './task-queue.service'
9
+
10
+ @Module({
11
+ imports: [
12
+ BullModule.forRootAsync({
13
+ imports: [ConfigModule],
14
+ inject: [ConfigService],
15
+ useFactory: (config: ConfigService) => {
16
+ const queue = config.getOrThrow<QueueConfig>('queue')
17
+
18
+ return {
19
+ connection: {
20
+ host: queue.host,
21
+ port: queue.port,
22
+ username: queue.username,
23
+ password: queue.password,
24
+ db: queue.db,
25
+ connectTimeout: 10_000,
26
+ },
27
+ prefix: queue.prefix,
28
+ defaultJobOptions: {
29
+ attempts: queue.defaultAttempts,
30
+ backoff: {
31
+ type: 'exponential',
32
+ delay: queue.backoffDelayMs,
33
+ },
34
+ removeOnComplete: {
35
+ age: queue.completedAgeSeconds,
36
+ count: queue.completedCount,
37
+ },
38
+ removeOnFail: {
39
+ age: queue.failedAgeSeconds,
40
+ count: queue.failedCount,
41
+ },
42
+ },
43
+ }
44
+ },
45
+ extraOptions: {
46
+ manualRegistration: true,
47
+ },
48
+ }),
49
+ BullModule.registerQueue({
50
+ name: TASK_QUEUE_NAME,
51
+ }),
52
+ ],
53
+ providers: [QueueWorkerBootstrap, TaskQueueProcessor, TaskQueueService],
54
+ exports: [TaskQueueService],
55
+ })
56
+ export class TaskQueueModule {}
@@ -0,0 +1,62 @@
1
+ import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq'
2
+ import { Logger } from '@nestjs/common'
3
+ import { type Job, UnrecoverableError } from 'bullmq'
4
+ import { TASK_JOB_NAMES, TASK_QUEUE_CONCURRENCY, TASK_QUEUE_NAME } from './task-queue.constants'
5
+ import { QueueSmokePayloadSchema } from './task-queue.contracts'
6
+
7
+ @Processor(TASK_QUEUE_NAME, { concurrency: TASK_QUEUE_CONCURRENCY })
8
+ export class TaskQueueProcessor extends WorkerHost {
9
+ private readonly logger = new Logger(TaskQueueProcessor.name)
10
+
11
+ async process(job: Job): Promise<unknown> {
12
+ switch (job.name) {
13
+ case TASK_JOB_NAMES.QUEUE_SMOKE: {
14
+ const parsed = QueueSmokePayloadSchema.safeParse(job.data)
15
+
16
+ if (!parsed.success) {
17
+ throw new UnrecoverableError('Invalid payload for ' + job.name)
18
+ }
19
+
20
+ return {
21
+ marker: parsed.data.marker,
22
+ processedAt: new Date().toISOString(),
23
+ }
24
+ }
25
+
26
+ default:
27
+ throw new UnrecoverableError('Unsupported task name: ' + job.name)
28
+ }
29
+ }
30
+
31
+ @OnWorkerEvent('completed')
32
+ onCompleted(job: Job) {
33
+ this.logger.log({
34
+ event: 'queue_job_completed',
35
+ queueName: TASK_QUEUE_NAME,
36
+ jobId: job.id,
37
+ jobName: job.name,
38
+ attemptsMade: job.attemptsMade,
39
+ })
40
+ }
41
+
42
+ @OnWorkerEvent('failed')
43
+ onFailed(job: Job | undefined, error: Error) {
44
+ this.logger.error({
45
+ event: 'queue_job_failed',
46
+ queueName: TASK_QUEUE_NAME,
47
+ jobId: job?.id,
48
+ jobName: job?.name,
49
+ attemptsMade: job?.attemptsMade,
50
+ errorName: error.name,
51
+ })
52
+ }
53
+
54
+ @OnWorkerEvent('error')
55
+ onWorkerError(error: Error) {
56
+ this.logger.error({
57
+ event: 'queue_worker_error',
58
+ queueName: TASK_QUEUE_NAME,
59
+ errorName: error.name,
60
+ })
61
+ }
62
+ }
@@ -0,0 +1,58 @@
1
+ import { InjectQueue } from '@nestjs/bullmq'
2
+ import { Injectable } from '@nestjs/common'
3
+ import type { JobsOptions, Queue } from 'bullmq'
4
+ import { TASK_QUEUE_NAME } from './task-queue.constants'
5
+ import {
6
+ type EnqueuedTask,
7
+ type EnqueueTaskOptions,
8
+ parseTaskPayload,
9
+ type TaskName,
10
+ type TaskPayloadMap,
11
+ } from './task-queue.contracts'
12
+
13
+ @Injectable()
14
+ export class TaskQueueService {
15
+ constructor(
16
+ @InjectQueue(TASK_QUEUE_NAME)
17
+ private readonly queue: Queue,
18
+ ) {}
19
+
20
+ async enqueue<Name extends TaskName>(
21
+ name: Name,
22
+ payload: TaskPayloadMap[Name],
23
+ options: EnqueueTaskOptions = {},
24
+ ): Promise<EnqueuedTask<Name>> {
25
+ this.validateOptions(options)
26
+
27
+ const data = parseTaskPayload(name, payload)
28
+ const jobOptions: JobsOptions = {
29
+ ...(options.jobId === undefined ? {} : { jobId: options.jobId }),
30
+ ...(options.delayMs === undefined ? {} : { delay: options.delayMs }),
31
+ }
32
+
33
+ const job = await this.queue.add(name, data, jobOptions)
34
+
35
+ if (job.id === undefined) {
36
+ throw new Error('BullMQ returned a Job without an id')
37
+ }
38
+
39
+ return {
40
+ queueName: TASK_QUEUE_NAME,
41
+ jobId: job.id,
42
+ name,
43
+ }
44
+ }
45
+
46
+ private validateOptions(options: EnqueueTaskOptions) {
47
+ if (options.jobId?.includes(':')) {
48
+ throw new Error('BullMQ jobId must not contain a colon')
49
+ }
50
+
51
+ if (
52
+ options.delayMs !== undefined &&
53
+ (!Number.isSafeInteger(options.delayMs) || options.delayMs < 0)
54
+ ) {
55
+ throw new Error('delayMs must be a non-negative safe integer')
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,34 @@
1
+ import { COMMON_ERRORS } from '@/common/error/common.error'
2
+ import { AUTH_ERRORS } from '@/modules/auth/auth.errors'
3
+ import { describe, expect, it } from 'vite-plus/test'
4
+ import { UPLOAD_ERRORS } from '../upload/upload.errors'
5
+
6
+ const definitions = [
7
+ ...Object.values(COMMON_ERRORS),
8
+ ...Object.values(AUTH_ERRORS),
9
+ ...Object.values(UPLOAD_ERRORS),
10
+ ]
11
+
12
+ describe('error catelog', () => {
13
+ it('uses valid and globally unique error codes', () => {
14
+ const codes = definitions.map((definition) => definition.code)
15
+ expect(new Set(codes).size).toBe(codes.length)
16
+
17
+ for (const definition of definitions) {
18
+ expect(definition.code).toMatch(/^[A-Z][A-Z0-9_]*(?:\.[A-Z][A-Z0-9_]*)+$/)
19
+ expect(definition.code.length).toBeLessThanOrEqual(80)
20
+ expect(definition.status).toBeGreaterThanOrEqual(400)
21
+ expect(definition.status).toBeLessThanOrEqual(599)
22
+ expect(definition.publicMessage.trim()).not.toBe('')
23
+ }
24
+ })
25
+
26
+ it('keeps bearerChallenge and HTTP 401 in sync', () => {
27
+ for (const definition of definitions) {
28
+ const hasBearerChallenge =
29
+ 'bearerChallenge' in definition && definition.bearerChallenge === true
30
+
31
+ expect(hasBearerChallenge).toBe(definition.status === 401)
32
+ }
33
+ })
34
+ })
@@ -0,0 +1,26 @@
1
+ import { createZodDto } from 'nestjs-zod'
2
+ import z from 'zod'
3
+
4
+ export const initiateMultipartUploadSchema = z
5
+ .object({
6
+ clientUploadId: z.uuid(),
7
+ fileName: z
8
+ .string()
9
+ .trim()
10
+ .min(1)
11
+ .max(255)
12
+ .refine((value) => !/[\u0000-\u001F\u007F]/u.test(value), 'fileName 不能包含控制字符'),
13
+ fileSize: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
14
+ contentType: z
15
+ .string()
16
+ .trim()
17
+ .min(1)
18
+ .max(255)
19
+ .regex(
20
+ /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$/,
21
+ 'contentType 必须是安全的 MIME media type,例如 image/png',
22
+ ),
23
+ })
24
+ .strict()
25
+
26
+ export class InitiateMultipartUploadDto extends createZodDto(initiateMultipartUploadSchema) {}
@@ -0,0 +1,15 @@
1
+ import { createZodDto } from 'nestjs-zod'
2
+ import z from 'zod'
3
+ import { UPLOAD_MAX_PARTS } from '../upload.constants'
4
+
5
+ export const uploadSessionParamsSchema = z.object({
6
+ uploadSessionId: z.uuid(),
7
+ })
8
+
9
+ export const uploadPartParamsSchema = uploadSessionParamsSchema.extend({
10
+ partNumber: z.coerce.number().int().min(1).max(UPLOAD_MAX_PARTS),
11
+ })
12
+
13
+ export class UploadSessionParamsDto extends createZodDto(uploadPartParamsSchema) {}
14
+
15
+ export class UploadPartParamsDto extends createZodDto(uploadPartParamsSchema) {}
@@ -0,0 +1,66 @@
1
+ import { TransformCallback } from 'node:stream'
2
+ import { Transform } from 'stream'
3
+
4
+ export class ExactSizeError extends Error {
5
+ constructor(
6
+ readonly expectedBytes: number,
7
+ readonly receivedBytes: number,
8
+ ) {
9
+ super(`Expected ${expectedBytes} bytes, recevied ${receivedBytes}`)
10
+ this.name = ExactSizeError.name
11
+ }
12
+ }
13
+
14
+ export class ExactSizeTransform extends Transform {
15
+ receivedBytes = 0
16
+
17
+ constructor(private readonly expectedBytes: number) {
18
+ super()
19
+ }
20
+
21
+ override _transform(chunk: any, encoding: BufferEncoding, callback: TransformCallback): void {
22
+ const byteLength = Buffer.isBuffer(chunk)
23
+ ? chunk.length
24
+ : Buffer.byteLength(String(chunk), encoding)
25
+ this.receivedBytes += byteLength
26
+
27
+ if (this.receivedBytes > this.expectedBytes) {
28
+ callback(new ExactSizeError(this.expectedBytes, this.receivedBytes))
29
+ return
30
+ }
31
+
32
+ callback(null, chunk)
33
+ }
34
+
35
+ /**
36
+ * 出场质检
37
+ * @param callback
38
+ * @returns
39
+ */
40
+ override _flush(callback: TransformCallback): void {
41
+ if (this.receivedBytes !== this.expectedBytes) {
42
+ callback(new ExactSizeError(this.expectedBytes, this.receivedBytes))
43
+ return
44
+ }
45
+
46
+ callback()
47
+ }
48
+ }
49
+
50
+ export function findExactSizeError(cause: unknown): ExactSizeError | null {
51
+ let current = cause
52
+
53
+ for (let depth = 0; depth < 8; depth += 1) {
54
+ if (current instanceof ExactSizeError) {
55
+ return current
56
+ }
57
+
58
+ if (typeof current !== 'object' || current === null || !('cause' in current)) {
59
+ return null
60
+ }
61
+
62
+ current = current.cause
63
+ }
64
+
65
+ return null
66
+ }
@@ -0,0 +1,239 @@
1
+ import {
2
+ AbortMultipartUploadCommand,
3
+ CompleteMultipartUploadCommand,
4
+ CreateMultipartUploadCommand,
5
+ HeadBucketCommand,
6
+ HeadObjectCommand,
7
+ ListPartsCommand,
8
+ S3Client,
9
+ UploadPartCommand,
10
+ } from '@aws-sdk/client-s3'
11
+ import { Injectable, OnModuleDestroy } from '@nestjs/common'
12
+ import { ConfigService } from '@nestjs/config'
13
+ import {
14
+ CreateMultipartInput,
15
+ MultipartIdentity,
16
+ StorageMultipartNotFoundError,
17
+ StorageObject,
18
+ StoragePart,
19
+ StoragePort,
20
+ UploadPartInput,
21
+ } from './storage.port'
22
+
23
+ function hasErrorName(cause: unknown, expected: string): boolean {
24
+ if (typeof cause !== 'object' || cause === null) {
25
+ return false
26
+ }
27
+ const record = cause as { name?: unknown; Code?: unknown }
28
+ return record.name === expected || record.Code === expected
29
+ }
30
+
31
+ function throwMappedMultipartError(cause: unknown): never {
32
+ if (hasErrorName(cause, 'NoSuchUpload')) {
33
+ throw new StorageMultipartNotFoundError(cause)
34
+ }
35
+
36
+ throw cause
37
+ }
38
+
39
+ @Injectable()
40
+ export class MinioStorageAdapter implements StoragePort, OnModuleDestroy {
41
+ private readonly client: S3Client
42
+ private readonly bucket: string
43
+
44
+ constructor(config: ConfigService) {
45
+ this.bucket = config.getOrThrow<string>('storage.bucket')
46
+ this.client = new S3Client({
47
+ endpoint: config.getOrThrow<string>('storage.endpoint'),
48
+ region: config.getOrThrow<string>('storage.region'),
49
+ credentials: {
50
+ accessKeyId: config.getOrThrow<string>('storage.accessKeyId'),
51
+ secretAccessKey: config.getOrThrow<string>('storage.secretAccessKey'),
52
+ },
53
+ forcePathStyle: config.getOrThrow<boolean>('storage.forcePathStyle'),
54
+ // UploadPart 的 Body 是不可回放流,禁止 SDK 在后端自动重放。
55
+ maxAttempts: 1,
56
+ requestChecksumCalculation: 'WHEN_REQUIRED',
57
+ responseChecksumValidation: 'WHEN_REQUIRED',
58
+ })
59
+ }
60
+
61
+ private async assertBucketAvailable(): Promise<void> {
62
+ await this.client.send(
63
+ new HeadBucketCommand({
64
+ Bucket: this.bucket,
65
+ }),
66
+ )
67
+ }
68
+
69
+ onModuleDestroy() {
70
+ this.client.destroy()
71
+ }
72
+
73
+ async onModuleInit() {
74
+ await this.assertBucketAvailable()
75
+ }
76
+
77
+ async createMultipartUpload(input: CreateMultipartInput) {
78
+ const response = await this.client.send(
79
+ new CreateMultipartUploadCommand({
80
+ Bucket: input.bucket,
81
+ Key: input.objectKey,
82
+ ContentType: input.contentType,
83
+ Metadata: input.metadata,
84
+ }),
85
+ )
86
+
87
+ if (!response.UploadId) {
88
+ throw new Error('Storage did not return an UploadId')
89
+ }
90
+
91
+ return {
92
+ storageUploadId: response.UploadId,
93
+ }
94
+ }
95
+
96
+ async uploadPart(input: UploadPartInput) {
97
+ try {
98
+ const response = await this.client.send(
99
+ new UploadPartCommand({
100
+ Bucket: input.bucket,
101
+ Key: input.objectKey,
102
+ UploadId: input.storageUploadId,
103
+ PartNumber: input.partNumber,
104
+ Body: input.body,
105
+ ContentLength: input.contentLength,
106
+ }),
107
+ {
108
+ abortSignal: input.abortSignal,
109
+ },
110
+ )
111
+
112
+ if (!response.ETag) {
113
+ throw new Error('Storage did not return an ETag')
114
+ }
115
+
116
+ return {
117
+ etag: response.ETag,
118
+ }
119
+ } catch (cause: unknown) {
120
+ throwMappedMultipartError(cause)
121
+ }
122
+ }
123
+
124
+ async listParts(input: MultipartIdentity): Promise<StoragePart[]> {
125
+ const parts: StoragePart[] = []
126
+ let partNumberMarker: string | undefined
127
+ try {
128
+ for (;;) {
129
+ const response = await this.client.send(
130
+ new ListPartsCommand({
131
+ Bucket: input.bucket,
132
+ Key: input.objectKey,
133
+ UploadId: input.storageUploadId,
134
+ PartNumberMarker: partNumberMarker,
135
+ }),
136
+ )
137
+
138
+ for (const part of response.Parts ?? []) {
139
+ if (part.PartNumber === undefined || part.ETag === undefined || part.Size === undefined) {
140
+ throw new Error('Storage returned an incomplete part record')
141
+ }
142
+ parts.push({
143
+ partNumber: part.PartNumber,
144
+ etag: part.ETag,
145
+ size: part.Size,
146
+ })
147
+ }
148
+
149
+ if (!response.IsTruncated) {
150
+ break
151
+ }
152
+
153
+ const nextMarker = response.NextPartNumberMarker
154
+ if (!nextMarker || nextMarker === partNumberMarker) {
155
+ throw new Error('Storage returned an invalid ListParts cursor')
156
+ }
157
+
158
+ partNumberMarker = nextMarker
159
+ }
160
+ } catch (cause: unknown) {
161
+ throwMappedMultipartError(cause)
162
+ }
163
+
164
+ return parts.sort((left, right) => left.partNumber - right.partNumber)
165
+ }
166
+
167
+ async completeMultipartUpload(input: MultipartIdentity & { parts: readonly StoragePart[] }) {
168
+ try {
169
+ const response = await this.client.send(
170
+ new CompleteMultipartUploadCommand({
171
+ Bucket: input.bucket,
172
+ Key: input.objectKey,
173
+ UploadId: input.storageUploadId,
174
+ MultipartUpload: {
175
+ Parts: input.parts.map((part) => ({
176
+ PartNumber: part.partNumber,
177
+ ETag: part.etag,
178
+ })),
179
+ },
180
+ }),
181
+ )
182
+ return {
183
+ etag: response.ETag ?? null,
184
+ }
185
+ } catch (cause: unknown) {
186
+ throwMappedMultipartError(cause)
187
+ }
188
+ }
189
+
190
+ async abortMultipartUpload(input: MultipartIdentity): Promise<void> {
191
+ try {
192
+ await this.client.send(
193
+ new AbortMultipartUploadCommand({
194
+ Bucket: input.bucket,
195
+ Key: input.objectKey,
196
+ UploadId: input.storageUploadId,
197
+ }),
198
+ )
199
+ } catch (cause: unknown) {
200
+ if (hasErrorName(cause, 'NoSuchUpload')) {
201
+ return
202
+ }
203
+ throw cause
204
+ }
205
+ }
206
+
207
+ async headObject(input: { bucket: string; objectKey: string }): Promise<StorageObject | null> {
208
+ try {
209
+ const response = await this.client.send(
210
+ new HeadObjectCommand({
211
+ Bucket: input.bucket,
212
+ Key: input.objectKey,
213
+ }),
214
+ )
215
+ return {
216
+ etag: response.ETag ?? null,
217
+ contentLength: response.ContentLength ?? null,
218
+ metadata: response.Metadata ?? {},
219
+ }
220
+ } catch (cause: unknown) {
221
+ if (hasErrorName(cause, 'NoSuchBucket')) {
222
+ throw cause
223
+ }
224
+
225
+ if (hasErrorName(cause, 'NoSuchKey')) {
226
+ return null
227
+ }
228
+
229
+ if (hasErrorName(cause, 'NotFound')) {
230
+ // 某些 S3 兼容实现会把“对象不存在”和“Bucket 不存在”都表示成 404。
231
+ // 额外探测 Bucket;只有 Bucket 确实可用时,才把这次 404 当成对象不存在。
232
+ await this.assertBucketAvailable()
233
+ return null
234
+ }
235
+
236
+ throw cause
237
+ }
238
+ }
239
+ }