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,1890 @@
1
+ # NestJS BullMQ 公共任务队列接入方案
2
+
3
+ > 状态:实施方案,尚未修改代码
4
+ > 基线日期:2026-09-03
5
+ > 适用范围:apps/server、Redis、BullMQ
6
+ > 面向读者:以前端开发为主、刚开始搭建 NestJS 后端的开发者
7
+ > 本文目标:先建立一套所有后端业务都能复用的公共队列能力,不实现上传清理或其他真实业务任务。
8
+
9
+ ## 0. 结论先行
10
+
11
+ 本项目第一版公共队列采用:
12
+
13
+ - BullMQ
14
+ - @nestjs/bullmq
15
+ - 当前已有的 Redis
16
+ - 一个默认物理队列 server-tasks
17
+ - 一个公共投递入口 TaskQueueService
18
+ - 一个公共 Worker:TaskQueueProcessor
19
+ - 使用 job.name 区分任务类型,例如 system.queue-smoke.v1、email.send.v1
20
+
21
+ 最终调用关系如下:
22
+
23
+ ```text
24
+ AuthModule / UploadModule / 未来业务模块
25
+ │
26
+ │ 注入 TaskQueueService
27
+ │ enqueue(任务名, 小型 Payload)
28
+ ▼
29
+ BullMQ: server-tasks
30
+ │
31
+ Redis DB 1
32
+ │
33
+ ▼
34
+ TaskQueueProcessor
35
+ │
36
+ 根据 job.name 分发
37
+ ▼
38
+ 具体任务处理逻辑
39
+ ```
40
+
41
+ 这一版先把“公共道路”修好,暂时不往道路上放真实业务车辆。唯一允许加入的是无业务含义的冒烟任务 system.queue-smoke.v1,用来证明:
42
+
43
+ ```text
44
+ 成功投递
45
+ → Redis 中出现 Job
46
+ → Worker 收到 Job
47
+ → Job 执行完成
48
+ ```
49
+
50
+ ### 0.1 为什么选 BullMQ
51
+
52
+ | 方案 | 优点 | 当前不选或选择的原因 |
53
+ | --------------------- | --------------------------------------------- | -------------------------------------------------------------- |
54
+ | Node.js 内存队列 | 最简单 | 服务重启就丢任务,多实例也无法共享,不适合作为后端公共能力 |
55
+ | PostgreSQL 轮询任务表 | 可与业务数据靠得更近 | 需要自己实现抢占、锁、重试、延时和清理;以后做 Outbox 时再引入 |
56
+ | RabbitMQ | 成熟、功能完整 | 当前项目规模下运维和学习成本偏高 |
57
+ | Kafka | 吞吐高,适合事件流 | 不适合当前普通后台任务场景,明显过重 |
58
+ | BullMQ + Redis | NestJS 接入自然,支持重试、延时、并发和持久化 | 适合当前项目,采用 |
59
+
60
+ 不要安装旧的 @nestjs/bull。本方案统一使用 @nestjs/bullmq。
61
+
62
+ 截至本文基线日期,建议版本为:
63
+
64
+ ```yaml
65
+ '@nestjs/bullmq': ^12.0.0
66
+ bullmq: ^6.3.4
67
+ ```
68
+
69
+ 这组版本兼容当前项目的 NestJS 11。
70
+
71
+ ### 0.2 第一版为什么只建一个物理队列
72
+
73
+ server-tasks 适合邮件、通知、轻量文件处理、普通第三方接口调用等任务。这样配置少、理解成本低,足够支撑项目初期。
74
+
75
+ 以下任务以后应拆成独立物理队列和独立 Worker:
76
+
77
+ - AI 推理
78
+ - 视频转码
79
+ - CPU 密集计算
80
+ - 超大文件处理
81
+ - 需要完全不同并发量或超时策略的任务
82
+ - 需要独立扩容、独立发布或独立故障隔离的任务
83
+
84
+ 即使以后拆队列,业务模块仍然应从公共 TaskQueueService 进入,不要各自直接连接 Redis。
85
+
86
+ ### 0.3 搭完后,后端项目是不是基本成型
87
+
88
+ 可以说“后端基础设施骨架基本成型”,但不能说“已经可以直接生产上线”。
89
+
90
+ 搭完本方案后,项目已经具备:
91
+
92
+ - NestJS 应用骨架
93
+ - PostgreSQL 数据访问
94
+ - Redis 与 Session
95
+ - 认证基础
96
+ - 对象存储与上传基础
97
+ - 可复用的异步任务队列
98
+ - 基础错误处理、配置加载、测试和构建流程
99
+
100
+ 仍然需要随着业务继续完善:
101
+
102
+ - 真实业务模块和权限模型
103
+ - 队列任务的幂等实现
104
+ - 日志、指标和告警
105
+ - 数据库与 Redis 备份恢复
106
+ - 限流、安全加固和密钥管理
107
+ - CI/CD、灰度发布和回滚
108
+ - 强一致投递场景所需的 Transactional Outbox
109
+
110
+ 所以准确结论是:骨架成型,业务和生产保障还要继续补。
111
+
112
+ ---
113
+
114
+ ## 1. 前端开发者先理解五个概念
115
+
116
+ 可以把队列理解成餐厅取号。
117
+
118
+ | 队列概念 | 餐厅类比 | 在本项目中的角色 |
119
+ | -------- | -------------------- | ------------------ |
120
+ | Queue | 等候区 | server-tasks |
121
+ | Job | 一张号码单 | 一次具体任务 |
122
+ | Producer | 发号机 | TaskQueueService |
123
+ | Worker | 后厨 | TaskQueueProcessor |
124
+ | Redis | 保存号码和状态的系统 | 队列数据存储 |
125
+
126
+ 最容易误解的一点是:
127
+
128
+ ```ts
129
+ await taskQueueService.enqueue(...)
130
+ ```
131
+
132
+ 只表示任务已经成功写入队列,不表示任务已经执行完成。
133
+
134
+ 将来 HTTP 接口投递异步任务时,通常返回:
135
+
136
+ ```json
137
+ {
138
+ "jobId": "some-job-id",
139
+ "status": "queued"
140
+ }
141
+ ```
142
+
143
+ HTTP 状态可使用 202 Accepted。前端不能直接连接 Redis。如果前端以后需要查询长任务进度,应再增加数据库任务表和查询接口;这不属于本次公共队列基础搭建。
144
+
145
+ ---
146
+
147
+ ## 2. 本次范围
148
+
149
+ ### 2.1 本次包含
150
+
151
+ - 安装 BullMQ 依赖
152
+ - 队列专用环境配置
153
+ - 队列配置校验
154
+ - 默认物理队列注册
155
+ - 类型安全的任务名称与 Payload 契约
156
+ - 公共投递服务
157
+ - 公共 Worker
158
+ - 重试、指数退避和任务保留上限
159
+ - Worker 启停开关
160
+ - 优雅停机
161
+ - 单元测试和真实 Redis 冒烟测试
162
+ - 后续新任务接入模板
163
+
164
+ ### 2.2 本次不包含
165
+
166
+ - 上传清理的真实实现
167
+ - 邮件、通知、AI、视频等真实业务任务
168
+ - 定时任务
169
+ - Bull Board 管理界面
170
+ - async_tasks 数据库表
171
+ - SSE 或 WebSocket 进度推送
172
+ - 独立 Worker 应用
173
+ - Transactional Outbox
174
+
175
+ 上传清理只作为未来附加项,详细说明见 [上传清理.md](./上传清理.md),当前不实现。
176
+
177
+ ---
178
+
179
+ ## 3. 当前项目基线
180
+
181
+ | 项目 | 当前状态 | 本方案处理 |
182
+ | ------------------ | ------------------------- | ------------------------------------- |
183
+ | NestJS | 11.1.x,Fastify | 使用 @nestjs/bullmq 12 |
184
+ | Redis | Redis 8,已开启 AOF | 本地复用 Redis,队列使用 DB 1 |
185
+ | Session | 使用 Redis DB 0 | 与队列配置分开 |
186
+ | Session Redis 连接 | 面向 HTTP 快速失败 | 绝不把现有 Redis Client 注入 BullMQ |
187
+ | BullMQ | 尚未安装 | 加入 workspace catalog 和 server 依赖 |
188
+ | Shutdown Hooks | main.ts 尚未开启 | 增加 app.enableShutdownHooks() |
189
+ | Server 测试 | 只匹配 test/\*_/_.spec.ts | 队列测试必须放 apps/server/test |
190
+ | 环境文件 | 已有分层加载 | 沿用现有 ENV_ARR 顺序 |
191
+
192
+ 当前 Session Redis 使用了 commandTimeout、maxRetriesPerRequest 和 enableOfflineQueue 等请求链路策略。Worker 需要长期阻塞取任务和断线恢复,因此不能复用该 Redis 实例,也不要把这些参数原样复制到 BullMQ。
193
+
194
+ BullMQ 应接收一份普通 Redis 连接配置对象,由它为 Queue 和 Worker 创建、管理各自的连接。
195
+
196
+ ---
197
+
198
+ ## 4. 最终文件结构
199
+
200
+ 完成本方案后,建议形成:
201
+
202
+ ```text
203
+ apps/server/src/config/
204
+ └── queue.config.ts
205
+
206
+ apps/server/src/modules/task-queue/
207
+ ├── queue-worker.bootstrap.ts
208
+ ├── task-queue.constants.ts
209
+ ├── task-queue.contracts.ts
210
+ ├── task-queue.module.ts
211
+ ├── task-queue.processor.ts
212
+ └── task-queue.service.ts
213
+
214
+ apps/server/test/
215
+ ├── task-queue.processor.spec.ts
216
+ ├── task-queue.service.spec.ts
217
+ └── task-queue.integration.spec.ts
218
+ ```
219
+
220
+ 同时修改:
221
+
222
+ ```text
223
+ pnpm-workspace.yaml
224
+ apps/server/package.json
225
+ apps/server/.env.development
226
+ apps/server/.env.production
227
+ apps/server/src/config/index.ts
228
+ apps/server/src/app.module.ts
229
+ apps/server/src/main.ts
230
+ ```
231
+
232
+ ---
233
+
234
+ ## 5. 公共队列必须先定下的规则
235
+
236
+ ### 5.1 队列名
237
+
238
+ 第一版只有一个物理队列:
239
+
240
+ ```text
241
+ server-tasks
242
+ ```
243
+
244
+ ### 5.2 任务名
245
+
246
+ 任务名统一采用:
247
+
248
+ ```text
249
+ 领域.动作.v版本
250
+ ```
251
+
252
+ 示例:
253
+
254
+ ```text
255
+ system.queue-smoke.v1
256
+ email.send.v1
257
+ notification.push.v1
258
+ ```
259
+
260
+ Payload 或行为发生不兼容修改时,必须升级任务版本,不能悄悄改变旧任务含义。
261
+
262
+ ### 5.3 Payload
263
+
264
+ Payload 只传:
265
+
266
+ - 数据库记录 ID
267
+ - 对象 ID
268
+ - 少量执行参数
269
+ - 必要的追踪字段
270
+
271
+ 禁止传:
272
+
273
+ - Buffer
274
+ - 文件正文
275
+ - 大段 Base64
276
+ - 完整数据库实体
277
+ - 登录 Token
278
+ - Redis、数据库或对象存储密钥
279
+ - 无法 JSON 序列化的数据
280
+
281
+ Worker 真正执行时,应根据 ID 重新读取数据库,不能相信队列中保存的是最新业务状态。
282
+
283
+ ### 5.4 投递和执行语义
284
+
285
+ BullMQ 是“至少执行一次”,不是“恰好执行一次”。
286
+
287
+ 同一个任务可能因为进程崩溃、锁丢失、网络中断或调用方重试而再次执行。因此每个真实任务处理器都必须幂等。
288
+
289
+ 常见幂等手段:
290
+
291
+ - 数据库唯一约束
292
+ - 带当前状态条件的 UPDATE
293
+ - 独立幂等键
294
+ - 调用外部服务时传稳定幂等键
295
+ - 发现目标已经完成时直接成功返回
296
+
297
+ 自定义 jobId 只能降低重复投递概率,不能替代业务幂等。
298
+
299
+ ### 5.5 每个进程中,一个物理队列只注册一种公共分发 Processor
300
+
301
+ 不要给同一个 server-tasks 队列中的每个任务分别写一个 @Processor。
302
+
303
+ 错误理解:
304
+
305
+ ```text
306
+ EmailProcessor 只会拿 email.send.v1
307
+ UploadProcessor 只会拿 upload.cleanup.v1
308
+ ```
309
+
310
+ 实际上,多个 Worker 会竞争同一队列中的所有 Job,并不会按 job.name 自动绑定。EmailProcessor 可能拿到上传任务。
311
+
312
+ 正确做法:
313
+
314
+ ```text
315
+ server-tasks
316
+ │
317
+ ▼
318
+ 唯一一种 TaskQueueProcessor
319
+ (每个启用 Worker 的副本各有一个同构实例)
320
+ │
321
+ ├── job.name = email.send.v1
322
+ ├── job.name = notification.push.v1
323
+ └── job.name = ...
324
+ ```
325
+
326
+ 第一版在公共 Processor 中分发。横向扩容时可以运行多个同构 Worker 实例;关键是它们都必须认识该物理队列中的全部 job.name,不能按任务名拆成互不相识的 Processor 类型。
327
+
328
+ 任务数量明显增加后,可再升级成 Handler Registry,但仍保持每个进程只注册一种能处理全部任务名的公共 WorkerHost。
329
+
330
+ ---
331
+
332
+ ## 6. 第 1 步:安装依赖
333
+
334
+ ### 本步目标
335
+
336
+ 把 BullMQ 加入 workspace catalog,并让 Server 使用 catalog 版本。
337
+
338
+ ### 文件位置
339
+
340
+ ```text
341
+ pnpm-workspace.yaml
342
+ apps/server/package.json
343
+ ```
344
+
345
+ ### 精确修改
346
+
347
+ 在 pnpm-workspace.yaml 的 catalog 中加入:
348
+
349
+ ```yaml
350
+ '@nestjs/bullmq': ^12.0.0
351
+ bullmq: ^6.3.4
352
+ ```
353
+
354
+ 在 apps/server/package.json 的 dependencies 中加入:
355
+
356
+ ```json
357
+ {
358
+ "@nestjs/bullmq": "catalog:",
359
+ "bullmq": "catalog:"
360
+ }
361
+ ```
362
+
363
+ ### 验证
364
+
365
+ 在仓库根目录执行:
366
+
367
+ ```powershell
368
+ vp install
369
+ vp run --filter server build
370
+ ```
371
+
372
+ ### 常见错误
373
+
374
+ - 安装成 @nestjs/bull。
375
+ - 只安装 @nestjs/bullmq,没有安装 bullmq。
376
+ - 在 Server 中写死版本,没有使用 catalog。
377
+ - 同时混用 Bull 和 BullMQ 的装饰器。
378
+
379
+ ---
380
+
381
+ ## 7. 第 2 步:增加队列环境变量
382
+
383
+ ### 本步目标
384
+
385
+ 让队列拥有独立于 Session Redis 的配置入口。
386
+
387
+ ### 文件位置
388
+
389
+ 开发环境建议修改:
390
+
391
+ ```text
392
+ apps/server/.env.development
393
+ ```
394
+
395
+ 个人本机密码等值应放:
396
+
397
+ ```text
398
+ apps/server/.env.development.local
399
+ ```
400
+
401
+ 生产变量写入部署平台 Secret,不要把真实密码提交到 Git。
402
+
403
+ 当前项目环境文件优先级为:
404
+
405
+ ```text
406
+ .env.<环境>.local
407
+ → .env.local
408
+ → .env.<环境>
409
+ → .env
410
+ ```
411
+
412
+ ### 建议变量
413
+
414
+ ```dotenv
415
+ # BullMQ 基础连接
416
+ QUEUE_REDIS_HOST=localhost
417
+ QUEUE_REDIS_PORT=6379
418
+ QUEUE_REDIS_USERNAME=
419
+ # 下面只是占位值,必须替换;不能原样复制使用
420
+ QUEUE_REDIS_PASSWORD=YOUR_LOCAL_REDIS_PASSWORD
421
+ QUEUE_REDIS_DB=1
422
+ QUEUE_PREFIX=bubbles:development:queue
423
+
424
+ # 当前 NestJS 进程是否启动 Worker
425
+ QUEUE_WORKER_ENABLED=true
426
+
427
+ # 默认任务策略
428
+ QUEUE_DEFAULT_ATTEMPTS=3
429
+ QUEUE_BACKOFF_DELAY_MS=1000
430
+ QUEUE_COMPLETED_AGE_SECONDS=86400
431
+ QUEUE_COMPLETED_COUNT=1000
432
+ QUEUE_FAILED_AGE_SECONDS=604800
433
+ QUEUE_FAILED_COUNT=5000
434
+ ```
435
+
436
+ 生产建议:
437
+
438
+ ```dotenv
439
+ # 下面两个 YOUR_... 都只是占位值,必须由部署配置或 Secret 替换
440
+ QUEUE_REDIS_HOST=YOUR_QUEUE_REDIS_HOST
441
+ QUEUE_REDIS_PORT=6379
442
+ QUEUE_REDIS_USERNAME=
443
+ QUEUE_REDIS_PASSWORD=YOUR_PRODUCTION_QUEUE_REDIS_PASSWORD
444
+ QUEUE_REDIS_DB=0
445
+ QUEUE_PREFIX=bubbles:production:queue
446
+ QUEUE_WORKER_ENABLED=true
447
+ ```
448
+
449
+ ### 代码解释
450
+
451
+ - 本地 Session 使用 DB 0,队列使用 DB 1,便于查看和清理。
452
+ - Redis DB 只是逻辑隔离,不是性能、安全或故障隔离。
453
+ - 生产推荐队列使用独立 Redis,并使用 DB 0。
454
+ - Redis Cluster 只支持 DB 0。
455
+ - QUEUE_PREFIX 用于区分项目和环境。
456
+ - 不要配置 ioredis 的 keyPrefix。BullMQ 必须使用自己的 prefix。
457
+ - 不要把字符串 false 写成 Boolean(process.env.QUEUE_WORKER_ENABLED),因为 Boolean('false') 仍然是 true。
458
+
459
+ ### 验证
460
+
461
+ 确认本地 Redis 可用。把命令中的占位密码替换为自己的本地值:
462
+
463
+ ```powershell
464
+ docker compose up -d redis
465
+ $queueRedisPassword = Read-Host '请输入本地 Redis 密码'
466
+ docker compose exec -e REDISCLI_AUTH=$queueRedisPassword redis redis-cli -n 1 ping
467
+ Remove-Variable queueRedisPassword
468
+ ```
469
+
470
+ 预期输出:
471
+
472
+ ```text
473
+ PONG
474
+ ```
475
+
476
+ ### 常见错误
477
+
478
+ - 队列仍然使用 REDIS_DB=0,和 Session 键混在一起。
479
+ - 把生产密码写入 .env.production 并提交。
480
+ - Prefix 在开发、测试、生产环境完全相同。
481
+ - 使用 Redis Cluster 时仍配置 DB 1。
482
+
483
+ ---
484
+
485
+ ## 8. 第 3 步:建立 queue.config.ts
486
+
487
+ ### 本步目标
488
+
489
+ 集中读取并校验所有队列变量。配置错误时让应用启动失败,而不是运行到一半才发现。
490
+
491
+ ### 文件位置
492
+
493
+ 新建:
494
+
495
+ ```text
496
+ apps/server/src/config/queue.config.ts
497
+ ```
498
+
499
+ ### 完整代码
500
+
501
+ ```ts
502
+ import { registerAs } from '@nestjs/config'
503
+
504
+ export interface QueueConfig {
505
+ host: string
506
+ port: number
507
+ username?: string
508
+ password?: string
509
+ db: number
510
+ prefix: string
511
+ workerEnabled: boolean
512
+ defaultAttempts: number
513
+ backoffDelayMs: number
514
+ completedAgeSeconds: number
515
+ completedCount: number
516
+ failedAgeSeconds: number
517
+ failedCount: number
518
+ }
519
+
520
+ function readInteger(
521
+ name: string,
522
+ fallback: number,
523
+ minimum: number,
524
+ maximum = Number.MAX_SAFE_INTEGER,
525
+ ) {
526
+ const rawValue = (process.env[name] ?? String(fallback)).trim()
527
+ const value = Number(rawValue)
528
+
529
+ if (rawValue === '' || !Number.isSafeInteger(value) || value < minimum || value > maximum) {
530
+ throw new Error(name + ' must be an integer between ' + minimum + ' and ' + maximum)
531
+ }
532
+
533
+ return value
534
+ }
535
+
536
+ function readBoolean(name: string, fallback: boolean) {
537
+ const rawValue = process.env[name]
538
+
539
+ if (rawValue === undefined) {
540
+ return fallback
541
+ }
542
+
543
+ const value = rawValue.trim().toLowerCase()
544
+
545
+ if (value === 'true') {
546
+ return true
547
+ }
548
+
549
+ if (value === 'false') {
550
+ return false
551
+ }
552
+
553
+ throw new Error(name + ' must be true or false')
554
+ }
555
+
556
+ function readOptionalText(name: string) {
557
+ const value = process.env[name]?.trim()
558
+ return value ? value : undefined
559
+ }
560
+
561
+ export default registerAs('queue', (): QueueConfig => {
562
+ const environment = process.env.NODE_ENV ?? 'development'
563
+ const prefix = process.env.QUEUE_PREFIX?.trim() || ['bubbles', environment, 'queue'].join(':')
564
+
565
+ if (!prefix) {
566
+ throw new Error('QUEUE_PREFIX must not be empty')
567
+ }
568
+
569
+ return {
570
+ host: process.env.QUEUE_REDIS_HOST?.trim() || 'localhost',
571
+ port: readInteger('QUEUE_REDIS_PORT', 6379, 1, 65_535),
572
+ username: readOptionalText('QUEUE_REDIS_USERNAME'),
573
+ password: readOptionalText('QUEUE_REDIS_PASSWORD'),
574
+ db: readInteger('QUEUE_REDIS_DB', 1, 0),
575
+ prefix,
576
+ workerEnabled: readBoolean('QUEUE_WORKER_ENABLED', true),
577
+ defaultAttempts: readInteger('QUEUE_DEFAULT_ATTEMPTS', 3, 1),
578
+ backoffDelayMs: readInteger('QUEUE_BACKOFF_DELAY_MS', 1_000, 1),
579
+ completedAgeSeconds: readInteger('QUEUE_COMPLETED_AGE_SECONDS', 86_400, 1),
580
+ completedCount: readInteger('QUEUE_COMPLETED_COUNT', 1_000, 1),
581
+ failedAgeSeconds: readInteger('QUEUE_FAILED_AGE_SECONDS', 604_800, 1),
582
+ failedCount: readInteger('QUEUE_FAILED_COUNT', 5_000, 1),
583
+ }
584
+ })
585
+ ```
586
+
587
+ ### 代码解释
588
+
589
+ - 所有数字先转成整数,再做范围校验。
590
+ - 密码和用户名允许为空,方便连接无认证的开发 Redis。
591
+ - QUEUE_WORKER_ENABLED 被严格解析成 true 或 false。
592
+ - 默认 Prefix 自动带 NODE_ENV,避免不同环境撞键。
593
+ - 配置文件只负责读取配置,不创建 Redis Client。
594
+
595
+ ### 常见错误
596
+
597
+ - 使用 Number.parseInt 后不检查 NaN。
598
+ - 用 Boolean('false') 解析环境变量。
599
+ - 在配置文件中直接 new Redis。
600
+ - 把 Session Redis 配置对象整个复制过来。
601
+
602
+ ---
603
+
604
+ ## 9. 第 4 步:注册 queueConfig
605
+
606
+ ### 本步目标
607
+
608
+ 让 ConfigService 可以读取 queue 配置。
609
+
610
+ ### 文件位置
611
+
612
+ 修改:
613
+
614
+ ```text
615
+ apps/server/src/config/index.ts
616
+ apps/server/src/app.module.ts
617
+ ```
618
+
619
+ ### 精确修改
620
+
621
+ 在 apps/server/src/config/index.ts 增加:
622
+
623
+ ```ts
624
+ export { default as queueConfig } from './queue.config'
625
+ ```
626
+
627
+ 在 AppModule 顶部的配置导入中加入 queueConfig:
628
+
629
+ ```ts
630
+ import {
631
+ appConfig,
632
+ databaseConfig,
633
+ llmConfig,
634
+ queueConfig,
635
+ redisConfig,
636
+ sessionConfig,
637
+ storageConfig,
638
+ } from '@/config'
639
+ ```
640
+
641
+ 把 ConfigModule.forRoot 的 load 改为:
642
+
643
+ ```ts
644
+ load: [
645
+ appConfig,
646
+ databaseConfig,
647
+ llmConfig,
648
+ queueConfig,
649
+ redisConfig,
650
+ sessionConfig,
651
+ storageConfig,
652
+ ],
653
+ ```
654
+
655
+ ### 验证
656
+
657
+ ```powershell
658
+ vp run --filter server build
659
+ ```
660
+
661
+ 如果 QUEUE_REDIS_PORT 等变量不合法,构建通常不会读取运行环境,但启动 Server 时必须立即报出清晰的配置错误。
662
+
663
+ ---
664
+
665
+ ## 10. 第 5 步:定义队列名和任务契约
666
+
667
+ ### 本步目标
668
+
669
+ 集中定义物理队列名、任务名和 Payload 类型,避免业务模块随手拼字符串。
670
+
671
+ ### 文件位置
672
+
673
+ 新建:
674
+
675
+ ```text
676
+ apps/server/src/modules/task-queue/task-queue.constants.ts
677
+ apps/server/src/modules/task-queue/task-queue.contracts.ts
678
+ ```
679
+
680
+ ### task-queue.constants.ts
681
+
682
+ ```ts
683
+ export const TASK_QUEUE_NAME = 'server-tasks'
684
+
685
+ export const TASK_QUEUE_CONCURRENCY = 5
686
+
687
+ export const TASK_JOB_NAMES = {
688
+ QUEUE_SMOKE: 'system.queue-smoke.v1',
689
+ } as const
690
+ ```
691
+
692
+ ### task-queue.contracts.ts
693
+
694
+ ```ts
695
+ import { z } from 'zod'
696
+ import { TASK_JOB_NAMES, TASK_QUEUE_NAME } from './task-queue.constants'
697
+
698
+ export const QueueSmokePayloadSchema = z.object({
699
+ marker: z.string().min(1).max(100),
700
+ enqueuedAt: z.string().datetime(),
701
+ })
702
+
703
+ export type TaskName = (typeof TASK_JOB_NAMES)[keyof typeof TASK_JOB_NAMES]
704
+
705
+ export interface TaskPayloadMap {
706
+ [TASK_JOB_NAMES.QUEUE_SMOKE]: z.infer<typeof QueueSmokePayloadSchema>
707
+ }
708
+
709
+ export const TASK_PAYLOAD_SCHEMAS = {
710
+ [TASK_JOB_NAMES.QUEUE_SMOKE]: QueueSmokePayloadSchema,
711
+ } satisfies Record<TaskName, z.ZodType>
712
+
713
+ export function parseTaskPayload<Name extends TaskName>(
714
+ name: Name,
715
+ payload: unknown,
716
+ ): TaskPayloadMap[Name] {
717
+ return TASK_PAYLOAD_SCHEMAS[name].parse(payload) as TaskPayloadMap[Name]
718
+ }
719
+
720
+ export interface EnqueueTaskOptions {
721
+ jobId?: string
722
+ delayMs?: number
723
+ }
724
+
725
+ export interface EnqueuedTask<Name extends TaskName = TaskName> {
726
+ queueName: typeof TASK_QUEUE_NAME
727
+ jobId: string
728
+ name: Name
729
+ }
730
+ ```
731
+
732
+ ### 代码解释
733
+
734
+ - TASK_QUEUE_NAME 是 Redis 中的物理队列。
735
+ - TASK_JOB_NAMES 是任务协议,不允许业务代码手写字符串。
736
+ - TaskPayloadMap 建立“任务名 → Payload 类型”的对应关系。
737
+ - TypeScript 保护正常后端调用。
738
+ - Zod 保护 Redis 中遗留的旧任务、错误生产者和运行时脏数据。
739
+ - 冒烟任务只用于验证基础设施,不承载业务。
740
+
741
+ ### 常见错误
742
+
743
+ - 任务名没有版本号。
744
+ - 多个文件分别定义相同字符串。
745
+ - 只写 TypeScript interface,不做 Worker 运行时校验。
746
+ - 在 Payload 中放完整文件内容。
747
+
748
+ ---
749
+
750
+ ## 11. 第 6 步:实现公共投递服务
751
+
752
+ ### 本步目标
753
+
754
+ 让所有业务模块只通过 TaskQueueService 投递任务,不直接操作 BullMQ Queue。
755
+
756
+ ### 文件位置
757
+
758
+ 新建:
759
+
760
+ ```text
761
+ apps/server/src/modules/task-queue/task-queue.service.ts
762
+ ```
763
+
764
+ ### 完整代码
765
+
766
+ ```ts
767
+ import { InjectQueue } from '@nestjs/bullmq'
768
+ import { Injectable } from '@nestjs/common'
769
+ import type { JobsOptions, Queue } from 'bullmq'
770
+ import { TASK_QUEUE_NAME } from './task-queue.constants'
771
+ import {
772
+ type EnqueuedTask,
773
+ type EnqueueTaskOptions,
774
+ parseTaskPayload,
775
+ type TaskName,
776
+ type TaskPayloadMap,
777
+ } from './task-queue.contracts'
778
+
779
+ @Injectable()
780
+ export class TaskQueueService {
781
+ constructor(
782
+ @InjectQueue(TASK_QUEUE_NAME)
783
+ private readonly queue: Queue,
784
+ ) {}
785
+
786
+ async enqueue<Name extends TaskName>(
787
+ name: Name,
788
+ payload: TaskPayloadMap[Name],
789
+ options: EnqueueTaskOptions = {},
790
+ ): Promise<EnqueuedTask<Name>> {
791
+ this.validateOptions(options)
792
+
793
+ const data = parseTaskPayload(name, payload)
794
+ const jobOptions: JobsOptions = {
795
+ ...(options.jobId === undefined ? {} : { jobId: options.jobId }),
796
+ ...(options.delayMs === undefined ? {} : { delay: options.delayMs }),
797
+ }
798
+
799
+ const job = await this.queue.add(name, data, jobOptions)
800
+
801
+ if (job.id === undefined) {
802
+ throw new Error('BullMQ returned a Job without an id')
803
+ }
804
+
805
+ return {
806
+ queueName: TASK_QUEUE_NAME,
807
+ jobId: job.id,
808
+ name,
809
+ }
810
+ }
811
+
812
+ private validateOptions(options: EnqueueTaskOptions) {
813
+ if (options.jobId?.includes(':')) {
814
+ throw new Error('BullMQ jobId must not contain a colon')
815
+ }
816
+
817
+ if (
818
+ options.delayMs !== undefined &&
819
+ (!Number.isSafeInteger(options.delayMs) || options.delayMs < 0)
820
+ ) {
821
+ throw new Error('delayMs must be a non-negative safe integer')
822
+ }
823
+ }
824
+ }
825
+ ```
826
+
827
+ ### 代码解释
828
+
829
+ - 业务层拿不到 pause、clean、drain、obliterate 等危险方法。
830
+ - 对外只返回 queueName、jobId 和 name,不泄漏整个 BullMQ Job。
831
+ - Service 在投递前做一次 Zod 校验。
832
+ - 默认重试和保留规则由公共模块统一提供,业务不能随意覆盖。
833
+ - jobId 不能包含冒号。
834
+
835
+ 不要在这里为 queue.add 层层添加 catch。未知 Redis 异常应继续向上交给项目现有全局异常过滤器。只有以后明确需要转换为某个公开 503 错误时,才在这个基础设施边界转换一次。
836
+
837
+ ### jobId 的边界
838
+
839
+ 相同 jobId 在旧 Job 仍保留于队列时,可以阻止重复加入。但 Job 被自动清理后,这个 jobId 可以再次投递。
840
+
841
+ 因此:
842
+
843
+ ```text
844
+ jobId 去重 ≠ 业务恰好执行一次
845
+ ```
846
+
847
+ 真实处理器仍必须使用数据库约束或状态条件保证幂等。
848
+
849
+ ---
850
+
851
+ ## 12. 第 7 步:实现公共分发 Worker
852
+
853
+ ### 本步目标
854
+
855
+ 在每个启用 Worker 的进程中,为 server-tasks 建立唯一一种公共 WorkerHost,并先支持基础设施冒烟任务。
856
+
857
+ ### 文件位置
858
+
859
+ 新建:
860
+
861
+ ```text
862
+ apps/server/src/modules/task-queue/task-queue.processor.ts
863
+ ```
864
+
865
+ ### 完整代码
866
+
867
+ ```ts
868
+ import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq'
869
+ import { Logger } from '@nestjs/common'
870
+ import { type Job, UnrecoverableError } from 'bullmq'
871
+ import { TASK_JOB_NAMES, TASK_QUEUE_CONCURRENCY, TASK_QUEUE_NAME } from './task-queue.constants'
872
+ import { QueueSmokePayloadSchema } from './task-queue.contracts'
873
+
874
+ @Processor(TASK_QUEUE_NAME, { concurrency: TASK_QUEUE_CONCURRENCY })
875
+ export class TaskQueueProcessor extends WorkerHost {
876
+ private readonly logger = new Logger(TaskQueueProcessor.name)
877
+
878
+ async process(job: Job): Promise<unknown> {
879
+ switch (job.name) {
880
+ case TASK_JOB_NAMES.QUEUE_SMOKE: {
881
+ const parsed = QueueSmokePayloadSchema.safeParse(job.data)
882
+
883
+ if (!parsed.success) {
884
+ throw new UnrecoverableError('Invalid payload for ' + job.name)
885
+ }
886
+
887
+ return {
888
+ marker: parsed.data.marker,
889
+ processedAt: new Date().toISOString(),
890
+ }
891
+ }
892
+
893
+ default:
894
+ throw new UnrecoverableError('Unsupported task name: ' + job.name)
895
+ }
896
+ }
897
+
898
+ @OnWorkerEvent('completed')
899
+ onCompleted(job: Job) {
900
+ this.logger.log({
901
+ event: 'queue_job_completed',
902
+ queueName: TASK_QUEUE_NAME,
903
+ jobId: job.id,
904
+ jobName: job.name,
905
+ attemptsMade: job.attemptsMade,
906
+ })
907
+ }
908
+
909
+ @OnWorkerEvent('failed')
910
+ onFailed(job: Job | undefined, error: Error) {
911
+ this.logger.error({
912
+ event: 'queue_job_failed',
913
+ queueName: TASK_QUEUE_NAME,
914
+ jobId: job?.id,
915
+ jobName: job?.name,
916
+ attemptsMade: job?.attemptsMade,
917
+ errorName: error.name,
918
+ })
919
+ }
920
+
921
+ @OnWorkerEvent('error')
922
+ onWorkerError(error: Error) {
923
+ this.logger.error({
924
+ event: 'queue_worker_error',
925
+ queueName: TASK_QUEUE_NAME,
926
+ errorName: error.name,
927
+ })
928
+ }
929
+ }
930
+ ```
931
+
932
+ ### 代码解释
933
+
934
+ - concurrency: 5 表示单个 NestJS 实例最多同时执行 5 个任务。
935
+ - 冒烟任务返回 marker 和处理时间,只验证完整链路。
936
+ - 未知任务名和非法 Payload 不会因为重试而自动变正确,所以使用 UnrecoverableError。
937
+ - 真实任务遇到数据库暂时不可用、第三方超时等可恢复错误时,不要吞掉异常,让 BullMQ 按公共策略重试。
938
+ - 日志只记录任务元数据,不记录完整 Payload。
939
+ - 第一版也不直接记录原始 error.message 和 error.stack,避免第三方 SDK、SQL 或连接错误把敏感信息带进日志。以后如需完整错误诊断,应先接入统一的日志脱敏函数。
940
+
941
+ ### 绝对不要这样写
942
+
943
+ ```ts
944
+ try {
945
+ await doRealWork()
946
+ } catch (error) {
947
+ return { ok: false }
948
+ }
949
+ ```
950
+
951
+ 这样 Worker 会把失败任务标记为 completed,BullMQ 不会重试。
952
+
953
+ 正确做法是让异常继续抛出:
954
+
955
+ ```ts
956
+ await doRealWork()
957
+ return { ok: true }
958
+ ```
959
+
960
+ ### 多实例并发
961
+
962
+ 如果部署 3 个 API 副本,并且每个副本都启用 Worker:
963
+
964
+ ```text
965
+ 总并发 = 3 × 5 = 15
966
+ ```
967
+
968
+ 这不是错误,但上线前必须清楚总并发会放大。
969
+
970
+ ---
971
+
972
+ ## 13. 第 8 步:增加 Worker 启动开关
973
+
974
+ ### 本步目标
975
+
976
+ 同一套代码既可以“投递并消费”,也可以只投递不消费,为以后拆 Worker 进程留出入口。
977
+
978
+ ### 文件位置
979
+
980
+ 新建:
981
+
982
+ ```text
983
+ apps/server/src/modules/task-queue/queue-worker.bootstrap.ts
984
+ ```
985
+
986
+ ### 完整代码
987
+
988
+ ```ts
989
+ import { BullRegistrar } from '@nestjs/bullmq'
990
+ import { Injectable, type OnApplicationBootstrap } from '@nestjs/common'
991
+ import { ConfigService } from '@nestjs/config'
992
+
993
+ @Injectable()
994
+ export class QueueWorkerBootstrap implements OnApplicationBootstrap {
995
+ private registered = false
996
+
997
+ constructor(
998
+ private readonly config: ConfigService,
999
+ private readonly registrar: BullRegistrar,
1000
+ ) {}
1001
+
1002
+ onApplicationBootstrap() {
1003
+ const workerEnabled = this.config.getOrThrow<boolean>('queue.workerEnabled')
1004
+
1005
+ if (!workerEnabled || this.registered) {
1006
+ return
1007
+ }
1008
+
1009
+ this.registrar.register()
1010
+ this.registered = true
1011
+ }
1012
+ }
1013
+ ```
1014
+
1015
+ ### 代码解释
1016
+
1017
+ - @nestjs/bullmq 默认会自动启动所有 Processor。
1018
+ - 下一步会开启 manualRegistration,改为由 QueueWorkerBootstrap 决定是否启动。
1019
+ - onApplicationBootstrap 发生在模块初始化之后,配置已经可读取。
1020
+ - 不要在 @Processor 装饰器参数中直接读取 .env。
1021
+
1022
+ ---
1023
+
1024
+ ## 14. 第 9 步:组装 TaskQueueModule
1025
+
1026
+ ### 本步目标
1027
+
1028
+ 统一注册 BullMQ、默认队列、公共 Service 和 Worker。
1029
+
1030
+ ### 文件位置
1031
+
1032
+ 新建:
1033
+
1034
+ ```text
1035
+ apps/server/src/modules/task-queue/task-queue.module.ts
1036
+ ```
1037
+
1038
+ ### 完整代码
1039
+
1040
+ ```ts
1041
+ import { BullModule } from '@nestjs/bullmq'
1042
+ import { Module } from '@nestjs/common'
1043
+ import { ConfigModule, ConfigService } from '@nestjs/config'
1044
+ import type { QueueConfig } from '@/config/queue.config'
1045
+ import { QueueWorkerBootstrap } from './queue-worker.bootstrap'
1046
+ import { TASK_QUEUE_NAME } from './task-queue.constants'
1047
+ import { TaskQueueProcessor } from './task-queue.processor'
1048
+ import { TaskQueueService } from './task-queue.service'
1049
+
1050
+ @Module({
1051
+ imports: [
1052
+ BullModule.forRootAsync({
1053
+ imports: [ConfigModule],
1054
+ inject: [ConfigService],
1055
+ useFactory: (config: ConfigService) => {
1056
+ const queue = config.getOrThrow<QueueConfig>('queue')
1057
+
1058
+ return {
1059
+ connection: {
1060
+ host: queue.host,
1061
+ port: queue.port,
1062
+ username: queue.username,
1063
+ password: queue.password,
1064
+ db: queue.db,
1065
+ connectTimeout: 10_000,
1066
+ },
1067
+ prefix: queue.prefix,
1068
+ defaultJobOptions: {
1069
+ attempts: queue.defaultAttempts,
1070
+ backoff: {
1071
+ type: 'exponential',
1072
+ delay: queue.backoffDelayMs,
1073
+ },
1074
+ removeOnComplete: {
1075
+ age: queue.completedAgeSeconds,
1076
+ count: queue.completedCount,
1077
+ },
1078
+ removeOnFail: {
1079
+ age: queue.failedAgeSeconds,
1080
+ count: queue.failedCount,
1081
+ },
1082
+ },
1083
+ }
1084
+ },
1085
+ extraOptions: {
1086
+ manualRegistration: true,
1087
+ },
1088
+ }),
1089
+ BullModule.registerQueue({
1090
+ name: TASK_QUEUE_NAME,
1091
+ }),
1092
+ ],
1093
+ providers: [QueueWorkerBootstrap, TaskQueueProcessor, TaskQueueService],
1094
+ exports: [TaskQueueService],
1095
+ })
1096
+ export class TaskQueueModule {}
1097
+ ```
1098
+
1099
+ ### 连接策略解释
1100
+
1101
+ 这份 connection 必须是普通配置对象,不能是现有 Session ioredis 实例。
1102
+
1103
+ 第一版单进程 Queue + Worker 建议:
1104
+
1105
+ - 设置 connectTimeout。
1106
+ - 不设置 commandTimeout。
1107
+ - 不设置 enableOfflineQueue。
1108
+ - 不显式设置 maxRetriesPerRequest。
1109
+
1110
+ BullMQ 会为 Worker 的阻塞连接自动使用适合长期消费的 maxRetriesPerRequest: null;普通 Queue 连接保留 ioredis 默认行为。
1111
+
1112
+ 如果以后真正拆成独立 Producer 和 Worker 进程,再进一步配置:
1113
+
1114
+ - API Producer:maxRetriesPerRequest: 1,快速失败。
1115
+ - Worker:maxRetriesPerRequest: null,持续重连。
1116
+
1117
+ 不要在当前共享配置里显式写 maxRetriesPerRequest: 1。BullMQ 虽然会为 Worker 覆盖它,但会输出警告。
1118
+
1119
+ ### manualRegistration 的作用范围
1120
+
1121
+ manualRegistration 配置在 BullModule.forRootAsync 上,是当前 Nest 应用中 BullMQ 根模块级的全局开关,不只控制 server-tasks。调用 BullRegistrar.register() 时,会注册应用内所有 BullMQ Processor 和 QueueEvents Listener。
1122
+
1123
+ 因此第一版的 QUEUE_WORKER_ENABLED 是“当前进程是否启动全部 BullMQ 消费者”的进程级开关。未来新增物理队列时,也会受到同一个开关控制;不要为了单独控制某个队列再创建第二套 BullModule.forRoot 或 forRootAsync 根配置。若以后确实需要不同队列独立启停,优先拆成独立 Worker 应用或重新设计更细粒度的消费者开关。
1124
+
1125
+ ### 默认任务策略解释
1126
+
1127
+ - attempts: 3 表示总共最多执行 3 次,不是“首次加重试 3 次”。
1128
+ - backoff 使用指数退避,避免外部服务故障时持续猛打。
1129
+ - removeOnComplete 同时限制保留时间和数量。
1130
+ - removeOnFail 保留更久,方便排查,但同样设置数量上限。
1131
+ - 没有保留上限会让 Redis 数据持续增长。
1132
+
1133
+ ### 为什么暂时不加 @Global
1134
+
1135
+ 需要投递任务的业务模块应显式导入 TaskQueueModule:
1136
+
1137
+ ```ts
1138
+ @Module({
1139
+ imports: [TaskQueueModule],
1140
+ })
1141
+ export class SomeBusinessModule {}
1142
+ ```
1143
+
1144
+ 这样依赖更清晰,测试时也更容易看出模块缺少什么。
1145
+
1146
+ ### 不需要的旧组件
1147
+
1148
+ BullMQ 当前版本不需要额外创建 QueueScheduler。不要复制旧教程中的 QueueScheduler 或 @Process 写法。
1149
+
1150
+ ---
1151
+
1152
+ ## 15. 第 10 步:接入 AppModule
1153
+
1154
+ ### 本步目标
1155
+
1156
+ 让应用启动时创建 Queue,并根据开关决定是否启动 Worker。
1157
+
1158
+ ### 文件位置
1159
+
1160
+ 修改:
1161
+
1162
+ ```text
1163
+ apps/server/src/app.module.ts
1164
+ ```
1165
+
1166
+ ### 精确修改
1167
+
1168
+ 增加导入:
1169
+
1170
+ ```ts
1171
+ import { TaskQueueModule } from './modules/task-queue/task-queue.module'
1172
+ ```
1173
+
1174
+ 在 imports 中加入:
1175
+
1176
+ ```ts
1177
+ TaskQueueModule,
1178
+ ```
1179
+
1180
+ 建议放在 RedisModule 之后、具体业务模块之前,便于阅读:
1181
+
1182
+ ```ts
1183
+ imports: [
1184
+ ConfigModule.forRoot(...),
1185
+ DatabaseModule,
1186
+ RedisModule.forRootAsync(...),
1187
+ TaskQueueModule,
1188
+ TestRedisModule,
1189
+ TestDbModule,
1190
+ AuthModule,
1191
+ UploadModule,
1192
+ ],
1193
+ ```
1194
+
1195
+ TaskQueueModule 只需在 AppModule 中接入一次以确保 Worker 启动。需要投递任务的具体业务模块仍显式导入它,以获得 TaskQueueService。
1196
+
1197
+ ---
1198
+
1199
+ ## 16. 第 11 步:开启优雅停机
1200
+
1201
+ ### 本步目标
1202
+
1203
+ 让 NestJS 在收到 SIGTERM 或 SIGINT 时关闭 Queue 和 Worker,不再领取新任务,并等待当前任务正常结束。
1204
+
1205
+ ### 文件位置
1206
+
1207
+ 修改:
1208
+
1209
+ ```text
1210
+ apps/server/src/main.ts
1211
+ ```
1212
+
1213
+ ### 精确修改
1214
+
1215
+ 在 NestFactory.create 完成后增加:
1216
+
1217
+ ```ts
1218
+ app.enableShutdownHooks()
1219
+ ```
1220
+
1221
+ 建议位置:
1222
+
1223
+ ```ts
1224
+ const app = await NestFactory.create<NestFastifyApplication>(AppModule, createFastifyAdapter(), {
1225
+ logger: new ConsoleLogger({
1226
+ json: process.env.NODE_ENV === 'production',
1227
+ colors: process.env.NODE_ENV === 'development',
1228
+ }),
1229
+ })
1230
+
1231
+ app.enableShutdownHooks()
1232
+
1233
+ app.enableCors({
1234
+ // 保留当前配置
1235
+ })
1236
+ ```
1237
+
1238
+ ### 代码解释
1239
+
1240
+ - @nestjs/bullmq 会关闭自己注册的 Queue。
1241
+ - Worker.close() 会停止领取新 Job,并等待当前 Job 结束。
1242
+ - 部署平台的 termination grace period 必须大于普通任务的最长执行时间。
1243
+ - 不需要再重复编写 process.on('SIGTERM')。
1244
+
1245
+ 如果进程最终仍被强杀,正在执行的任务之后可能被判定为 stalled 并再次执行,这也是处理器必须幂等的原因。
1246
+
1247
+ ---
1248
+
1249
+ ## 17. 第 12 步:增加单元测试
1250
+
1251
+ ### 本步目标
1252
+
1253
+ 不连接 Redis,先验证公共 Service 和 Worker 分发规则。
1254
+
1255
+ ### 文件位置
1256
+
1257
+ 新建:
1258
+
1259
+ ```text
1260
+ apps/server/test/task-queue.service.spec.ts
1261
+ apps/server/test/task-queue.processor.spec.ts
1262
+ ```
1263
+
1264
+ 注意:当前 Server 的 Vite+ 测试配置只匹配 test/\*_/_.spec.ts。不要把测试放在 src 目录。
1265
+
1266
+ ### TaskQueueService 至少测试
1267
+
1268
+ - 合法任务被传给 queue.add。
1269
+ - jobId 和 delayMs 被正确转换。
1270
+ - 返回值不暴露完整 Job。
1271
+ - jobId 含冒号时拒绝。
1272
+ - delayMs 为负数或非安全整数时拒绝。
1273
+ - Payload 不合法时拒绝。
1274
+
1275
+ 测试可直接模拟 Queue:
1276
+
1277
+ ```ts
1278
+ import type { Queue } from 'bullmq'
1279
+ import { describe, expect, it, vi } from 'vite-plus/test'
1280
+ import { TASK_JOB_NAMES } from '@/modules/task-queue/task-queue.constants'
1281
+ import { TaskQueueService } from '@/modules/task-queue/task-queue.service'
1282
+
1283
+ describe('TaskQueueService', () => {
1284
+ it('enqueues a typed task', async () => {
1285
+ const add = vi.fn().mockResolvedValue({
1286
+ id: 'queue-smoke-1',
1287
+ })
1288
+ const queue = { add } as unknown as Queue
1289
+ const service = new TaskQueueService(queue)
1290
+
1291
+ const result = await service.enqueue(
1292
+ TASK_JOB_NAMES.QUEUE_SMOKE,
1293
+ {
1294
+ marker: 'test',
1295
+ enqueuedAt: new Date().toISOString(),
1296
+ },
1297
+ {
1298
+ jobId: 'queue-smoke-1',
1299
+ delayMs: 100,
1300
+ },
1301
+ )
1302
+
1303
+ expect(add).toHaveBeenCalledOnce()
1304
+ expect(result).toEqual({
1305
+ queueName: 'server-tasks',
1306
+ jobId: 'queue-smoke-1',
1307
+ name: TASK_JOB_NAMES.QUEUE_SMOKE,
1308
+ })
1309
+ })
1310
+ })
1311
+ ```
1312
+
1313
+ ### TaskQueueProcessor 至少测试
1314
+
1315
+ - 合法冒烟任务返回 marker 和 processedAt。
1316
+ - 非法 Payload 抛出 UnrecoverableError。
1317
+ - 未知任务名抛出 UnrecoverableError。
1318
+ - 普通业务异常不会被吞掉。
1319
+
1320
+ ### 验证
1321
+
1322
+ ```powershell
1323
+ vp run --filter server test
1324
+ ```
1325
+
1326
+ ---
1327
+
1328
+ ## 18. 第 13 步:增加真实 Redis 冒烟测试
1329
+
1330
+ ### 本步目标
1331
+
1332
+ 证明真正的“投递 → 消费 → 完成”链路可用。只 Mock Queue 不能证明 Redis 和 Worker 已正确接通。
1333
+
1334
+ ### 文件位置
1335
+
1336
+ 新建:
1337
+
1338
+ ```text
1339
+ apps/server/test/task-queue.integration.spec.ts
1340
+ ```
1341
+
1342
+ ### 为什么默认跳过
1343
+
1344
+ 这个文件仍使用 `.spec.ts` 后缀,所以普通 `vp run --filter server test` 和根目录 `vp run ready` 都能发现它。为了避免没有启动 Redis 时单元测试整体失败,真实 Redis 测试必须通过 `RUN_QUEUE_INTEGRATION=true` 显式开启;未设置时使用 `describe.skip`。
1345
+
1346
+ ### 测试流程
1347
+
1348
+ ```text
1349
+ 生成唯一测试 Prefix
1350
+ ↓
1351
+ 创建 ConfigModule + TaskQueueModule
1352
+ ↓
1353
+ 等待 TestingModule 完成初始化
1354
+ ↓
1355
+ 创建 QueueEvents 并等待 ready
1356
+ ↓
1357
+ 通过 TaskQueueService 投递 system.queue-smoke.v1
1358
+ ↓
1359
+ 根据 jobId 取得 Job
1360
+ ↓
1361
+ 最多等待 5~10 秒
1362
+ ↓
1363
+ 断言状态为 completed,并校验返回 marker
1364
+ ↓
1365
+ 关闭 QueueEvents
1366
+ ↓
1367
+ 关闭 TestingModule
1368
+ ```
1369
+
1370
+ 测试 Prefix 必须唯一,例如:
1371
+
1372
+ ```text
1373
+ bubbles:test:<随机UUID>:queue
1374
+ ```
1375
+
1376
+ 不要让集成测试连接开发或生产 Prefix 后执行 drain、clean 或 obliterate。
1377
+
1378
+ ### 完整测试文件
1379
+
1380
+ 因为公共模块使用了 manualRegistration: true,只执行 compile() 不会启动 Worker。测试必须显式执行 testingModule.init(),让 QueueWorkerBootstrap 的 onApplicationBootstrap 得到调用。
1381
+
1382
+ 同时必须使用 ConfigModule.forRoot 注册 queueConfig,并且要在编译模块之前设置唯一测试 Prefix。下面是一份可以直接复制的完整文件:
1383
+
1384
+ ```ts
1385
+ import { randomUUID } from 'node:crypto'
1386
+ import { getQueueToken } from '@nestjs/bullmq'
1387
+ import { ConfigModule } from '@nestjs/config'
1388
+ import { Test, type TestingModule } from '@nestjs/testing'
1389
+ import { type Queue, QueueEvents } from 'bullmq'
1390
+ import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'
1391
+ import queueConfig from '@/config/queue.config'
1392
+ import { TaskQueueModule } from '@/modules/task-queue/task-queue.module'
1393
+ import { TaskQueueService } from '@/modules/task-queue/task-queue.service'
1394
+ import { TASK_JOB_NAMES, TASK_QUEUE_NAME } from '@/modules/task-queue/task-queue.constants'
1395
+ import { ENV_ARR } from '@/utils/env-arr'
1396
+
1397
+ const describeQueueIntegration =
1398
+ process.env.RUN_QUEUE_INTEGRATION === 'true' ? describe : describe.skip
1399
+
1400
+ describeQueueIntegration('TaskQueue Redis integration', () => {
1401
+ let testingModule: TestingModule | undefined
1402
+ let queue: Queue | undefined
1403
+ let queueEvents: QueueEvents | undefined
1404
+ let taskQueueService: TaskQueueService | undefined
1405
+
1406
+ const previousPrefix = process.env.QUEUE_PREFIX
1407
+ const previousWorkerEnabled = process.env.QUEUE_WORKER_ENABLED
1408
+ const uniquePrefix = 'bubbles:test:' + randomUUID() + ':queue'
1409
+
1410
+ beforeAll(async () => {
1411
+ if (process.env.NODE_ENV === 'production') {
1412
+ throw new Error('Queue integration test must not run in production')
1413
+ }
1414
+
1415
+ process.env.QUEUE_PREFIX = uniquePrefix
1416
+ process.env.QUEUE_WORKER_ENABLED = 'true'
1417
+
1418
+ testingModule = await Test.createTestingModule({
1419
+ imports: [
1420
+ ConfigModule.forRoot({
1421
+ isGlobal: true,
1422
+ envFilePath: ENV_ARR,
1423
+ load: [queueConfig],
1424
+ }),
1425
+ TaskQueueModule,
1426
+ ],
1427
+ }).compile()
1428
+
1429
+ // 必须调用。compile() 本身不会触发
1430
+ // QueueWorkerBootstrap.onApplicationBootstrap()
1431
+ await testingModule.init()
1432
+
1433
+ queue = testingModule.get<Queue>(getQueueToken(TASK_QUEUE_NAME))
1434
+ taskQueueService = testingModule.get(TaskQueueService)
1435
+
1436
+ // QueueEvents 必须与 Queue 使用完全相同的
1437
+ // connection、DB 和 prefix。
1438
+ queueEvents = new QueueEvents(TASK_QUEUE_NAME, {
1439
+ connection: queue.opts.connection,
1440
+ prefix: queue.opts.prefix,
1441
+ })
1442
+
1443
+ // 必须先 ready,再投递,避免错过完成事件。
1444
+ await queueEvents.waitUntilReady()
1445
+ })
1446
+
1447
+ it('completes a Job through real Redis', async () => {
1448
+ if (!queue || !queueEvents || !taskQueueService) {
1449
+ throw new Error('Queue integration test was not initialized')
1450
+ }
1451
+
1452
+ const queued = await taskQueueService.enqueue(
1453
+ TASK_JOB_NAMES.QUEUE_SMOKE,
1454
+ {
1455
+ marker: 'redis-smoke',
1456
+ enqueuedAt: new Date().toISOString(),
1457
+ },
1458
+ {
1459
+ jobId: 'queue-smoke-' + randomUUID(),
1460
+ },
1461
+ )
1462
+
1463
+ const job = await queue.getJob(queued.jobId)
1464
+
1465
+ if (!job) {
1466
+ throw new Error('Smoke Job was not found')
1467
+ }
1468
+
1469
+ const result = await job.waitUntilFinished(queueEvents, 10_000)
1470
+
1471
+ expect(result.marker).toBe('redis-smoke')
1472
+ }, 15_000)
1473
+
1474
+ afterAll(async () => {
1475
+ try {
1476
+ await queueEvents?.close()
1477
+
1478
+ if (queue) {
1479
+ if (queue.opts.prefix !== uniquePrefix) {
1480
+ throw new Error('Refusing to clean a non-test Queue prefix')
1481
+ }
1482
+
1483
+ // 这里只清理由随机 Prefix 创建的测试队列。
1484
+ await queue.obliterate({ force: true })
1485
+ }
1486
+ } finally {
1487
+ try {
1488
+ await testingModule?.close()
1489
+ } finally {
1490
+ if (previousPrefix === undefined) {
1491
+ delete process.env.QUEUE_PREFIX
1492
+ } else {
1493
+ process.env.QUEUE_PREFIX = previousPrefix
1494
+ }
1495
+
1496
+ if (previousWorkerEnabled === undefined) {
1497
+ delete process.env.QUEUE_WORKER_ENABLED
1498
+ } else {
1499
+ process.env.QUEUE_WORKER_ENABLED = previousWorkerEnabled
1500
+ }
1501
+ }
1502
+ }
1503
+ })
1504
+ })
1505
+ ```
1506
+
1507
+ 这里的 obliterate 受到随机测试 Prefix 的显式保护,只清理本测试创建的键。不得删除 Prefix 检查,也不得把这段清理代码用于开发或生产队列。
1508
+
1509
+ 如果不关闭 QueueEvents 和 TestingModule,Worker 的阻塞连接会让测试进程无法退出。
1510
+
1511
+ 不要只导入裸 ConfigModule。否则 queueConfig 不会注册,TaskQueueModule 中的 getOrThrow('queue') 会失败。
1512
+
1513
+ 这个测试必须连接本地或专用测试 Redis,不能加载生产 Redis 凭据。唯一 Prefix 只能防止键名冲突,不能把生产 Redis 自动变成测试环境。
1514
+
1515
+ 不要把 ioredis-mock 当作最终验收。BullMQ 依赖 Lua、Streams 和阻塞命令,模拟 Redis 通常不完整。
1516
+
1517
+ ### 手工验证
1518
+
1519
+ ```powershell
1520
+ docker compose up -d redis
1521
+ $env:RUN_QUEUE_INTEGRATION = 'true'
1522
+ try {
1523
+ vp run --filter server test
1524
+ } finally {
1525
+ Remove-Item Env:RUN_QUEUE_INTEGRATION -ErrorAction SilentlyContinue
1526
+ }
1527
+ vp run --filter server build
1528
+ ```
1529
+
1530
+ 未显式设置 RUN_QUEUE_INTEGRATION 时,普通 `vp run --filter server test` 和 `vp run ready` 会跳过这一个真实 Redis 测试,但仍会执行其他单元测试。
1531
+
1532
+ 如需查看键,使用 SCAN,不要使用 KEYS:
1533
+
1534
+ ```powershell
1535
+ $queueRedisPassword = Read-Host '请输入本地 Redis 密码'
1536
+ docker compose exec -e REDISCLI_AUTH=$queueRedisPassword redis redis-cli -n 1 --scan --pattern 'bubbles:development:queue:server-tasks:*'
1537
+ Remove-Variable queueRedisPassword
1538
+ ```
1539
+
1540
+ ---
1541
+
1542
+ ## 19. 第 14 步:以后新业务任务怎么接入
1543
+
1544
+ 每增加一种任务,都按固定顺序操作。
1545
+
1546
+ ### 19.1 增加任务名
1547
+
1548
+ ```ts
1549
+ export const TASK_JOB_NAMES = {
1550
+ QUEUE_SMOKE: 'system.queue-smoke.v1',
1551
+ SOME_FUTURE_TASK: 'some-domain.some-action.v1',
1552
+ } as const
1553
+ ```
1554
+
1555
+ 不要直接复制这个占位任务名到生产代码,应换成真实领域和动作。
1556
+
1557
+ ### 19.2 增加 Zod Schema
1558
+
1559
+ ```ts
1560
+ export const SomeFutureTaskPayloadSchema = z.object({
1561
+ resourceId: z.string().uuid(),
1562
+ requestedBy: z.string().uuid(),
1563
+ })
1564
+ ```
1565
+
1566
+ ### 19.3 加入 TaskPayloadMap 和 Schema Map
1567
+
1568
+ ```ts
1569
+ export interface TaskPayloadMap {
1570
+ [TASK_JOB_NAMES.QUEUE_SMOKE]: z.infer<typeof QueueSmokePayloadSchema>
1571
+ [TASK_JOB_NAMES.SOME_FUTURE_TASK]: z.infer<typeof SomeFutureTaskPayloadSchema>
1572
+ }
1573
+
1574
+ export const TASK_PAYLOAD_SCHEMAS = {
1575
+ [TASK_JOB_NAMES.QUEUE_SMOKE]: QueueSmokePayloadSchema,
1576
+ [TASK_JOB_NAMES.SOME_FUTURE_TASK]: SomeFutureTaskPayloadSchema,
1577
+ } satisfies Record<TaskName, z.ZodType>
1578
+ ```
1579
+
1580
+ ### 19.4 业务模块导入 TaskQueueModule
1581
+
1582
+ ```ts
1583
+ @Module({
1584
+ imports: [TaskQueueModule],
1585
+ providers: [SomeBusinessService],
1586
+ })
1587
+ export class SomeBusinessModule {}
1588
+ ```
1589
+
1590
+ ### 19.5 业务 Service 只负责投递
1591
+
1592
+ ```ts
1593
+ constructor(
1594
+ private readonly taskQueue: TaskQueueService,
1595
+ ) {}
1596
+
1597
+ async startFutureTask(
1598
+ resourceId: string,
1599
+ userId: string,
1600
+ ) {
1601
+ return this.taskQueue.enqueue(
1602
+ TASK_JOB_NAMES.SOME_FUTURE_TASK,
1603
+ {
1604
+ resourceId,
1605
+ requestedBy: userId,
1606
+ },
1607
+ {
1608
+ jobId: 'some-future-task-' + resourceId,
1609
+ },
1610
+ )
1611
+ }
1612
+ ```
1613
+
1614
+ ### 19.6 在唯一 Processor 中增加分发
1615
+
1616
+ 第一版可在 switch 中增加分支,并把真实逻辑委托给单独的 Handler Service。
1617
+
1618
+ ```ts
1619
+ case TASK_JOB_NAMES.SOME_FUTURE_TASK: {
1620
+ const parsed = SomeFutureTaskPayloadSchema.safeParse(
1621
+ job.data,
1622
+ )
1623
+
1624
+ if (!parsed.success) {
1625
+ throw new UnrecoverableError(
1626
+ 'Invalid payload for ' + job.name,
1627
+ )
1628
+ }
1629
+
1630
+ return this.someFutureTaskHandler.handle(parsed.data)
1631
+ }
1632
+ ```
1633
+
1634
+ 同时从 contracts 文件导入 SomeFutureTaskPayloadSchema;UnrecoverableError 已在公共 Processor 中导入。不要把未经校验的 job.data 直接传给 Handler。
1635
+
1636
+ Processor 负责:
1637
+
1638
+ - 路由
1639
+ - Payload 运行时校验
1640
+ - 公共日志
1641
+ - 决定永久错误还是可重试错误
1642
+
1643
+ Handler 负责:
1644
+
1645
+ - 读取最新数据库状态
1646
+ - 幂等判断
1647
+ - 执行真实业务
1648
+ - 保存结果
1649
+
1650
+ ### 19.7 为任务补测试
1651
+
1652
+ 每个真实任务至少测试:
1653
+
1654
+ - 首次执行成功
1655
+ - 临时失败会重试
1656
+ - 永久错误不重试
1657
+ - 同一任务重复执行仍安全
1658
+ - 已完成状态重复执行会幂等返回
1659
+ - 日志不泄露敏感 Payload
1660
+
1661
+ ---
1662
+
1663
+ ## 20. 生产环境必须知道的边界
1664
+
1665
+ ### 20.1 数据库与 Redis 不是一个事务
1666
+
1667
+ 以下风险真实存在:
1668
+
1669
+ ```text
1670
+ PostgreSQL 提交成功
1671
+ ↓
1672
+ 进程在 queue.add 前崩溃
1673
+ ↓
1674
+ 数据库有记录,但任务没有进入 Redis
1675
+ ```
1676
+
1677
+ 第一版基础队列先接受这个边界。
1678
+
1679
+ 以后某类任务如果“绝对不能漏”,应使用 Transactional Outbox:
1680
+
1681
+ ```text
1682
+ 同一个数据库事务
1683
+ ├── 写业务数据
1684
+ └── 写 outbox 事件
1685
+
1686
+ 独立发布器
1687
+ └── 把 outbox 可靠投递到 BullMQ
1688
+ ```
1689
+
1690
+ 不要把当前方案描述成恰好一次或强一致。
1691
+
1692
+ ### 20.2 Redis 内存策略
1693
+
1694
+ BullMQ 使用的 Redis 必须采用:
1695
+
1696
+ ```text
1697
+ maxmemory-policy noeviction
1698
+ ```
1699
+
1700
+ 当前 Redis 默认策略通常就是 noeviction,但生产部署必须显式检查:
1701
+
1702
+ ```text
1703
+ CONFIG GET maxmemory-policy
1704
+ ```
1705
+
1706
+ 如果 Redis 在内存紧张时自动淘汰 BullMQ 键,队列状态可能损坏。
1707
+
1708
+ ### 20.3 持久化
1709
+
1710
+ 当前 Docker Redis 已开启 AOF,这是正确基础。生产环境还要考虑:
1711
+
1712
+ - AOF 策略
1713
+ - 磁盘容量
1714
+ - 备份
1715
+ - 故障恢复演练
1716
+ - Redis 高可用
1717
+
1718
+ ### 20.4 API 与 Worker 同进程
1719
+
1720
+ 第一版同进程是合理的,部署简单。
1721
+
1722
+ 出现以下情况时再拆:
1723
+
1724
+ - Worker 抢占 API CPU 或内存。
1725
+ - API 和 Worker 需要不同副本数。
1726
+ - Job 执行时间很长。
1727
+ - 需要 API 快速失败、Worker 持续重连的独立 Redis 策略。
1728
+ - 需要独立发布和故障隔离。
1729
+
1730
+ 拆分后 TaskQueueService 的业务调用方式不应变化。
1731
+
1732
+ ### 20.5 CPU 密集任务
1733
+
1734
+ CPU 密集任务会阻塞 Node.js 事件循环,Worker 可能无法及时续锁,导致 Job 被判定 stalled 并重复执行。
1735
+
1736
+ 这类任务应使用:
1737
+
1738
+ - 独立 Worker 进程
1739
+ - BullMQ 沙箱处理器
1740
+ - Worker Thread
1741
+ - 专门的计算服务
1742
+
1743
+ ### 20.6 监控
1744
+
1745
+ 第一版至少记录:
1746
+
1747
+ - Job completed
1748
+ - Job failed
1749
+ - Worker error
1750
+ - jobId
1751
+ - jobName
1752
+ - attemptsMade
1753
+
1754
+ 后续生产监控应增加:
1755
+
1756
+ - waiting 数量
1757
+ - active 数量
1758
+ - failed 数量
1759
+ - stalled 次数
1760
+ - 最老等待任务时长
1761
+ - 单任务耗时分布
1762
+
1763
+ Bull Board 可以以后作为受保护的内部管理页面接入,本次不做。
1764
+
1765
+ ---
1766
+
1767
+ ## 21. 常见故障排查
1768
+
1769
+ | 现象 | 常见原因 | 处理方向 |
1770
+ | -------------------------- | ---------------------------------------------------------- | ----------------------------------------------- |
1771
+ | 投递成功但没有消费 | TaskQueueModule 未导入、Worker 开关关闭或 Processor 未注册 | 检查 AppModule、QUEUE_WORKER_ENABLED 和启动日志 |
1772
+ | 任务失败却显示 completed | Worker catch 后返回了错误对象 | 让异常继续抛出 |
1773
+ | 同一任务执行两次 | 至少一次语义,处理器缺少幂等 | 增加数据库唯一约束或状态条件 |
1774
+ | 未知任务不断重试 | 未使用 UnrecoverableError | 把未知协议视为永久错误 |
1775
+ | Redis 内存持续增长 | 未配置保留上限或 Payload 太大 | 检查 removeOnComplete、removeOnFail 和 Payload |
1776
+ | Session 键和队列键混在一起 | DB 或 Prefix 配错 | 本地 Session DB 0、Queue DB 1 |
1777
+ | 测试执行完不退出 | QueueEvents 或 TestingModule 未关闭 | 在 afterAll 关闭资源 |
1778
+ | Redis Cluster 报 DB 错误 | Cluster 只能使用 DB 0 | 改为 DB 0 和独立 Prefix |
1779
+ | 自定义 jobId 报错 | jobId 含冒号 | 改用连字符 |
1780
+ | BullMQ 键结构异常 | 使用了 ioredis keyPrefix | 删除 keyPrefix,使用 BullMQ prefix |
1781
+ | Worker 出现重试配置警告 | 共享配置显式设置 maxRetriesPerRequest: 1 | 第一版共享配置中省略该项 |
1782
+ | HTTP 一直等业务结果 | 把入队完成误解为执行完成 | HTTP 返回 202 和 jobId |
1783
+ | Job 查不到 | 完成保留策略太短,或测试 Prefix 不一致 | 检查保留参数和 Prefix |
1784
+
1785
+ ---
1786
+
1787
+ ## 22. 最终验收顺序
1788
+
1789
+ 按下面顺序执行,某一步失败就先停下来修正:
1790
+
1791
+ ```powershell
1792
+ docker compose up -d redis
1793
+ vp install
1794
+ vp check
1795
+ vp run --filter server test
1796
+ $env:RUN_QUEUE_INTEGRATION = 'true'
1797
+ try {
1798
+ vp run --filter server test
1799
+ } finally {
1800
+ Remove-Item Env:RUN_QUEUE_INTEGRATION -ErrorAction SilentlyContinue
1801
+ }
1802
+ vp run --filter server build
1803
+ vp run ready
1804
+ ```
1805
+
1806
+ 第一次 `vp run --filter server test` 验证普通测试,并确认真实 Redis 测试默认跳过;设置 RUN_QUEUE_INTEGRATION 后的第二次测试才验证真实 Redis 链路。finally 会清理环境开关,确保后面的 `vp run ready` 恢复默认行为。
1807
+
1808
+ ### 验收清单
1809
+
1810
+ - [ ] 使用 @nestjs/bullmq,不是 @nestjs/bull。
1811
+ - [ ] @nestjs/bullmq 和 bullmq 已加入 workspace catalog。
1812
+ - [ ] Session Redis DB 0,开发队列 DB 1。
1813
+ - [ ] 没有复用 @nestjs-modules/ioredis 创建的 Session Redis Client。
1814
+ - [ ] 没有复制 commandTimeout 和 enableOfflineQueue 到 BullMQ。
1815
+ - [ ] 共享连接没有显式设置 maxRetriesPerRequest: 1。
1816
+ - [ ] 队列名和任务名集中定义。
1817
+ - [ ] 业务模块只调用 TaskQueueService。
1818
+ - [ ] Payload 同时有 TypeScript 类型和 Zod 校验。
1819
+ - [ ] 默认重试、指数退避和保留上限已生效。
1820
+ - [ ] 每个进程对一个物理队列只注册一种能识别全部任务名的公共 WorkerHost。
1821
+ - [ ] 未知任务名和非法 Payload 不会无意义重试。
1822
+ - [ ] Worker 不吞异常。
1823
+ - [ ] Worker 日志不打印完整 Payload。
1824
+ - [ ] QUEUE_WORKER_ENABLED=false 时仍可投递,但当前进程不消费。
1825
+ - [ ] app.enableShutdownHooks() 已开启。
1826
+ - [ ] 单元测试通过。
1827
+ - [ ] 真实 Redis 完成投递、消费和完成全链路。
1828
+ - [ ] vp run ready 通过。
1829
+ - [ ] 没有实现任何真实业务任务。
1830
+ - [ ] 上传清理仅存在于附加方案文档,没有实现业务代码。
1831
+
1832
+ ---
1833
+
1834
+ ## 附录 A:未来上传清理如何接入
1835
+
1836
+ 详细方案已经拆分到独立文件:[上传清理.md](./上传清理.md)。该文件只说明未来如何复用公共队列,不属于当前实施内容。
1837
+
1838
+ 以后可能增加:
1839
+
1840
+ ```text
1841
+ upload.multipart-expire.v1
1842
+ upload.session-reconcile.v1
1843
+ ```
1844
+
1845
+ Payload 只传上传会话 ID:
1846
+
1847
+ ```json
1848
+ {
1849
+ "uploadSessionId": "..."
1850
+ }
1851
+ ```
1852
+
1853
+ 未来 Handler 应遵守:
1854
+
1855
+ 1. 根据 uploadSessionId 重新读取数据库。
1856
+ 2. 校验会话当前状态和过期时间。
1857
+ 3. 通过状态条件更新或租约领取处理权。
1858
+ 4. 已完成、已取消或已过期时幂等返回。
1859
+ 5. 不相信 Payload 里的 Bucket、Object Key 或 MinIO UploadId。
1860
+ 6. 从数据库读取真实存储信息。
1861
+ 7. 对象存储操作成功后再更新最终状态。
1862
+ 8. 能安全应对同一 Job 重复执行。
1863
+
1864
+ 要自动周期性扫描过期上传时,还需要另外选择调度方式,例如 NestJS Schedule 或 BullMQ Job Scheduler。本次不要提前安装调度依赖,也不要实现上传清理。
1865
+
1866
+ ---
1867
+
1868
+ ## 附录 B:本次建议的实施边界
1869
+
1870
+ 本轮真正执行时,只做:
1871
+
1872
+ ```text
1873
+ 公共配置
1874
+ 公共投递
1875
+ 公共消费
1876
+ 冒烟验证
1877
+ ```
1878
+
1879
+ 不要顺手加入:
1880
+
1881
+ ```text
1882
+ 上传清理
1883
+ 任务管理后台
1884
+ 任务进度接口
1885
+ 定时扫描
1886
+ Outbox
1887
+ 邮件或通知业务
1888
+ ```
1889
+
1890
+ 先让公共队列稳定跑通,再按真实业务逐个接任务,后端结构会更清楚,也更容易定位问题。