create-bubbles 0.1.25 → 0.1.27

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 (622) 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/.agents/rules//351/234/200/346/261/202/344/270/216/350/256/276/350/256/241/346/226/207/346/241/243.md +50 -0
  41. package/template-vp-monorepo-react-nestjs/.codegraph/.gitignore +5 -0
  42. 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//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225/PRD.md +121 -0
  43. 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//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225/ui/cleanup-empty-preview.png +0 -0
  44. 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//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225/ui/company-created.png +0 -0
  45. 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//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225/ui/login-desktop.png +0 -0
  46. 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//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225/ui/member-role-assigned.png +0 -0
  47. 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//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225/ui/permission-revoked.png +0 -0
  48. 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//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225/ui/platform-menu-recovery.png +0 -0
  49. 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//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225/ui/project-created.png +0 -0
  50. 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//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225/ui/project-member-home.png +0 -0
  51. 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//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225/ui/replacement-administrator.png +0 -0
  52. 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//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225/ui/workspace-empty.png +0 -0
  53. 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//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225//345/206/263/347/255/226/350/256/260/345/275/225.md +29 -0
  54. 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//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225//345/211/215/347/253/257/344/272/244/346/216/245.md +74 -0
  55. 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//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225//345/220/216/347/253/257/346/265/213/350/257/225/346/212/245/345/221/212.md +115 -0
  56. 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//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225//346/212/200/346/234/257/345/245/221/347/272/246.md +578 -0
  57. 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//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225//346/265/213/350/257/225/346/212/245/345/221/212.md +137 -0
  58. 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//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225//346/265/213/350/257/225/350/257/201/346/215/256/browser.json +84 -0
  59. 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//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225//346/265/213/350/257/225/350/257/201/346/215/256/http.json +108 -0
  60. 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//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225//346/265/213/350/257/225/350/257/201/346/215/256/legacy-http.json +18 -0
  61. 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//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225//346/265/213/350/257/225/350/257/201/346/215/256/legacy.json +24 -0
  62. 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//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225//350/201/224/350/260/203/350/256/260/345/275/225.md +76 -0
  63. 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//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225//350/277/220/350/241/214/350/257/264/346/230/216.md +117 -0
  64. 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//346/235/203/351/231/220/344/270/216/350/217/234/345/215/225//351/234/200/346/261/202/346/226/207/346/241/243.md +275 -0
  65. 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//347/273/204/347/273/207/346/236/266/346/236/204//345/206/263/347/255/226/350/256/260/345/275/225.md +35 -0
  66. 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//347/273/204/347/273/207/346/236/266/346/236/204//346/212/200/346/234/257/345/245/221/347/272/246.md +744 -0
  67. 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//347/273/204/347/273/207/346/236/266/346/236/204//346/265/213/350/257/225/346/212/245/345/221/212.md +174 -0
  68. 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//347/273/204/347/273/207/346/236/266/346/236/204//350/201/224/350/260/203/350/256/260/345/275/225.md +137 -0
  69. 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//347/273/204/347/273/207/346/236/266/346/236/204//350/256/276/350/256/241/346/226/271/346/241/210.md +215 -0
  70. 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//347/273/204/347/273/207/346/236/266/346/236/204//351/234/200/346/261/202/346/226/207/346/241/243.md +421 -0
  71. package/template-vp-monorepo-react-nestjs/.spaces/02./344/270/212/344/274/240/344/270/216/346/226/207/344/273/266//344/270/212/344/274/240/346/224/271/351/200/240//350/256/276/350/256/241/346/226/271/346/241/210.md +851 -0
  72. package/template-vp-monorepo-react-nestjs/.spaces/02./344/270/212/344/274/240/344/270/216/346/226/207/344/273/266//344/270/212/344/274/240/346/270/205/347/220/206//350/256/276/350/256/241/346/226/271/346/241/210.md +1470 -0
  73. package/template-vp-monorepo-react-nestjs/.spaces/02./344/270/212/344/274/240/344/270/216/346/226/207/344/273/266//345/210/206/347/211/207/344/270/212/344/274/240//345/256/236/346/226/275/346/225/231/347/250/213.md +4507 -0
  74. package/template-vp-monorepo-react-nestjs/.spaces/03./344/273/273/345/212/241/351/230/237/345/210/227//345/205/254/345/205/261/351/230/237/345/210/227//350/256/276/350/256/241/346/226/271/346/241/210.md +1897 -0
  75. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/accounts-mobile-320.png +0 -0
  76. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/accounts-mobile-360.png +0 -0
  77. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/accounts-mobile-390.png +0 -0
  78. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/companies-desktop-data.png +0 -0
  79. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/companies-desktop-empty.png +0 -0
  80. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/companies-mobile-320.png +0 -0
  81. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/companies-mobile-360.png +0 -0
  82. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/companies-mobile-390.png +0 -0
  83. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/company-create-dialog.png +0 -0
  84. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/company-projects-1440.png +0 -0
  85. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/company-projects-390.png +0 -0
  86. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/login-1440.png +0 -0
  87. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/login-390.png +0 -0
  88. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/project-home-1440.png +0 -0
  89. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/project-home-390.png +0 -0
  90. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/register-1440.png +0 -0
  91. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/register-390.png +0 -0
  92. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/workspaces-1440.png +0 -0
  93. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/workspaces-390.png +0 -0
  94. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273/ui/workspaces-empty.png +0 -0
  95. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273//345/267/245/345/205/267/fixtures.mjs +132 -0
  96. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273//345/267/245/345/205/267/visual-check.mjs +274 -0
  97. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273//346/265/213/350/257/225/350/257/201/346/215/256/visual-report.json +104 -0
  98. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//344/270/207/347/211/251/346/242/246/345/271/273//351/252/214/350/257/201/350/257/264/346/230/216.md +25 -0
  99. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//345/233/275/351/231/205/345/214/226//344/272/244/344/273/230/350/256/260/345/275/225.md +12 -0
  100. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//345/233/275/351/231/205/345/214/226//346/265/213/350/257/225/346/212/245/345/221/212.md +19 -0
  101. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//345/233/275/351/231/205/345/214/226//350/256/276/350/256/241/346/226/271/346/241/210.md +18 -0
  102. package/template-vp-monorepo-react-nestjs/.spaces/04./347/225/214/351/235/242/344/270/216/344/270/273/351/242/230//345/233/275/351/231/205/345/214/226//351/234/200/346/261/202/346/226/207/346/241/243.md +24 -0
  103. package/template-vp-monorepo-react-nestjs/.spaces/05./350/277/220/350/241/214/347/273/264/346/212/244//345/271/263/345/217/260/346/223/215/344/275/234/346/211/213/345/206/214.md +397 -0
  104. package/template-vp-monorepo-react-nestjs/.spaces/05./350/277/220/350/241/214/347/273/264/346/212/244//346/225/260/346/215/256/345/272/223/350/277/201/347/247/273//345/244/207/344/273/275/backup.dump +0 -0
  105. package/template-vp-monorepo-react-nestjs/.spaces/05./350/277/220/350/241/214/347/273/264/346/212/244//346/225/260/346/215/256/345/272/223/350/277/201/347/247/273//345/244/207/344/273/275/export-dump.ps1 +34 -0
  106. package/template-vp-monorepo-react-nestjs/.spaces/05./350/277/220/350/241/214/347/273/264/346/212/244//346/225/260/346/215/256/345/272/223/350/277/201/347/247/273//345/244/207/344/273/275/restore-dump.ps1 +73 -0
  107. package/template-vp-monorepo-react-nestjs/.spaces/05./350/277/220/350/241/214/347/273/264/346/212/244//346/225/260/346/215/256/345/272/223/350/277/201/347/247/273//346/223/215/344/275/234/346/214/207/345/215/227.md +227 -0
  108. package/template-vp-monorepo-react-nestjs/.spaces/05./350/277/220/350/241/214/347/273/264/346/212/244//346/225/260/346/215/256/345/272/223/350/277/201/347/247/273//351/234/200/346/261/202/346/226/207/346/241/243.md +31 -0
  109. package/template-vp-monorepo-react-nestjs/.spaces/06./350/256/244/350/257/201/344/270/216/344/274/232/350/257/235//344/274/232/350/257/235/347/256/241/347/220/206//346/212/200/346/234/257/345/245/221/347/272/246.md +93 -0
  110. package/template-vp-monorepo-react-nestjs/.spaces/06./350/256/244/350/257/201/344/270/216/344/274/232/350/257/235//344/274/232/350/257/235/347/256/241/347/220/206//351/234/200/346/261/202/346/226/207/346/241/243.md +87 -0
  111. package/template-vp-monorepo-react-nestjs/.spaces/06./350/256/244/350/257/201/344/270/216/344/274/232/350/257/235//344/277/256/346/224/271/345/257/206/347/240/201/PRD.md +55 -0
  112. package/template-vp-monorepo-react-nestjs/.spaces/06./350/256/244/350/257/201/344/270/216/344/274/232/350/257/235//344/277/256/346/224/271/345/257/206/347/240/201//346/212/200/346/234/257/345/245/221/347/272/246.md +92 -0
  113. package/template-vp-monorepo-react-nestjs/.spaces/06./350/256/244/350/257/201/344/270/216/344/274/232/350/257/235//344/277/256/346/224/271/345/257/206/347/240/201//346/265/213/350/257/225/346/212/245/345/221/212.md +38 -0
  114. package/template-vp-monorepo-react-nestjs/.spaces/06./350/256/244/350/257/201/344/270/216/344/274/232/350/257/235//344/277/256/346/224/271/345/257/206/347/240/201//350/201/224/350/260/203/350/256/260/345/275/225.md +26 -0
  115. package/template-vp-monorepo-react-nestjs/.spaces/06./350/256/244/350/257/201/344/270/216/344/274/232/350/257/235//344/277/256/346/224/271/345/257/206/347/240/201//350/256/276/350/256/241/346/226/271/346/241/210.md +53 -0
  116. package/template-vp-monorepo-react-nestjs/.spaces/06./350/256/244/350/257/201/344/270/216/344/274/232/350/257/235//344/277/256/346/224/271/345/257/206/347/240/201//351/234/200/346/261/202/346/226/207/346/241/243.md +63 -0
  117. package/template-vp-monorepo-react-nestjs/.spaces/07.API/344/270/216/351/224/231/350/257/257//346/212/200/346/234/257/345/245/221/347/272/246.md +95 -0
  118. package/template-vp-monorepo-react-nestjs/.spaces/README.md +85 -0
  119. package/template-vp-monorepo-react-nestjs/.spaces//346/226/207/346/241/243/350/247/204/350/214/203.md +97 -0
  120. package/template-vp-monorepo-react-nestjs/AGENTS.md +18 -10
  121. package/template-vp-monorepo-react-nestjs/README.md +3 -27
  122. package/template-vp-monorepo-react-nestjs/apps/server/.agents/rules/error-handling.md +84 -0
  123. package/template-vp-monorepo-react-nestjs/apps/server/.env.development +60 -0
  124. package/template-vp-monorepo-react-nestjs/apps/server/.env.production +16 -0
  125. package/template-vp-monorepo-react-nestjs/apps/server/AGENTS.md +9 -0
  126. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/0000_military_colonel_america.sql +11 -0
  127. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/0001_loud_siren.sql +27 -0
  128. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/0002_enterprise_access.sql +182 -0
  129. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/0003_cute_killmonger.sql +357 -0
  130. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/0004_company-member-invitations.sql +25 -0
  131. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/0005_menu_svg_icons.sql +54 -0
  132. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/meta/0000_snapshot.json +97 -0
  133. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/meta/0001_snapshot.json +354 -0
  134. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/meta/0002_snapshot.json +1801 -0
  135. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/meta/0003_snapshot.json +3511 -0
  136. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/meta/0004_snapshot.json +3713 -0
  137. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/meta/_journal.json +48 -0
  138. package/template-vp-monorepo-react-nestjs/apps/server/drizzle/rollback/0003_organization_backfill.sql +32 -0
  139. package/template-vp-monorepo-react-nestjs/apps/server/package.json +9 -3
  140. package/template-vp-monorepo-react-nestjs/apps/server/src/access-init.ts +45 -0
  141. package/template-vp-monorepo-react-nestjs/apps/server/src/app.controller.ts +3 -0
  142. package/template-vp-monorepo-react-nestjs/apps/server/src/app.module.ts +41 -6
  143. package/template-vp-monorepo-react-nestjs/apps/server/src/app.service.ts +3 -0
  144. package/template-vp-monorepo-react-nestjs/apps/server/src/common/adapters/fastify.adapter.ts +17 -0
  145. package/template-vp-monorepo-react-nestjs/apps/server/src/common/constants/session.constants.ts +9 -0
  146. package/template-vp-monorepo-react-nestjs/apps/server/src/common/decorators/access-policy.decorator.ts +18 -0
  147. package/template-vp-monorepo-react-nestjs/apps/server/src/common/decorators/current-auth.decorator.ts +4 -0
  148. package/template-vp-monorepo-react-nestjs/apps/server/src/common/decorators/public.decorator.ts +3 -0
  149. package/template-vp-monorepo-react-nestjs/apps/server/src/common/exceptions/app.exception.ts +9 -0
  150. package/template-vp-monorepo-react-nestjs/apps/server/src/common/filters/global-exception.filter.ts +38 -5
  151. package/template-vp-monorepo-react-nestjs/apps/server/src/common/security/password-security-event.ts +98 -0
  152. package/template-vp-monorepo-react-nestjs/apps/server/src/config/app.config.ts +3 -0
  153. package/template-vp-monorepo-react-nestjs/apps/server/src/config/database.config.ts +3 -0
  154. package/template-vp-monorepo-react-nestjs/apps/server/src/config/index.ts +2 -0
  155. package/template-vp-monorepo-react-nestjs/apps/server/src/config/llm.config.ts +3 -0
  156. package/template-vp-monorepo-react-nestjs/apps/server/src/config/queue.config.ts +119 -0
  157. package/template-vp-monorepo-react-nestjs/apps/server/src/config/redis.config.ts +3 -0
  158. package/template-vp-monorepo-react-nestjs/apps/server/src/config/session.config.ts +7 -0
  159. package/template-vp-monorepo-react-nestjs/apps/server/src/config/storage.config.ts +42 -0
  160. package/template-vp-monorepo-react-nestjs/apps/server/src/database/access.schema.ts +394 -0
  161. package/template-vp-monorepo-react-nestjs/apps/server/src/database/db.module.ts +3 -0
  162. package/template-vp-monorepo-react-nestjs/apps/server/src/database/organization.schema.ts +453 -0
  163. package/template-vp-monorepo-react-nestjs/apps/server/src/database/schema.ts +63 -1
  164. package/template-vp-monorepo-react-nestjs/apps/server/src/main.ts +5 -0
  165. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/access-http.ts +29 -0
  166. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/access.errors.ts +130 -0
  167. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/access.guard.ts +63 -0
  168. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/access.module.ts +27 -0
  169. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/access.service.ts +441 -0
  170. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/access.store.ts +68 -0
  171. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/access.validation.ts +44 -0
  172. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/maintenance/permissions-cleanup.controller.ts +27 -0
  173. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/maintenance/permissions-cleanup.service.ts +260 -0
  174. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/maintenance/permissions-cleanup.validation.ts +7 -0
  175. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/roles/roles.controller.ts +80 -0
  176. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/roles/roles.service.ts +249 -0
  177. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/roles/roles.validation.ts +24 -0
  178. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/seed/access-seed.service.ts +183 -0
  179. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/workspaces/workspaces.controller.ts +28 -0
  180. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/workspaces/workspaces.service.ts +94 -0
  181. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/access/workspaces/workspaces.validation.ts +5 -0
  182. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/audit/audit.controller.ts +26 -0
  183. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/audit/audit.module.ts +11 -0
  184. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/audit/audit.service.ts +74 -0
  185. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/audit/audit.validation.ts +11 -0
  186. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/auth.controller.ts +25 -0
  187. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/auth.errors.ts +10 -0
  188. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/auth.module.ts +1 -1
  189. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/auth.repository.ts +60 -0
  190. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/auth.service.ts +69 -13
  191. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/dto/auth-repsponse.dto.ts +2 -2
  192. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/dto/register.dto.ts +1 -1
  193. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/guards/session-auth.guard.ts +8 -0
  194. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/password/change-password.dto.ts +17 -0
  195. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/password.service.ts +6 -0
  196. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/session/session-store.service.ts +46 -0
  197. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/session/session-token.service.ts +12 -0
  198. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/session/session.script.ts +7 -1
  199. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/companies/companies.controller.ts +105 -0
  200. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/companies/companies.module.ts +12 -0
  201. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/companies/companies.service.ts +523 -0
  202. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/accounts/accounts.controller.ts +72 -0
  203. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/accounts/accounts.service.ts +154 -0
  204. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/accounts/accounts.validation.ts +8 -0
  205. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/accounts/password-reset/password-reset.service.ts +81 -0
  206. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/accounts/password-reset/reset-password.validation.ts +13 -0
  207. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/administrators/administrators.service.ts +116 -0
  208. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/administrators/administrators.validation.ts +1 -0
  209. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/invitations/company-member-invitations.controller.ts +69 -0
  210. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/invitations/member-invitation-acceptance.controller.ts +23 -0
  211. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/invitations/member-invitations.service.ts +344 -0
  212. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/invitations/member-invitations.validation.ts +23 -0
  213. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/members.controller.ts +88 -0
  214. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/members.module.ts +31 -0
  215. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/members.service.ts +573 -0
  216. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/members/members.validation.ts +19 -0
  217. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/menus/menus.controller.ts +56 -0
  218. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/menus/menus.module.ts +11 -0
  219. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/menus/menus.service.ts +230 -0
  220. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/menus/menus.validation.ts +30 -0
  221. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/candidates/global-account/global-account-candidates.controller.ts +35 -0
  222. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/candidates/global-account/global-account-candidates.service.ts +109 -0
  223. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/candidates/member-candidates.controller.ts +60 -0
  224. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/candidates/member-candidates.service.ts +939 -0
  225. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/initialization/project-organization-initialization.service.ts +189 -0
  226. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/organization.module.ts +34 -0
  227. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/organization.store.ts +150 -0
  228. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/organization.validation.ts +23 -0
  229. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/positions/positions.controller.ts +160 -0
  230. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/positions/positions.service.ts +636 -0
  231. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/templates/organization-templates.controller.ts +77 -0
  232. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/templates/organization-templates.service.ts +501 -0
  233. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/templates/organization-templates.store.ts +163 -0
  234. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/units/organization-units.controller.ts +187 -0
  235. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/organization/units/organization-units.service.ts +947 -0
  236. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/projects/projects.controller.ts +94 -0
  237. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/projects/projects.module.ts +13 -0
  238. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/projects/projects.service.ts +369 -0
  239. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/queue-worker.bootstrap.ts +27 -0
  240. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/task-queue.constants.ts +24 -0
  241. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/task-queue.contracts.ts +39 -0
  242. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/task-queue.module.ts +61 -0
  243. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/task-queue.processor.ts +75 -0
  244. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/task-queue.service.ts +67 -0
  245. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/test/db/db.controller.ts +9 -0
  246. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/test/db/db.service.ts +9 -0
  247. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/test/error-catelog.spec.ts +34 -0
  248. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/test/redis/redis.controller.ts +9 -0
  249. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/test/redis/redis.service.ts +18 -0
  250. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/dto/initiate-mutipart-upload.dto.ts +26 -0
  251. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/dto/upload-params.dto.ts +15 -0
  252. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/storage/exact-size.transform.ts +77 -0
  253. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/storage/minio-storage.adapter.ts +276 -0
  254. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/storage/storage.port.ts +82 -0
  255. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.constants.ts +32 -0
  256. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.controller.ts +134 -0
  257. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.errors.ts +80 -0
  258. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.module.ts +20 -0
  259. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.reponsitory.ts +193 -0
  260. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.service.ts +774 -0
  261. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/utils/object-key.ts +14 -0
  262. package/template-vp-monorepo-react-nestjs/apps/server/src/utils/server-address.ts +6 -0
  263. package/template-vp-monorepo-react-nestjs/apps/server/test/access/access-policy.spec.ts +95 -0
  264. package/template-vp-monorepo-react-nestjs/apps/server/test/access/access.integration.spec.ts +2250 -0
  265. package/template-vp-monorepo-react-nestjs/apps/server/test/access/member-onboarding.validation.spec.ts +78 -0
  266. package/template-vp-monorepo-react-nestjs/apps/server/test/auth/password-operations.spec.ts +433 -0
  267. package/template-vp-monorepo-react-nestjs/apps/server/test/auth/password.integration.spec.ts +300 -0
  268. package/template-vp-monorepo-react-nestjs/apps/server/test/error-catalog.spec.ts +6 -1
  269. package/template-vp-monorepo-react-nestjs/apps/server/test/menus.validation.spec.ts +28 -0
  270. package/template-vp-monorepo-react-nestjs/apps/server/test/organization/organization-domain.spec.ts +260 -0
  271. package/template-vp-monorepo-react-nestjs/apps/server/test/task-queue.integration.spec.ts +120 -0
  272. package/template-vp-monorepo-react-nestjs/apps/server/test/task-queue.processor.spec.ts +63 -0
  273. package/template-vp-monorepo-react-nestjs/apps/server/test/task-queue.service.spec.ts +106 -0
  274. package/template-vp-monorepo-react-nestjs/apps/server/tsconfig.scripts.json +5 -0
  275. package/template-vp-monorepo-react-nestjs/apps/web/.agents/rules/SVG/344/275/277/347/224/250.md +8 -0
  276. 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
  277. 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
  278. 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
  279. package/template-vp-monorepo-react-nestjs/apps/web/.agents/rules//347/273/204/344/273/266/345/244/215/347/224/250.md +7 -0
  280. package/template-vp-monorepo-react-nestjs/apps/web/.agents/rules//350/241/250/345/215/225/345/233/236/345/241/253.md +4 -0
  281. 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
  282. 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
  283. package/template-vp-monorepo-react-nestjs/apps/web/.env.dev +1 -1
  284. package/template-vp-monorepo-react-nestjs/apps/web/AGENTS.md +25 -0
  285. package/template-vp-monorepo-react-nestjs/apps/web/README.MD +181 -0
  286. package/template-vp-monorepo-react-nestjs/apps/web/index.html +3 -2
  287. package/template-vp-monorepo-react-nestjs/apps/web/package.json +7 -1
  288. package/template-vp-monorepo-react-nestjs/apps/web/public/favicon.svg +9 -1
  289. package/template-vp-monorepo-react-nestjs/apps/web/src/App.module.css +5 -0
  290. package/template-vp-monorepo-react-nestjs/apps/web/src/App.tsx +30 -12
  291. package/template-vp-monorepo-react-nestjs/apps/web/src/api/auth.ts +22 -0
  292. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/accounts.svg +4 -0
  293. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/audit-log.svg +4 -0
  294. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/companies.svg +5 -0
  295. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/dashboard.svg +6 -0
  296. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/draft.svg +4 -0
  297. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/home.svg +4 -0
  298. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/members.svg +4 -0
  299. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/menu-item.svg +4 -0
  300. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/menus.svg +6 -0
  301. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/organization-template.svg +4 -0
  302. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/organization.svg +6 -0
  303. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/positions.svg +4 -0
  304. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/profile.svg +5 -0
  305. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/projects.svg +4 -0
  306. package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/roles.svg +4 -0
  307. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Brand/Brand.module.css +125 -0
  308. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Brand/Brand.tsx +56 -0
  309. package/template-vp-monorepo-react-nestjs/apps/web/src/components/DraftProTable/DraftProTable.module.css +78 -0
  310. package/template-vp-monorepo-react-nestjs/apps/web/src/components/DraftProTable/DraftProTable.tsx +105 -0
  311. package/template-vp-monorepo-react-nestjs/apps/web/src/components/DraftProTable/README.md +34 -0
  312. package/template-vp-monorepo-react-nestjs/apps/web/src/components/FullHeightProTable/FullHeightProTable.module.css +54 -0
  313. package/template-vp-monorepo-react-nestjs/apps/web/src/components/FullHeightProTable/FullHeightProTable.tsx +41 -0
  314. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Icon/SvgAsset/IconSelector.tsx +38 -0
  315. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Icon/SvgAsset/SvgAssetIcon.tsx +14 -0
  316. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Icon/SvgAsset/SvgAssetRegistry.ts +32 -0
  317. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Icon/SvgAsset/index.ts +10 -0
  318. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Icon/svg-icon/README.md +37 -0
  319. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Icon/svg-icon/SvgIcon.tsx +25 -0
  320. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Loading/PageLoading.module.css +7 -0
  321. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Loading/PageLoading.tsx +6 -1
  322. package/template-vp-monorepo-react-nestjs/apps/web/src/components/LocaleSwitch/LocaleSwitch.tsx +52 -0
  323. package/template-vp-monorepo-react-nestjs/apps/web/src/components/PasswordActions/ChangePasswordDialog.tsx +90 -0
  324. package/template-vp-monorepo-react-nestjs/apps/web/src/components/PasswordActions/PasswordFields.module.css +12 -0
  325. package/template-vp-monorepo-react-nestjs/apps/web/src/components/PasswordActions/PasswordFields.tsx +54 -0
  326. package/template-vp-monorepo-react-nestjs/apps/web/src/components/PasswordActions/ResetAccountPasswordDialog.tsx +100 -0
  327. package/template-vp-monorepo-react-nestjs/apps/web/src/components/PasswordActions/change-password-session.ts +13 -0
  328. package/template-vp-monorepo-react-nestjs/apps/web/src/components/RouteTransition/RouteTransition.css +60 -0
  329. package/template-vp-monorepo-react-nestjs/apps/web/src/components/RouteTransition/RouteTransition.tsx +44 -0
  330. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/BasicTableSelector/BasicTableSelector.tsx +218 -0
  331. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/BasicTableSelector/BasicTableSelectorSelection.ts +91 -0
  332. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/BasicTableSelector/BasicTableSelectorTypes.ts +60 -0
  333. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/BasicTableSelector/README.md +110 -0
  334. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/BasicTableSelector/index.ts +7 -0
  335. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/GlobalAccountSelect/GlobalAccountSelect.tsx +211 -0
  336. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/GlobalAccountSelect/GlobalAccountSelectTypes.ts +75 -0
  337. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/GlobalAccountSelect/README.md +35 -0
  338. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/GlobalAccountSelect/index.ts +9 -0
  339. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/OrganizationMemberSelector/OrganizationMemberBrowser.tsx +146 -0
  340. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/OrganizationMemberSelector/OrganizationMemberCandidateColumns.tsx +137 -0
  341. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/OrganizationMemberSelector/OrganizationMemberFilterPanel.tsx +209 -0
  342. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/OrganizationMemberSelector/OrganizationMemberSelector.tsx +196 -0
  343. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/OrganizationMemberSelector/OrganizationMemberSelectorTypes.ts +103 -0
  344. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/OrganizationMemberSelector/README.md +8 -0
  345. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/OrganizationMemberSelector/index.ts +13 -0
  346. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/README.md +13 -0
  347. package/template-vp-monorepo-react-nestjs/apps/web/src/components/Selector/index.ts +30 -0
  348. package/template-vp-monorepo-react-nestjs/apps/web/src/config/theme.ts +52 -0
  349. package/template-vp-monorepo-react-nestjs/apps/web/src/hooks/useLatestDialogRequest.ts +71 -0
  350. package/template-vp-monorepo-react-nestjs/apps/web/src/i18n/config.ts +40 -0
  351. package/template-vp-monorepo-react-nestjs/apps/web/src/i18n/index.ts +10 -0
  352. package/template-vp-monorepo-react-nestjs/apps/web/src/i18n/instance.ts +36 -0
  353. package/template-vp-monorepo-react-nestjs/apps/web/src/i18n/ui-locale.ts +30 -0
  354. package/template-vp-monorepo-react-nestjs/apps/web/src/layouts/BasicLayout/index.tsx +113 -0
  355. package/template-vp-monorepo-react-nestjs/apps/web/src/layouts/WorkspaceLayout/components/WorkspaceNavigationBoundary.tsx +50 -0
  356. package/template-vp-monorepo-react-nestjs/apps/web/src/layouts/WorkspaceLayout/index.tsx +196 -0
  357. package/template-vp-monorepo-react-nestjs/apps/web/src/layouts/WorkspaceLayout/workspace-landing.css +119 -0
  358. package/template-vp-monorepo-react-nestjs/apps/web/src/layouts/WorkspaceLayout/workspace-surfaces.css +110 -0
  359. package/template-vp-monorepo-react-nestjs/apps/web/src/layouts/WorkspaceLayout/workspace.css +223 -0
  360. package/template-vp-monorepo-react-nestjs/apps/web/src/locales/en_US.json +639 -1
  361. package/template-vp-monorepo-react-nestjs/apps/web/src/locales/zh_CN.json +621 -1
  362. package/template-vp-monorepo-react-nestjs/apps/web/src/main.tsx +8 -2
  363. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/api.ts +226 -0
  364. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/audit.tsx +169 -0
  365. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/AdministratorDialog.tsx +128 -0
  366. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/CleanupDialog.tsx +140 -0
  367. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/CompanyHierarchyDialog.tsx +244 -0
  368. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/EntityFormDialog.tsx +285 -0
  369. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/EntitySelectors/index.ts +6 -0
  370. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/EntitySelectors/selector-requests.ts +59 -0
  371. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberAffiliations/MemberOrganizationsDialog.tsx +188 -0
  372. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberAffiliations/MemberPositionsDialog.tsx +116 -0
  373. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberAffiliations/affiliation-loaders.ts +18 -0
  374. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberAffiliations/index.ts +5 -0
  375. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberOnboarding/AddProjectMemberDialog.tsx +75 -0
  376. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberOnboarding/CompanyMemberInvitation/CompanyMemberInvitationDialog.tsx +246 -0
  377. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberOnboarding/CompanyMemberInvitation/InvitationRecordsTable.tsx +152 -0
  378. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberOnboarding/CompanyMemberInvitation/invitation-link.ts +9 -0
  379. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberOnboarding/MemberOnboardingActions.tsx +79 -0
  380. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberOnboarding/RegisterCompanyMember/RegisterCompanyMemberDialog.module.css +12 -0
  381. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberOnboarding/RegisterCompanyMember/RegisterCompanyMemberDialog.tsx +122 -0
  382. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberRolesDialog.tsx +80 -0
  383. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberTable/MemberTableColumns.tsx +234 -0
  384. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberTable/index.ts +2 -0
  385. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MemberTable/member-candidate-cache.ts +12 -0
  386. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/MenuFormDialog.tsx +251 -0
  387. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/RoleFormDialog.tsx +73 -0
  388. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/components/RolePermissionsDialog.tsx +185 -0
  389. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/entities.tsx +268 -0
  390. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/home.tsx +67 -0
  391. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/members.tsx +310 -0
  392. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/menus/api.ts +74 -0
  393. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/menus/index.tsx +325 -0
  394. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/profile.tsx +160 -0
  395. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/roles.tsx +173 -0
  396. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/access/use-access.ts +35 -0
  397. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/error/404.tsx +20 -0
  398. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/error/error.tsx +42 -0
  399. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/i18n/index.module.css +23 -0
  400. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/i18n/index.tsx +35 -0
  401. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/components/ProjectFormDialog.tsx +137 -0
  402. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/config/columns.tsx +126 -0
  403. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/config/index.ts +87 -0
  404. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/components/DraftProjectFormDialog.tsx +147 -0
  405. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/config/columns.tsx +134 -0
  406. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/config/index.ts +39 -0
  407. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/config/projects.ts +56 -0
  408. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/index.module.css +49 -0
  409. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/index.tsx +183 -0
  410. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/index.module.css +53 -0
  411. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/index.tsx +150 -0
  412. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/home/index.module.css +23 -0
  413. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/home/index.tsx +28 -42
  414. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/login/api.ts +14 -0
  415. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/login/index.tsx +163 -0
  416. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/login/login-form.css +199 -0
  417. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/login/login.css +186 -0
  418. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/member-invitations/accept.css +47 -0
  419. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/member-invitations/accept.tsx +143 -0
  420. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/member-invitations/api.ts +20 -0
  421. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization/api.ts +91 -0
  422. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization/components/MoveOrganizationUnitDialog.tsx +82 -0
  423. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization/components/OrganizationBrowser/OrganizationMemberColumns.tsx +87 -0
  424. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization/components/OrganizationBrowser/OrganizationMemberHeader.tsx +102 -0
  425. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization/components/OrganizationBrowser/OrganizationTreeModel.tsx +59 -0
  426. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization/components/OrganizationBrowser/OrganizationTreePanel.tsx +88 -0
  427. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization/components/OrganizationBrowser/index.ts +11 -0
  428. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization/components/OrganizationBrowser/organization-loaders.ts +47 -0
  429. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization/components/OrganizationBrowser/organization-member-filter-loaders.ts +187 -0
  430. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization/components/OrganizationMembersDialog.tsx +207 -0
  431. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization/components/OrganizationUnitDialog.tsx +110 -0
  432. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization/index.tsx +258 -0
  433. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization-templates/api.ts +53 -0
  434. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization-templates/components/OrganizationTemplateDialog/TemplateDefinitionTabs.tsx +189 -0
  435. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization-templates/components/OrganizationTemplateDialog/index.tsx +135 -0
  436. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization-templates/components/OrganizationTemplateDialog/types.ts +25 -0
  437. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/organization-templates/index.tsx +201 -0
  438. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/positions/api.ts +81 -0
  439. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/positions/components/PositionFormDialog.tsx +86 -0
  440. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/positions/components/PositionMembersDialog.tsx +102 -0
  441. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/positions/index.tsx +198 -0
  442. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/positions/loaders.ts +35 -0
  443. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/register/api.ts +14 -0
  444. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/register/index.tsx +157 -0
  445. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/workspaces/api.ts +32 -0
  446. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/workspaces/index.tsx +146 -0
  447. package/template-vp-monorepo-react-nestjs/apps/web/src/pages/workspaces/state.ts +24 -0
  448. package/template-vp-monorepo-react-nestjs/apps/web/src/router/AGENTS.MD +6 -0
  449. package/template-vp-monorepo-react-nestjs/apps/web/src/router/index.tsx +3 -23
  450. package/template-vp-monorepo-react-nestjs/apps/web/src/router/lazy-load/index.tsx +26 -0
  451. package/template-vp-monorepo-react-nestjs/apps/web/src/router/middleware/README.md +23 -0
  452. package/template-vp-monorepo-react-nestjs/apps/web/src/router/middleware/auth.ts +6 -0
  453. package/template-vp-monorepo-react-nestjs/apps/web/src/router/middleware/index.ts +4 -0
  454. package/template-vp-monorepo-react-nestjs/apps/web/src/router/middleware/workspace/access.ts +23 -0
  455. package/template-vp-monorepo-react-nestjs/apps/web/src/router/middleware/workspace/data.ts +55 -0
  456. package/template-vp-monorepo-react-nestjs/apps/web/src/router/middleware/workspace/entry.ts +52 -0
  457. package/template-vp-monorepo-react-nestjs/apps/web/src/router/modules/access/page.tsx +20 -0
  458. package/template-vp-monorepo-react-nestjs/apps/web/src/router/modules/auth.tsx +17 -0
  459. package/template-vp-monorepo-react-nestjs/apps/web/src/router/modules/company.tsx +30 -0
  460. package/template-vp-monorepo-react-nestjs/apps/web/src/router/modules/entry.tsx +20 -0
  461. package/template-vp-monorepo-react-nestjs/apps/web/src/router/modules/examples.tsx +23 -0
  462. package/template-vp-monorepo-react-nestjs/apps/web/src/router/modules/fallback.tsx +13 -0
  463. package/template-vp-monorepo-react-nestjs/apps/web/src/router/modules/index.tsx +32 -0
  464. package/template-vp-monorepo-react-nestjs/apps/web/src/router/modules/platform.tsx +27 -0
  465. package/template-vp-monorepo-react-nestjs/apps/web/src/router/modules/project.tsx +28 -0
  466. package/template-vp-monorepo-react-nestjs/apps/web/src/router/page-registry.tsx +94 -0
  467. package/template-vp-monorepo-react-nestjs/apps/web/src/styles/dream.css +34 -0
  468. package/template-vp-monorepo-react-nestjs/apps/web/src/styles/index.css +17 -4
  469. package/template-vp-monorepo-react-nestjs/apps/web/src/types/auto-imports.d.ts +26 -0
  470. package/template-vp-monorepo-react-nestjs/apps/web/src/utils/organization-assignment.ts +66 -0
  471. package/template-vp-monorepo-react-nestjs/apps/web/src/utils/request/core/index.ts +101 -58
  472. package/template-vp-monorepo-react-nestjs/apps/web/src/utils/request/core/utils.ts +5 -0
  473. package/template-vp-monorepo-react-nestjs/apps/web/src/utils/request/index.ts +16 -6
  474. package/template-vp-monorepo-react-nestjs/apps/web/src/utils/request/workspace.ts +82 -0
  475. package/template-vp-monorepo-react-nestjs/apps/web/src/utils/safe-next.ts +36 -0
  476. package/template-vp-monorepo-react-nestjs/apps/web/src/utils/storage/cookie.ts +51 -0
  477. package/template-vp-monorepo-react-nestjs/apps/web/src/utils/storage/session.ts +34 -0
  478. package/template-vp-monorepo-react-nestjs/apps/web/test/draft-projects.spec.ts +89 -0
  479. package/template-vp-monorepo-react-nestjs/apps/web/test/i18n-locale.spec.ts +22 -0
  480. package/template-vp-monorepo-react-nestjs/apps/web/test/icon-selector.spec.tsx +23 -0
  481. package/template-vp-monorepo-react-nestjs/apps/web/test/latest-dialog-request.spec.ts +89 -0
  482. package/template-vp-monorepo-react-nestjs/apps/web/test/member-invitation-navigation.spec.ts +34 -0
  483. package/template-vp-monorepo-react-nestjs/apps/web/test/organization-assignment.spec.ts +137 -0
  484. package/template-vp-monorepo-react-nestjs/apps/web/test/password-actions.spec.ts +40 -0
  485. package/template-vp-monorepo-react-nestjs/apps/web/test/password-reset-action.spec.ts +64 -0
  486. package/template-vp-monorepo-react-nestjs/apps/web/test/request.spec.ts +146 -0
  487. package/template-vp-monorepo-react-nestjs/apps/web/test/router/access.spec.ts +136 -0
  488. package/template-vp-monorepo-react-nestjs/apps/web/test/router/config.ts +140 -0
  489. package/template-vp-monorepo-react-nestjs/apps/web/test/router/entry.spec.ts +179 -0
  490. package/template-vp-monorepo-react-nestjs/apps/web/test/router/workspace-data.spec.ts +112 -0
  491. package/template-vp-monorepo-react-nestjs/apps/web/test/selector/global-account-select.spec.ts +311 -0
  492. package/template-vp-monorepo-react-nestjs/apps/web/test/selector/organization-member-selector.spec.ts +454 -0
  493. package/template-vp-monorepo-react-nestjs/apps/web/test/selector/selector-selection.spec.ts +132 -0
  494. package/template-vp-monorepo-react-nestjs/apps/web/test/storage.spec.ts +109 -0
  495. package/template-vp-monorepo-react-nestjs/apps/web/test/tsconfig.json +7 -0
  496. package/template-vp-monorepo-react-nestjs/apps/web/test/workspace-navigation-boundary.spec.tsx +169 -0
  497. package/template-vp-monorepo-react-nestjs/apps/web/test/workspace-navigation.spec.ts +105 -0
  498. package/template-vp-monorepo-react-nestjs/apps/web/test/workspace-requests.spec.ts +174 -0
  499. package/template-vp-monorepo-react-nestjs/apps/web/test/workspaces-password-entry.spec.tsx +71 -0
  500. package/template-vp-monorepo-react-nestjs/apps/web/tsconfig.json +1 -1
  501. package/template-vp-monorepo-react-nestjs/apps/web/vite.config.ts +85 -29
  502. package/template-vp-monorepo-react-nestjs/apps/web-vue/package.json +2 -0
  503. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/api/index.ts +1 -0
  504. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/hooks/chart/useEcharts.ts +13 -1
  505. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/layout/default/header/index.vue +1 -0
  506. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/layout/default/index.vue +6 -2
  507. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/router/guard/index.ts +1 -0
  508. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/router/guard/permissionGuard.ts +1 -0
  509. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/router/index.ts +1 -0
  510. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/store/index.ts +1 -0
  511. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/store/modules/user.ts +1 -0
  512. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/types/auto-import.d.ts +2 -0
  513. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/utils/request/core/index.ts +30 -0
  514. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/utils/request/core/utils.ts +5 -0
  515. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/utils/request/index.ts +2 -0
  516. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/views/example/echart/config.ts +1 -0
  517. package/template-vp-monorepo-react-nestjs/apps/web-vue/tsconfig.tsbuildinfo +1 -0
  518. package/template-vp-monorepo-react-nestjs/docker-compose.yml +19 -0
  519. package/template-vp-monorepo-react-nestjs/mise.toml +1 -1
  520. package/template-vp-monorepo-react-nestjs/package.json +1 -13
  521. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/command.ts +22 -2
  522. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/config.ts +1 -0
  523. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/files.ts +1 -0
  524. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/load-config.ts +8 -0
  525. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/report.ts +2 -0
  526. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/scanner.ts +11 -0
  527. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/sync.ts +12 -0
  528. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/test/command.test.ts +1 -0
  529. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/test/temporary-directory.ts +5 -0
  530. package/template-vp-monorepo-react-nestjs/packages/i18n/core/src/format.ts +1 -0
  531. package/template-vp-monorepo-react-nestjs/packages/i18n/core/src/i18n.ts +14 -0
  532. package/template-vp-monorepo-react-nestjs/packages/i18n/core/src/storage.ts +8 -0
  533. package/template-vp-monorepo-react-nestjs/packages/i18n/core/src/store.ts +9 -0
  534. package/template-vp-monorepo-react-nestjs/packages/i18n/react/src/index.tsx +5 -0
  535. package/template-vp-monorepo-react-nestjs/packages/i18n/react/src/use-store.ts +3 -0
  536. package/template-vp-monorepo-react-nestjs/packages/i18n/vue/src/index.ts +6 -0
  537. package/template-vp-monorepo-react-nestjs/packages/i18n/vue/src/use-store.ts +3 -0
  538. package/template-vp-monorepo-react-nestjs/packages/shared/package.json +5 -1
  539. package/template-vp-monorepo-react-nestjs/packages/shared/src/index.ts +2 -13
  540. package/template-vp-monorepo-react-nestjs/packages/shared/src/types/access-maintenance.ts +68 -0
  541. package/template-vp-monorepo-react-nestjs/packages/shared/src/types/access-menus.ts +94 -0
  542. package/template-vp-monorepo-react-nestjs/packages/shared/src/types/access-resources.ts +168 -0
  543. package/template-vp-monorepo-react-nestjs/packages/shared/src/types/access.ts +71 -0
  544. package/template-vp-monorepo-react-nestjs/packages/shared/src/types/auth.ts +23 -0
  545. package/template-vp-monorepo-react-nestjs/packages/shared/src/types/index.ts +9 -0
  546. package/template-vp-monorepo-react-nestjs/packages/shared/src/types/organization/models.ts +361 -0
  547. package/template-vp-monorepo-react-nestjs/packages/shared/src/utils/access-catalog.ts +304 -0
  548. package/template-vp-monorepo-react-nestjs/packages/shared/src/utils/access-scope.ts +97 -0
  549. package/template-vp-monorepo-react-nestjs/packages/shared/src/utils/account.ts +1 -0
  550. package/template-vp-monorepo-react-nestjs/packages/shared/src/utils/index.ts +65 -0
  551. package/template-vp-monorepo-react-nestjs/packages/shared/src/utils/organization/contracts.ts +237 -0
  552. package/template-vp-monorepo-react-nestjs/packages/shared/src/utils/record.ts +10 -0
  553. package/template-vp-monorepo-react-nestjs/packages/shared/src/utils/session-terminal.ts +1 -0
  554. package/template-vp-monorepo-react-nestjs/packages/shared/test/access.spec.ts +194 -0
  555. package/template-vp-monorepo-react-nestjs/packages/shared/test/organization.spec.ts +168 -0
  556. package/template-vp-monorepo-react-nestjs/pnpm-workspace.yaml +110 -68
  557. package/template-vp-monorepo-react-nestjs/scripts/enterprise-qa/browser.mjs +696 -0
  558. package/template-vp-monorepo-react-nestjs/scripts/enterprise-qa/http-support.mjs +105 -0
  559. package/template-vp-monorepo-react-nestjs/scripts/enterprise-qa/http.mjs +1091 -0
  560. package/template-vp-monorepo-react-nestjs/scripts/enterprise-qa/legacy-http.mjs +140 -0
  561. package/template-vp-monorepo-react-nestjs/scripts/enterprise-qa/legacy.mjs +196 -0
  562. package/template-vp-monorepo-react-nestjs/scripts/enterprise-qa/runtime.mjs +110 -0
  563. package/template-vp-monorepo-react-nestjs/scripts/with-enterprise-test-env.mjs +80 -0
  564. package/template-vp-monorepo-react-nestjs/src/types/auto-imports.d.ts +60 -0
  565. package/template-vp-react/.vscode/settings.json +8 -1
  566. package/template-vp-react/commitlint.config.js +0 -0
  567. package/template-vp-react/package.json +21 -26
  568. package/template-vp-react/pnpm-workspace.yaml +6 -0
  569. package/template-vp-react/src/assets/svg/draft.svg +4 -0
  570. package/template-vp-react/src/components/Icon/svg-icon/README.md +37 -0
  571. package/template-vp-react/src/components/Icon/svg-icon/SvgIcon.tsx +27 -0
  572. package/template-vp-react/src/types/auto-imports.d.ts +2 -0
  573. package/template-vp-react/src/utils/request/index.ts +18 -5
  574. package/template-vp-react/tsconfig.json +2 -3
  575. package/template-vp-react/vite.config.ts +2 -0
  576. package/template-vp-react-shadcn/.agents/rules/SVG/344/275/277/347/224/250.md +8 -0
  577. package/template-vp-react-shadcn/.vscode/settings.json +2 -2
  578. package/template-vp-react-shadcn/{agents.md → AGENTS.md} +12 -2
  579. package/template-vp-react-shadcn/README.md +41 -0
  580. package/template-vp-react-shadcn/commitlint.config.js +1 -1
  581. package/template-vp-react-shadcn/components.json +1 -1
  582. package/template-vp-react-shadcn/package.json +25 -32
  583. package/template-vp-react-shadcn/pnpm-workspace.yaml +10 -0
  584. package/template-vp-react-shadcn/src/App.tsx +10 -10
  585. package/template-vp-react-shadcn/src/assets/svg/draft.svg +4 -0
  586. package/template-vp-react-shadcn/src/components/Icon/svg-icon/README.md +37 -0
  587. package/template-vp-react-shadcn/src/components/Icon/svg-icon/SvgIcon.tsx +27 -0
  588. package/template-vp-react-shadcn/src/components/Loading/PageLoading.tsx +4 -4
  589. package/template-vp-react-shadcn/src/components/Player/VideoJS/index.tsx +94 -78
  590. package/template-vp-react-shadcn/src/components/Player/index.ts +1 -1
  591. package/template-vp-react-shadcn/src/components/ui/button.tsx +25 -32
  592. package/template-vp-react-shadcn/src/components/ui/spinner.tsx +8 -7
  593. package/template-vp-react-shadcn/src/components/ui/toast.tsx +229 -0
  594. package/template-vp-react-shadcn/src/lib/utils.ts +3 -3
  595. package/template-vp-react-shadcn/src/main.tsx +5 -4
  596. package/template-vp-react-shadcn/src/pages/home/index.tsx +43 -35
  597. package/template-vp-react-shadcn/src/router/index.tsx +15 -15
  598. package/template-vp-react-shadcn/src/styles/index.css +4 -4
  599. package/template-vp-react-shadcn/src/styles/shadcn.css +2 -2
  600. package/template-vp-react-shadcn/src/utils/env/index.tsx +2 -2
  601. package/template-vp-react-shadcn/src/utils/request/alova-core/index.ts +102 -30
  602. package/template-vp-react-shadcn/src/utils/request/alova-core/utils.ts +5 -2
  603. package/template-vp-react-shadcn/src/utils/request/index.ts +32 -19
  604. package/template-vp-react-shadcn/src/utils/request/readme.md +41 -38
  605. package/template-vp-react-shadcn/tsconfig.json +2 -3
  606. package/template-vp-react-shadcn/vite.config.ts +29 -15
  607. package/template-vp-vue-eslint-vapor/package.json +29 -36
  608. package/template-vp-vue-eslint-vapor/pnpm-workspace.yaml +29 -1
  609. package/template-vue-vp-eslint/README.md +3 -10
  610. package/template-vue-vp-eslint/package.json +27 -34
  611. package/template-vue-vp-eslint/pnpm-workspace.yaml +27 -2
  612. package/template-vue-vp-eslint/src/layout/default/index.vue +4 -3
  613. package/template-vp-monorepo-react-nestjs/.agents/auth.config.ts +0 -0
  614. package/template-vp-monorepo-react-nestjs/.spaces/server/todo.md +0 -991
  615. package/template-vp-monorepo-react-nestjs/apps/server/README.md +0 -3
  616. package/template-vp-monorepo-react-nestjs/apps/web/src/router/modules/.gitkeep +0 -0
  617. package/template-vp-react-shadcn/.oxfmtrc.jsonc +0 -8
  618. package/template-vp-react-shadcn/.oxlintrc.jsonc +0 -3
  619. package/template-vp-react-shadcn/src/components/ui/sonner.tsx +0 -43
  620. /package/template-vp-monorepo-react-nestjs/apps/web/src/assets/{hero.png → image/hero.png} +0 -0
  621. /package/template-vp-monorepo-react-nestjs/apps/web/src/assets/{react.svg → svg/react.svg} +0 -0
  622. /package/template-vp-monorepo-react-nestjs/apps/web/src/assets/{vite.svg → svg/vite.svg} +0 -0
@@ -0,0 +1,4507 @@
1
+ # NestJS 中转 MinIO 的 10 MiB 大文件分片上传教程
2
+
3
+ > 适用目录:`packages/create-bubbles/template-vp-monorepo-react-nestjs`
4
+ >
5
+ > 适用技术栈:NestJS 11、Fastify、Drizzle、PostgreSQL、MinIO、React。
6
+ >
7
+ > 本文面向以前端开发为主、刚开始写 NestJS 的开发者。请严格按顺序操作,不要跳过数据库迁移、原始流解析和字节数校验。
8
+ >
9
+ > 当前状态(2026-09-17):仓库已经包含上传模块、分片上传接口、流式中转和 `upload_sessions` 表;本文保留为实施教程,不等同于当前代码验收报告。活动分片租约、定时清理等第 18 步生产加固能力尚未落地。
10
+
11
+ ## 先看最终方案
12
+
13
+ 本教程实现的是“后端中转”,不是“浏览器直传 MinIO”:
14
+
15
+ ```text
16
+ 浏览器
17
+ │
18
+ │ 1. JSON:初始化、查询、完成、取消
19
+ │ 2. application/octet-stream:每次发送一个 10 MiB 分片
20
+ ▼
21
+ NestJS + Fastify
22
+ │
23
+ │ 鉴权、会话归属校验、分片编号校验、真实字节数校验
24
+ │ 不落临时文件,不把整个分片转成 Buffer
25
+ ▼
26
+ MinIO S3 Multipart Upload
27
+ │
28
+ ▼
29
+ 一个完整的私有对象
30
+ ```
31
+
32
+ 这意味着:
33
+
34
+ - 浏览器永远只请求 NestJS,不请求 MinIO。
35
+ - MinIO 可以使用 Docker 内部地址 `http://minio:9000`,前提是 NestJS 也在同一个 Compose 网络中。
36
+ - NestJS 在 Windows 宿主机运行时,应使用 `http://127.0.0.1:9000`,不能使用 `http://minio:9000`。
37
+ - 不需要 MinIO 公网域名、MinIO CORS、预签名 URL、`@aws-sdk/s3-request-presigner`。如果浏览器与 NestJS API 跨域,仍然要配置 **NestJS/API 网关自己的 CORS**;这里只是不需要配置 MinIO CORS。
38
+ - 不需要 `@fastify/multipart`,因为分片请求不是 HTTP `multipart/form-data`。
39
+ - 每上传 10 MiB,后端会同时承担约 10 MiB 的流入和 10 MiB 的流出。流式中转解决的是内存占用,不会消除后端带宽消耗。
40
+
41
+ ### 固定协议
42
+
43
+ ```ts
44
+ export const UPLOAD_PART_SIZE = 10 * 1024 * 1024 // 10 MiB = 10,485,760 字节
45
+ export const UPLOAD_MAX_PARTS = 10_000
46
+ export const UPLOAD_MAX_FILE_SIZE = 90 * 1024 * 1024 * 1024 // 90 GiB
47
+ ```
48
+
49
+ 必须遵守以下规则:
50
+
51
+ 1. `partNumber` 从 `1` 开始,不是从 `0` 开始。
52
+ 2. 除最后一片外,每片必须正好是 `10,485,760` 字节。
53
+ 3. 最后一片必须是 `1` 到 `10,485,760` 字节。
54
+ 4. 0 字节文件不走本教程的 Multipart 流程,直接拒绝。
55
+ 5. MinIO/S3 最多允许 10,000 片。10 MiB 分片的理论上限约 97.66 GiB,本模板主动限制为 90 GiB。
56
+ 6. 前端默认同时上传 3 片,最多建议 5 片;同一片总共最多尝试 3 次,也就是首次失败后最多再重试 2 次。
57
+ 7. 同一个 `partNumber` 重新上传会覆盖旧分片,因此分片重试是幂等的。
58
+ 8. 最终合并时,由后端调用 MinIO `ListParts` 获取真实分片,不能直接信任前端提交的 ETag 数组。
59
+
60
+ ### API 约定
61
+
62
+ 当前模板的浏览器请求统一带 `/api` 前缀,Vite 或 Nginx 去掉 `/api` 后,NestJS Controller 才会收到 `/uploads/multipart`。前端写 URL 时看“浏览器地址”,后端写 Controller 时看“NestJS 路由”。
63
+
64
+ | 方法 | 浏览器地址 | NestJS 路由 | 请求体 | 作用 |
65
+ | -------- | ----------------------------------------------------------- | ------------------------------------------------------- | ---------- | ---------------------------------- |
66
+ | `POST` | `/api/uploads/multipart` | `/uploads/multipart` | JSON | 初始化上传会话 |
67
+ | `GET` | `/api/uploads/multipart/:uploadSessionId` | `/uploads/multipart/:uploadSessionId` | 无 | 查询状态和已上传分片,用于断点续传 |
68
+ | `PUT` | `/api/uploads/multipart/:uploadSessionId/parts/:partNumber` | `/uploads/multipart/:uploadSessionId/parts/:partNumber` | 原始二进制 | 上传一个分片 |
69
+ | `POST` | `/api/uploads/multipart/:uploadSessionId/complete` | `/uploads/multipart/:uploadSessionId/complete` | 无 | 校验并合并全部分片 |
70
+ | `DELETE` | `/api/uploads/multipart/:uploadSessionId` | `/uploads/multipart/:uploadSessionId` | 无 | 取消上传并释放 MinIO 分片 |
71
+
72
+ 浏览器只拿到业务字段 `uploadSessionId`。MinIO 自己的 `UploadId` 只保存在 PostgreSQL 中,绝不能返回前端。
73
+
74
+ ### 状态流
75
+
76
+ ```text
77
+ uploading ──完成请求──> completing ──MinIO 合并成功──> completed
78
+ │
79
+ ├──取消请求──> aborting ──MinIO 清理成功──> aborted
80
+ │
81
+ └──过期清理──────────────────────────────> expired
82
+ ```
83
+
84
+ `completed` 是终态,取消接口不能删除已经完成的最终对象。`aborted` 和 `expired` 也不能继续上传分片。
85
+
86
+ ## 实施前检查
87
+
88
+ ### 本步目标
89
+
90
+ 确认当前模板的端口、基础服务和开发约束,避免代码正确但环境不通。
91
+
92
+ ### 文件位置
93
+
94
+ - `docker-compose.yml`
95
+ - `apps/server/.env`
96
+ - `apps/server/.env.development.local`
97
+ - `apps/web/.env.dev`
98
+ - `apps/server/src/common/adapters/fastify.adapter.ts`
99
+ - `apps/server/src/database/schema.ts`
100
+ - `apps/web/src/utils/request/index.ts`
101
+
102
+ ### 必要说明
103
+
104
+ 当前模板有三个需要先确认的现状:
105
+
106
+ 1. `apps/server/.env` 当前端口是 `13000`。
107
+ 2. `apps/web/.env.dev` 当前 API 目标也是 `http://localhost:13000`;修改任一端口后必须同步另一端配置。
108
+ 3. `apps/web/src/utils/request/index.ts` 当前使用 `isWrapped: false`,与 Server 直接返回业务对象的响应形式一致。
109
+
110
+ 旧版后端待办已经退出正式文档体系,其中部分状态也落后于当前源码。写代码时应以 `apps/server/src` 的实际内容和 `.spaces/` 中的现行专题文档为准。
111
+
112
+ ### 验证
113
+
114
+ 在模板根目录执行:
115
+
116
+ ```powershell
117
+ docker compose config
118
+ pnpm --filter server build
119
+ ```
120
+
121
+ 预期结果:
122
+
123
+ - Compose 配置可以正常解析。
124
+ - Server 在增加上传功能前可以构建;如果这里已经失败,应先记录现有错误,不要把它误判成上传代码造成的。
125
+
126
+ ### 常见错误
127
+
128
+ - 前端一直报网络错误,却只检查上传代码,没有确认前后端当前端口是否一致。
129
+ - 把 MinIO 控制台端口 9001 当成 S3 API 端口。S3 API 是 9000。
130
+ - 使用已经退出正式文档体系的历史待办覆盖当前源码事实。
131
+
132
+ ---
133
+
134
+ ## 第 1 步:准备本地 MinIO 和私有 Bucket
135
+
136
+ ### 本步目标
137
+
138
+ 启动 MinIO,并自动创建一个名为 `uploads` 的私有 Bucket。NestJS 只连接 9000,9001 仅供本地开发查看控制台。
139
+
140
+ ### 文件位置
141
+
142
+ 修改:
143
+
144
+ ```text
145
+ docker-compose.yml
146
+ ```
147
+
148
+ ### 精确修改
149
+
150
+ 将文件替换为下面内容。PostgreSQL、Redis 保留不变,只为 MinIO 增加健康检查和一次性初始化服务:
151
+
152
+ ```yaml
153
+ services:
154
+ postgres:
155
+ image: postgres:18-alpine
156
+ container_name: vp-postgres
157
+ restart: unless-stopped
158
+ environment:
159
+ POSTGRES_DB: postgres
160
+ POSTGRES_USER: postgres
161
+ POSTGRES_PASSWORD: ml
162
+ ports:
163
+ - '5432:5432'
164
+ volumes:
165
+ - postgres18-data:/var/lib/postgresql
166
+
167
+ redis:
168
+ image: redis:8-alpine
169
+ container_name: vp-redis
170
+ restart: unless-stopped
171
+ command: redis-server --appendonly yes --requirepass ml
172
+ ports:
173
+ - '6379:6379'
174
+ volumes:
175
+ - redis-data:/data
176
+
177
+ minio:
178
+ image: minio/minio:latest
179
+ container_name: vp-minio
180
+ restart: unless-stopped
181
+ environment:
182
+ MINIO_ROOT_USER: minio
183
+ MINIO_ROOT_PASSWORD: minio123456
184
+ command: server /data --console-address ":9001"
185
+ ports:
186
+ - '127.0.0.1:9000:9000'
187
+ - '127.0.0.1:9001:9001'
188
+ volumes:
189
+ - minio-data:/data
190
+ minio-init:
191
+ image: minio/mc:latest
192
+ container_name: vp-minio-init
193
+ depends_on:
194
+ minio:
195
+ condition: service_started
196
+ environment:
197
+ MINIO_ROOT_USER: minio
198
+ MINIO_ROOT_PASSWORD: minio123456
199
+ MINIO_BUCKET: uploads
200
+ entrypoint: >
201
+ /bin/sh -c "
202
+ until mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD;
203
+ do sleep 1; done;
204
+ mc mb --ignore-existing local/$$MINIO_BUCKET;
205
+ mc anonymous set none local/$$MINIO_BUCKET;
206
+ "
207
+ restart: 'no'
208
+
209
+ volumes:
210
+ postgres18-data:
211
+ redis-data:
212
+ minio-data:
213
+ ```
214
+
215
+ ### 代码解释
216
+
217
+ - `minio` 保存真正的对象数据。
218
+ - `minio-init` 使用 MinIO Client 创建 Bucket,执行完成后正常退出。它会循环等待 S3 API 可用,因此不依赖 MinIO 镜像里是否碰巧内置 `curl`。
219
+ - `mc anonymous set none` 保证 Bucket 不是匿名公开读写。
220
+ - 9000 和 9001 只绑定 `127.0.0.1`,避免本地演示账号和控制台监听整张局域网网卡。
221
+ - 本地开发暂时沿用模板已有的 Root 账号。生产环境必须改成最小权限服务账号。
222
+ - `latest` 只适合本地演示。模板准备发布时,应把 `minio/minio` 和 `minio/mc` 固定为经过测试的版本或镜像摘要,并让二者版本匹配。
223
+
224
+ ### 文件位置
225
+
226
+ 向下面这个被 Git 忽略的本地文件追加配置,不要覆盖文件里已有的数据库和 Session 配置:
227
+
228
+ ```text
229
+ apps/server/.env.development.local
230
+ ```
231
+
232
+ 追加:
233
+
234
+ ```dotenv
235
+ # MinIO / S3,NestJS 在 Windows 宿主机运行时使用 127.0.0.1
236
+ STORAGE_ENDPOINT=http://127.0.0.1:9000
237
+ STORAGE_REGION=us-east-1
238
+ STORAGE_ACCESS_KEY_ID=minio
239
+ STORAGE_SECRET_ACCESS_KEY=minio123456
240
+ STORAGE_BUCKET=uploads
241
+ ```
242
+
243
+ 如果将来把 NestJS 也放进同一个 Compose 文件,只有那时才改成:
244
+
245
+ ```dotenv
246
+ STORAGE_ENDPOINT=http://minio:9000
247
+ ```
248
+
249
+ 不要把任何 `STORAGE_ACCESS_KEY_ID` 或 `STORAGE_SECRET_ACCESS_KEY` 写进 `apps/web/.env*`,也不要加 `VITE_` 前缀。
250
+
251
+ ### 验证
252
+
253
+ ```powershell
254
+ docker compose up -d postgres redis minio minio-init
255
+ docker compose ps
256
+ docker compose logs minio-init
257
+ ```
258
+
259
+ 预期结果:
260
+
261
+ - `vp-minio` 状态为 running。
262
+ - `vp-minio-init` 日志显示 alias 和 Bucket 创建成功,然后容器退出码为 0。
263
+ - 浏览器打开 `http://127.0.0.1:9001`,使用本地账号登录后能看到私有 Bucket `uploads`。
264
+
265
+ ### 常见错误
266
+
267
+ - NestJS 在宿主机运行,却填写 `http://minio:9000`:Windows 无法解析 Compose 服务名。
268
+ - 填写 `http://127.0.0.1:9001`:9001 是控制台,不是 S3 API。
269
+ - Bucket 名与 `STORAGE_BUCKET` 不一致:初始化 Multipart 会返回 `NoSuchBucket`。
270
+ - 生产环境继续暴露 9000/9001 到公网,或继续使用 Root 账号。
271
+ - 某个 MinIO 镜像不包含 `curl`,导致健康检查失败。此时先用 `docker compose logs minio` 确认服务已启动,再把健康检查改成该固定镜像实际支持的探针;不要直接删除生产健康检查。
272
+
273
+ ---
274
+
275
+ ## 第 2 步:安装 S3 SDK
276
+
277
+ ### 本步目标
278
+
279
+ 使用 AWS SDK v3 的底层 Multipart 命令连接 MinIO。
280
+
281
+ ### 文件位置
282
+
283
+ 修改:
284
+
285
+ ```text
286
+ pnpm-workspace.yaml
287
+ apps/server/package.json
288
+ ```
289
+
290
+ ### 精确修改
291
+
292
+ 在根目录 `pnpm-workspace.yaml` 的 `catalog:` 下增加:
293
+
294
+ ```yaml
295
+ '@aws-sdk/client-s3': ^3.910.0
296
+ ```
297
+
298
+ 在 `apps/server/package.json` 的 `dependencies` 中增加:
299
+
300
+ ```json
301
+ "@aws-sdk/client-s3": "catalog:"
302
+ ```
303
+
304
+ 然后在模板根目录执行:
305
+
306
+ ```powershell
307
+ pnpm install
308
+ ```
309
+
310
+ ### 代码解释
311
+
312
+ 本文只需要:
313
+
314
+ - `CreateMultipartUploadCommand`
315
+ - `UploadPartCommand`
316
+ - `ListPartsCommand`
317
+ - `CompleteMultipartUploadCommand`
318
+ - `AbortMultipartUploadCommand`
319
+ - `HeadBucketCommand`
320
+ - `HeadObjectCommand`
321
+
322
+ 不要安装:
323
+
324
+ - `@aws-sdk/s3-request-presigner`:本方案不生成浏览器直传签名。
325
+ - `@aws-sdk/lib-storage`:它会再次自动切片,和我们自己的浏览器断点续传协议冲突。
326
+ - `@fastify/multipart`:分片请求体是原始二进制,不是表单。
327
+
328
+ 不要从 `^3.0.0` 这种过宽下限开始。这里给出一个明确的 3.x 最低版本,实际安装结果由 `pnpm-lock.yaml` 精确锁定;模板升级 SDK 后必须重新跑完整上传测试,并提交新的 Lockfile。
329
+
330
+ ### 验证
331
+
332
+ ```powershell
333
+ pnpm --filter server exec node -e "import('@aws-sdk/client-s3').then(() => console.log('s3 sdk ok'))"
334
+ ```
335
+
336
+ 预期输出:
337
+
338
+ ```text
339
+ s3 sdk ok
340
+ ```
341
+
342
+ ### 常见错误
343
+
344
+ - 只改 `apps/server/package.json`,忘记根 Catalog,导致模板依赖风格不一致。
345
+ - 同时使用 `@aws-sdk/lib-storage`,结果浏览器切一次、后端 SDK 又切一次。
346
+ - 安装 presigner 后误以为后端中转也需要 MinIO 公网地址。
347
+
348
+ ---
349
+
350
+ ## 第 3 步:增加 Storage 配置
351
+
352
+ ### 本步目标
353
+
354
+ 集中读取和校验 MinIO 连接配置,并固定 24 小时会话有效期。10 MiB 和 90 GiB 业务规则只在第 4 步的 `upload.constants.ts` 中定义,避免出现两个来源。
355
+
356
+ ### 文件位置
357
+
358
+ 新建:
359
+
360
+ ```text
361
+ apps/server/src/config/storage.config.ts
362
+ ```
363
+
364
+ ### 完整代码
365
+
366
+ ```ts
367
+ import { registerAs } from '@nestjs/config'
368
+
369
+ const SESSION_TTL_MS = 24 * 60 * 60 * 1000
370
+
371
+ function readRequired(name: string): string {
372
+ const value = process.env[name]?.trim()
373
+
374
+ if (!value) {
375
+ throw new Error(`${name} is required`)
376
+ }
377
+
378
+ return value
379
+ }
380
+
381
+ function readEndpoint(): string {
382
+ const value = readRequired('STORAGE_ENDPOINT')
383
+ const url = new URL(value)
384
+
385
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
386
+ throw new Error('STORAGE_ENDPOINT must use http or https')
387
+ }
388
+
389
+ return url.toString().replace(/\/$/, '')
390
+ }
391
+
392
+ export default registerAs('storage', () => ({
393
+ endpoint: readEndpoint(),
394
+ region: process.env.STORAGE_REGION?.trim() || 'us-east-1',
395
+ accessKeyId: readRequired('STORAGE_ACCESS_KEY_ID'),
396
+ secretAccessKey: readRequired('STORAGE_SECRET_ACCESS_KEY'),
397
+ bucket: readRequired('STORAGE_BUCKET'),
398
+ forcePathStyle: true,
399
+ sessionTtlMs: SESSION_TTL_MS,
400
+ }))
401
+ ```
402
+
403
+ ### 文件位置
404
+
405
+ 修改:
406
+
407
+ ```text
408
+ apps/server/src/config/index.ts
409
+ ```
410
+
411
+ 将其替换为:
412
+
413
+ ```ts
414
+ export { default as appConfig } from './app.config'
415
+ export { default as databaseConfig } from './database.config'
416
+ export { default as llmConfig } from './llm.config'
417
+ export { default as redisConfig } from './redis.config'
418
+ export { default as sessionConfig } from './session.config'
419
+ export { default as storageConfig } from './storage.config'
420
+ ```
421
+
422
+ ### 文件位置
423
+
424
+ 修改:
425
+
426
+ ```text
427
+ apps/server/src/app.module.ts
428
+ ```
429
+
430
+ 这一文件稍后还要导入 `UploadModule`。现在先把顶部配置导入改成:
431
+
432
+ ```ts
433
+ import {
434
+ appConfig,
435
+ databaseConfig,
436
+ llmConfig,
437
+ redisConfig,
438
+ sessionConfig,
439
+ storageConfig,
440
+ } from '@/config'
441
+ ```
442
+
443
+ 再把 `ConfigModule.forRoot` 中的 `load` 改成:
444
+
445
+ ```ts
446
+ load: [appConfig, databaseConfig, llmConfig, redisConfig, sessionConfig, storageConfig],
447
+ ```
448
+
449
+ ### 代码解释
450
+
451
+ - MinIO 兼容 S3,但本地通常必须使用 `forcePathStyle: true`。
452
+ - `storage.config.ts` 只负责连接与会话 TTL;分片大小、最大文件和最大分片数统一放在 `upload.constants.ts`。
453
+ - 密钥缺失时让应用启动失败,比运行到第一次上传才报错更容易定位。
454
+ - 后端中转时 `endpoint` 只需要对 NestJS 可达,不需要对浏览器可达。
455
+
456
+ ### 验证
457
+
458
+ 先临时注释本地文件中的 `STORAGE_ENDPOINT`,运行:
459
+
460
+ ```powershell
461
+ pnpm --filter server dev
462
+ ```
463
+
464
+ 预期应用明确提示 `STORAGE_ENDPOINT is required`。恢复变量后重新启动,应不再出现该错误。
465
+
466
+ ### 常见错误
467
+
468
+ - 在配置里写死真实生产密钥并提交 Git。
469
+ - 忘记在 `config/index.ts` 导出,或忘记放进 `ConfigModule.forRoot({ load: [...] })`。
470
+ - MinIO 使用 path-style,但 S3Client 没设置 `forcePathStyle: true`。
471
+
472
+ ---
473
+
474
+ ## 第 4 步:增加上传常量和分片计算
475
+
476
+ ### 本步目标
477
+
478
+ 让后端成为分片大小的唯一规则来源。前端传来的分片编号只能用于定位,不能决定这一片应该多大。
479
+
480
+ ### 文件位置
481
+
482
+ 新建:
483
+
484
+ ```text
485
+ apps/server/src/modules/upload/upload.constants.ts
486
+ ```
487
+
488
+ ### 完整代码
489
+
490
+ ```ts
491
+ export const UPLOAD_PART_SIZE = 10 * 1024 * 1024
492
+ export const UPLOAD_MAX_PARTS = 10_000
493
+ export const UPLOAD_MAX_FILE_SIZE = 90 * 1024 * 1024 * 1024
494
+
495
+ export function calculateTotalParts(fileSize: number): number {
496
+ return Math.ceil(fileSize / UPLOAD_PART_SIZE)
497
+ }
498
+
499
+ export function calculateExpectedPartSize(
500
+ fileSize: number,
501
+ totalParts: number,
502
+ partNumber: number,
503
+ ): number {
504
+ if (partNumber < 1 || partNumber > totalParts) {
505
+ throw new RangeError('partNumber is outside the upload range')
506
+ }
507
+
508
+ if (partNumber < totalParts) {
509
+ return UPLOAD_PART_SIZE
510
+ }
511
+
512
+ return fileSize - (totalParts - 1) * UPLOAD_PART_SIZE
513
+ }
514
+ ```
515
+
516
+ ### 代码解释
517
+
518
+ 以 25 MiB 文件为例:
519
+
520
+ ```text
521
+ totalParts = 3
522
+ 第 1 片 = 10 MiB
523
+ 第 2 片 = 10 MiB
524
+ 第 3 片 = 5 MiB
525
+ ```
526
+
527
+ 最后一片大小由数据库中的 `fileSize` 计算,不能信任浏览器传一个 `chunkSize`。
528
+
529
+ ### 验证
530
+
531
+ 后面会增加单元测试。现在可以先人工确认:
532
+
533
+ ```ts
534
+ calculateTotalParts(25 * 1024 * 1024) === 3
535
+ calculateExpectedPartSize(25 * 1024 * 1024, 3, 3) === 5 * 1024 * 1024
536
+ ```
537
+
538
+ ### 常见错误
539
+
540
+ - 前端编号从 0 开始,后端编号从 1 开始,导致第一片覆盖或完成顺序错误。
541
+ - 使用 `Math.floor` 计算总片数,最后不足 10 MiB 的部分丢失。
542
+ - 接受任意小于 10 MiB 的中间片。S3 要求除最后一片外至少 5 MiB,而本协议进一步固定为正好 10 MiB,便于校验和恢复。
543
+
544
+ ---
545
+
546
+ ## 第 5 步:建立上传会话表
547
+
548
+ ### 本步目标
549
+
550
+ PostgreSQL 只保存“上传会话和状态”,分片本身保存在 MinIO。已上传哪些片以 MinIO `ListParts` 为真实依据,不单独建立分片表。
551
+
552
+ ### 文件位置
553
+
554
+ 修改:
555
+
556
+ ```text
557
+ apps/server/src/database/schema.ts
558
+ ```
559
+
560
+ ### 完整代码
561
+
562
+ 将当前文件替换为:
563
+
564
+ ```ts
565
+ import {
566
+ bigint,
567
+ index,
568
+ integer,
569
+ pgEnum,
570
+ pgTable,
571
+ text,
572
+ timestamp,
573
+ uniqueIndex,
574
+ uuid,
575
+ varchar,
576
+ } from 'drizzle-orm/pg-core'
577
+
578
+ export const userStatusEnum = pgEnum('user_status', ['active', 'locked', 'disabled'])
579
+
580
+ export const users = pgTable('users', {
581
+ id: uuid('id').defaultRandom().primaryKey(),
582
+ name: varchar('name', { length: 100 }).notNull(),
583
+ account: varchar('account', { length: 32 }).notNull().unique(),
584
+ passwordHash: varchar('password_hash', { length: 255 }).notNull(),
585
+ status: userStatusEnum('status').default('active').notNull(),
586
+ createdAt: timestamp('create_at').defaultNow().notNull(),
587
+ updatedAt: timestamp('update_at', { withTimezone: true }).defaultNow().notNull(),
588
+ })
589
+
590
+ export const uploadStatusEnum = pgEnum('upload_status', [
591
+ 'uploading',
592
+ 'completing',
593
+ 'completed',
594
+ 'aborting',
595
+ 'aborted',
596
+ 'expired',
597
+ ])
598
+
599
+ export const uploadSessions = pgTable(
600
+ 'upload_sessions',
601
+ {
602
+ id: uuid('id').primaryKey(),
603
+ ownerId: uuid('owner_id')
604
+ .notNull()
605
+ .references(() => users.id, { onDelete: 'restrict' }),
606
+ clientUploadId: uuid('client_upload_id').notNull(),
607
+ bucket: varchar('bucket', { length: 63 }).notNull(),
608
+ objectKey: varchar('object_key', { length: 1024 }).notNull(),
609
+ storageUploadId: text('storage_upload_id').notNull(),
610
+ originalName: varchar('original_name', { length: 255 }).notNull(),
611
+ contentType: varchar('content_type', { length: 255 }).notNull(),
612
+ fileSize: bigint('file_size', { mode: 'number' }).notNull(),
613
+ partSize: integer('part_size').notNull(),
614
+ totalParts: integer('total_parts').notNull(),
615
+ status: uploadStatusEnum('status').default('uploading').notNull(),
616
+ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
617
+ objectEtag: text('object_etag'),
618
+ createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
619
+ updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
620
+ completedAt: timestamp('completed_at', { withTimezone: true }),
621
+ },
622
+ (table) => [
623
+ uniqueIndex('upload_sessions_owner_client_uq').on(table.ownerId, table.clientUploadId),
624
+ uniqueIndex('upload_sessions_bucket_key_uq').on(table.bucket, table.objectKey),
625
+ index('upload_sessions_owner_status_expires_idx').on(
626
+ table.ownerId,
627
+ table.status,
628
+ table.expiresAt,
629
+ ),
630
+ index('upload_sessions_status_expires_idx').on(table.status, table.expiresAt),
631
+ index('upload_sessions_status_updated_idx').on(table.status, table.updatedAt),
632
+ ],
633
+ )
634
+ ```
635
+
636
+ ### 字段解释
637
+
638
+ - `id`:对前端公开的业务上传会话 ID。
639
+ - `clientUploadId`:前端在初始化前生成的 UUID,用于初始化幂等。
640
+ - `storageUploadId`:MinIO 返回的 Multipart UploadId,只能留在后端。
641
+ - `ownerId`:当前登录用户。后续所有查询都必须同时带 `id + ownerId`。
642
+ - `objectKey`:后端生成,不能直接使用客户端文件名。
643
+ - `fileSize/partSize/totalParts`:后端校验每片大小的依据。
644
+ - `objectEtag`:完成后保存 MinIO 返回值。Multipart ETag 不是整个文件的 MD5。
645
+
646
+ `ownerId` 使用 `onDelete: 'restrict'` 是有意的:如果直接级联删除上传会话,会先丢掉 MinIO `UploadId/objectKey`,未完成分片和已完成对象都可能变成孤儿。删除用户前应先由业务流程取消未完成上传、处理最终对象,再删除用户;也可以采用软删除。
647
+
648
+ `ownerId + status + expiresAt` 服务于某个用户的会话查询;`status + expiresAt` 和 `status + updatedAt` 服务于第 18 步的全局清理任务。全局扫描不能只依赖以 `ownerId` 开头的索引。
649
+
650
+ `ownerId + clientUploadId` 唯一约束解决下面这个场景:
651
+
652
+ ```text
653
+ MinIO 初始化成功
654
+ ↓
655
+ 数据库写入成功
656
+ ↓
657
+ 响应返回前断网
658
+ ↓
659
+ 前端使用同一个 clientUploadId 重试
660
+ ```
661
+
662
+ 重试时后端返回原会话,而不是再制造一个孤儿 Multipart。
663
+
664
+ ### 文件位置
665
+
666
+ 修改:
667
+
668
+ ```text
669
+ apps/server/.gitignore
670
+ ```
671
+
672
+ 删除文件末尾这条规则:
673
+
674
+ ```gitignore
675
+ drizzle
676
+ ```
677
+
678
+ 保留 `# Drizzle` 注释没有问题。迁移文件属于项目源码,必须提交到模板中。
679
+
680
+ ### 生成迁移
681
+
682
+ 当前仓库完整迁移链受企业层级残留 `0003` 和未确认邀请实现 `0004` 阻断。此处当前只生成并审阅上传迁移,不得执行完整 `db:migrate`,也不得用 `db:push` 或手工跳号绕过。阻断解除后的执行流程以[数据库迁移指南](../../05.运行维护/数据库迁移/操作指南.md)为准。
683
+
684
+ 在模板根目录执行:
685
+
686
+ ```powershell
687
+ pnpm --filter server db:generate
688
+ ```
689
+
690
+ 不要手写迁移 SQL,也不要只在本地执行 `db:push` 后就结束。模板需要可重复执行、可提交的迁移历史;当前生成结果只能进入审阅,不能据此修改数据库。
691
+
692
+ ### 验证
693
+
694
+ ```powershell
695
+ pnpm --filter server db:studio
696
+ ```
697
+
698
+ 预期在 Drizzle Studio 中看到 `upload_sessions` 表和 `upload_status` 枚举,并看到两个唯一索引。
699
+
700
+ 再执行:
701
+
702
+ ```powershell
703
+ git status --short apps/server/drizzle apps/server/src/database/schema.ts
704
+ ```
705
+
706
+ 预期迁移文件出现在 Git 状态中,而不是被 `.gitignore` 隐藏。
707
+
708
+ ### 常见错误
709
+
710
+ - 所有接口只按 `uploadSessionId` 查询,没有带 `ownerId`,造成越权读取和上传。
711
+ - 把每片二进制或 ETag 当成唯一事实存数据库。可能出现“MinIO 已成功,数据库记录失败”,所以最终仍要查询 MinIO。
712
+ - 使用客户端原始文件名作为 Key,导致路径穿越、重名覆盖或暴露用户信息。
713
+ - 生成迁移后忘记移除 `drizzle` 忽略规则。
714
+
715
+ ---
716
+
717
+ ## 第 6 步:让 Fastify 接收原始二进制流
718
+
719
+ ### 本步目标
720
+
721
+ Fastify 默认没有 `application/octet-stream` 解析器,而且普通请求体限制通常约 1 MiB。这里要把原始 Node.js `Readable` 直接交给 Controller,不能先读成 Buffer。
722
+
723
+ ### 文件位置
724
+
725
+ 修改:
726
+
727
+ ```text
728
+ apps/server/src/common/adapters/fastify.adapter.ts
729
+ ```
730
+
731
+ ### 完整代码
732
+
733
+ 将文件替换为:
734
+
735
+ ```ts
736
+ import { FastifyAdapter } from '@nestjs/platform-fastify'
737
+ import { randomUUID } from 'node:crypto'
738
+
739
+ export function createFastifyAdapter() {
740
+ const adapter = new FastifyAdapter({
741
+ genReqId: () => randomUUID(),
742
+ logger: false,
743
+ })
744
+
745
+ adapter
746
+ .getInstance()
747
+ .addContentTypeParser('application/octet-stream', (_request, payload, done) => {
748
+ done(null, payload)
749
+ })
750
+
751
+ adapter.getInstance().addHook('onRequest', (request, reply, done) => {
752
+ reply.header('x-request-id', request.id)
753
+ done()
754
+ })
755
+
756
+ return adapter
757
+ }
758
+ ```
759
+
760
+ ### 代码解释
761
+
762
+ `payload` 是请求的可读流。`done(null, payload)` 只是把流交给后续代码,没有把 10 MiB 分片读入内存。
763
+
764
+ 示例没有全局设置 `trustProxy: true`。本地直连不需要它;生产如果按客户端 IP 限流,应在确认反向代理拓扑后,只信任明确的代理 IP、CIDR 或固定跳数,并让边缘代理覆盖客户端传入的 `X-Forwarded-*`,不能无条件信任任意来源的转发头。
765
+
766
+ 不要写成:
767
+
768
+ ```ts
769
+ parseAs: 'buffer'
770
+ ```
771
+
772
+ 也不要在 Controller 中调用:
773
+
774
+ ```ts
775
+ await request.body.arrayBuffer()
776
+ await file.toBuffer()
777
+ ```
778
+
779
+ 自定义流解析器不会替你完成可靠的“真实字节数”限制,所以后面还必须增加计数 Transform。只设置 Fastify `bodyLimit` 不够。
780
+
781
+ ### 验证
782
+
783
+ 完成 Controller 接线后执行:
784
+
785
+ ```powershell
786
+ curl.exe -i -X PUT http://127.0.0.1:13000/uploads/multipart/not-a-uuid/parts/1 -H "Content-Type: application/octet-stream" --data-binary "test"
787
+ ```
788
+
789
+ 预期不再收到“没有 application/octet-stream 解析器”的 415。由于路径和鉴权无效,最终应由鉴权或 Zod 返回 401/400,这说明请求已经进入 NestJS。
790
+
791
+ ### 常见错误
792
+
793
+ - 安装 `@fastify/multipart` 后使用 `request.file()`:那是表单上传,不是本协议。
794
+ - 使用 `parseAs: 'buffer'`:并发 100 个分片时,至少可能额外占用约 1 GiB 内存。
795
+ - 以为注册原始流后 Fastify 会自动拒绝 10 MiB+1,实际没有做真实流计数。
796
+
797
+ ---
798
+
799
+ ## 第 7 步:实现严格字节数 Transform
800
+
801
+ ### 本步目标
802
+
803
+ 同时校验两层大小:
804
+
805
+ 1. 请求头 `Content-Length` 必须等于后端计算值,用于尽早拒绝。
806
+ 2. 实际读到的流字节数也必须等于计算值,防止伪造请求头或中途断流。
807
+
808
+ ### 文件位置
809
+
810
+ 新建:
811
+
812
+ ```text
813
+ apps/server/src/modules/upload/storage/exact-size.transform.ts
814
+ ```
815
+
816
+ ### 完整代码
817
+
818
+ ```ts
819
+ import { Transform, type TransformCallback } from 'node:stream'
820
+
821
+ export class ExactSizeError extends Error {
822
+ constructor(
823
+ readonly expectedBytes: number,
824
+ readonly receivedBytes: number,
825
+ ) {
826
+ super(`Expected ${expectedBytes} bytes, received ${receivedBytes}`)
827
+ this.name = ExactSizeError.name
828
+ }
829
+ }
830
+
831
+ export class ExactSizeTransform extends Transform {
832
+ receivedBytes = 0
833
+
834
+ constructor(private readonly expectedBytes: number) {
835
+ super()
836
+ }
837
+
838
+ override _transform(chunk: unknown, encoding: BufferEncoding, callback: TransformCallback): void {
839
+ const byteLength = Buffer.isBuffer(chunk)
840
+ ? chunk.length
841
+ : Buffer.byteLength(String(chunk), encoding)
842
+
843
+ this.receivedBytes += byteLength
844
+
845
+ if (this.receivedBytes > this.expectedBytes) {
846
+ callback(new ExactSizeError(this.expectedBytes, this.receivedBytes))
847
+ return
848
+ }
849
+
850
+ callback(null, chunk)
851
+ }
852
+
853
+ override _flush(callback: TransformCallback): void {
854
+ if (this.receivedBytes !== this.expectedBytes) {
855
+ callback(new ExactSizeError(this.expectedBytes, this.receivedBytes))
856
+ return
857
+ }
858
+
859
+ callback()
860
+ }
861
+ }
862
+
863
+ export function findExactSizeError(cause: unknown): ExactSizeError | null {
864
+ let current = cause
865
+
866
+ for (let depth = 0; depth < 8; depth += 1) {
867
+ if (current instanceof ExactSizeError) {
868
+ return current
869
+ }
870
+
871
+ if (typeof current !== 'object' || current === null || !('cause' in current)) {
872
+ return null
873
+ }
874
+
875
+ current = current.cause
876
+ }
877
+
878
+ return null
879
+ }
880
+ ```
881
+
882
+ ### 代码解释
883
+
884
+ - 一旦超过预期大小,立即终止流,不会继续把恶意数据送到 MinIO。
885
+ - 流结束时不足预期大小,也会报错。
886
+ - `findExactSizeError` 会沿着 `cause` 查找,因为网络 SDK 有时会在外面包一层错误。
887
+ - 错误消息只用于服务端诊断,最终给浏览器的是统一的 `UPLOAD.PART_SIZE_MISMATCH`。
888
+
889
+ ### 验证
890
+
891
+ 后面会增加自动化测试,至少覆盖:
892
+
893
+ - 正好 10 MiB:通过。
894
+ - 10 MiB + 1 字节:在超出时立即失败。
895
+ - 10 MiB - 1 字节:在流结束时失败。
896
+
897
+ ### 常见错误
898
+
899
+ - 只检查 `Content-Length`,不检查实际流。
900
+ - 为了计算大小先把流读完,失去流式中转意义。
901
+ - 后端自动重试已经消费过的 Node.js 流。不可回放流应由前端重新发送对应 Blob。
902
+
903
+ ---
904
+
905
+ ## 第 8 步:定义存储接口并实现 MinIO 适配器
906
+
907
+ ### 本步目标
908
+
909
+ 业务 Service 不直接依赖 AWS SDK。以后即使换成 AWS S3、阿里云 OSS 的 S3 兼容层,Controller 和业务协议也不需要重写。
910
+
911
+ ### 文件位置
912
+
913
+ 新建:
914
+
915
+ ```text
916
+ apps/server/src/modules/upload/storage/storage.port.ts
917
+ ```
918
+
919
+ ### 完整代码
920
+
921
+ ```ts
922
+ import type { Readable } from 'node:stream'
923
+
924
+ export const STORAGE_PORT = Symbol('STORAGE_PORT')
925
+
926
+ export interface StoragePart {
927
+ partNumber: number
928
+ etag: string
929
+ size: number
930
+ }
931
+
932
+ export interface CreateMultipartInput {
933
+ bucket: string
934
+ objectKey: string
935
+ contentType: string
936
+ metadata: Record<string, string>
937
+ }
938
+
939
+ export interface UploadPartInput {
940
+ bucket: string
941
+ objectKey: string
942
+ storageUploadId: string
943
+ partNumber: number
944
+ body: Readable
945
+ contentLength: number
946
+ abortSignal?: AbortSignal
947
+ }
948
+
949
+ export interface MultipartIdentity {
950
+ bucket: string
951
+ objectKey: string
952
+ storageUploadId: string
953
+ }
954
+
955
+ export interface StorageObject {
956
+ etag: string | null
957
+ contentLength: number | null
958
+ metadata: Record<string, string>
959
+ }
960
+
961
+ export class StorageMultipartNotFoundError extends Error {
962
+ constructor(cause: unknown) {
963
+ super('Multipart upload does not exist', { cause })
964
+ this.name = StorageMultipartNotFoundError.name
965
+ }
966
+ }
967
+
968
+ export interface StoragePort {
969
+ createMultipartUpload(input: CreateMultipartInput): Promise<{ storageUploadId: string }>
970
+
971
+ uploadPart(input: UploadPartInput): Promise<{ etag: string }>
972
+
973
+ listParts(input: MultipartIdentity): Promise<StoragePart[]>
974
+
975
+ completeMultipartUpload(
976
+ input: MultipartIdentity & { parts: readonly StoragePart[] },
977
+ ): Promise<{ etag: string | null }>
978
+
979
+ abortMultipartUpload(input: MultipartIdentity): Promise<void>
980
+
981
+ headObject(input: { bucket: string; objectKey: string }): Promise<StorageObject | null>
982
+ }
983
+ ```
984
+
985
+ ### 文件位置
986
+
987
+ 新建:
988
+
989
+ ```text
990
+ apps/server/src/modules/upload/storage/minio-storage.adapter.ts
991
+ ```
992
+
993
+ ### 完整代码
994
+
995
+ ```ts
996
+ import {
997
+ AbortMultipartUploadCommand,
998
+ CompleteMultipartUploadCommand,
999
+ CreateMultipartUploadCommand,
1000
+ HeadBucketCommand,
1001
+ HeadObjectCommand,
1002
+ ListPartsCommand,
1003
+ S3Client,
1004
+ UploadPartCommand,
1005
+ } from '@aws-sdk/client-s3'
1006
+ import { Injectable, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common'
1007
+ import { ConfigService } from '@nestjs/config'
1008
+ import {
1009
+ StorageMultipartNotFoundError,
1010
+ type CreateMultipartInput,
1011
+ type MultipartIdentity,
1012
+ type StoragePart,
1013
+ type StoragePort,
1014
+ type UploadPartInput,
1015
+ } from './storage.port'
1016
+
1017
+ function hasErrorName(cause: unknown, expected: string): boolean {
1018
+ if (typeof cause !== 'object' || cause === null) {
1019
+ return false
1020
+ }
1021
+
1022
+ const record = cause as { name?: unknown; Code?: unknown }
1023
+ return record.name === expected || record.Code === expected
1024
+ }
1025
+
1026
+ function throwMappedMultipartError(cause: unknown): never {
1027
+ if (hasErrorName(cause, 'NoSuchUpload')) {
1028
+ throw new StorageMultipartNotFoundError(cause)
1029
+ }
1030
+
1031
+ throw cause
1032
+ }
1033
+
1034
+ @Injectable()
1035
+ export class MinioStorageAdapter implements StoragePort, OnModuleInit, OnModuleDestroy {
1036
+ private readonly client: S3Client
1037
+ private readonly bucket: string
1038
+
1039
+ constructor(config: ConfigService) {
1040
+ this.bucket = config.getOrThrow<string>('storage.bucket')
1041
+ this.client = new S3Client({
1042
+ endpoint: config.getOrThrow<string>('storage.endpoint'),
1043
+ region: config.getOrThrow<string>('storage.region'),
1044
+ credentials: {
1045
+ accessKeyId: config.getOrThrow<string>('storage.accessKeyId'),
1046
+ secretAccessKey: config.getOrThrow<string>('storage.secretAccessKey'),
1047
+ },
1048
+ forcePathStyle: config.getOrThrow<boolean>('storage.forcePathStyle'),
1049
+ // UploadPart 的 Body 是不可回放流,禁止 SDK 在后端自动重放。
1050
+ maxAttempts: 1,
1051
+ requestChecksumCalculation: 'WHEN_REQUIRED',
1052
+ responseChecksumValidation: 'WHEN_REQUIRED',
1053
+ })
1054
+ }
1055
+
1056
+ async onModuleInit(): Promise<void> {
1057
+ await this.assertBucketAvailable()
1058
+ }
1059
+
1060
+ onModuleDestroy(): void {
1061
+ this.client.destroy()
1062
+ }
1063
+
1064
+ private async assertBucketAvailable(): Promise<void> {
1065
+ await this.client.send(
1066
+ new HeadBucketCommand({
1067
+ Bucket: this.bucket,
1068
+ }),
1069
+ )
1070
+ }
1071
+
1072
+ async createMultipartUpload(input: CreateMultipartInput) {
1073
+ const response = await this.client.send(
1074
+ new CreateMultipartUploadCommand({
1075
+ Bucket: input.bucket,
1076
+ Key: input.objectKey,
1077
+ ContentType: input.contentType,
1078
+ Metadata: input.metadata,
1079
+ }),
1080
+ )
1081
+
1082
+ if (!response.UploadId) {
1083
+ throw new Error('Storage did not return an UploadId')
1084
+ }
1085
+
1086
+ return {
1087
+ storageUploadId: response.UploadId,
1088
+ }
1089
+ }
1090
+
1091
+ async uploadPart(input: UploadPartInput) {
1092
+ try {
1093
+ const response = await this.client.send(
1094
+ new UploadPartCommand({
1095
+ Bucket: input.bucket,
1096
+ Key: input.objectKey,
1097
+ UploadId: input.storageUploadId,
1098
+ PartNumber: input.partNumber,
1099
+ Body: input.body,
1100
+ ContentLength: input.contentLength,
1101
+ }),
1102
+ {
1103
+ abortSignal: input.abortSignal,
1104
+ },
1105
+ )
1106
+
1107
+ if (!response.ETag) {
1108
+ throw new Error('Storage did not return an ETag')
1109
+ }
1110
+
1111
+ return {
1112
+ etag: response.ETag,
1113
+ }
1114
+ } catch (cause: unknown) {
1115
+ throwMappedMultipartError(cause)
1116
+ }
1117
+ }
1118
+
1119
+ async listParts(input: MultipartIdentity): Promise<StoragePart[]> {
1120
+ const parts: StoragePart[] = []
1121
+ let partNumberMarker: string | undefined
1122
+
1123
+ try {
1124
+ for (;;) {
1125
+ const response = await this.client.send(
1126
+ new ListPartsCommand({
1127
+ Bucket: input.bucket,
1128
+ Key: input.objectKey,
1129
+ UploadId: input.storageUploadId,
1130
+ PartNumberMarker: partNumberMarker,
1131
+ }),
1132
+ )
1133
+
1134
+ for (const part of response.Parts ?? []) {
1135
+ if (part.PartNumber === undefined || part.ETag === undefined || part.Size === undefined) {
1136
+ throw new Error('Storage returned an incomplete part record')
1137
+ }
1138
+
1139
+ parts.push({
1140
+ partNumber: part.PartNumber,
1141
+ etag: part.ETag,
1142
+ size: part.Size,
1143
+ })
1144
+ }
1145
+
1146
+ if (!response.IsTruncated) {
1147
+ break
1148
+ }
1149
+
1150
+ const nextMarker = response.NextPartNumberMarker
1151
+
1152
+ if (!nextMarker || nextMarker === partNumberMarker) {
1153
+ throw new Error('Storage returned an invalid ListParts cursor')
1154
+ }
1155
+
1156
+ partNumberMarker = nextMarker
1157
+ }
1158
+ } catch (cause: unknown) {
1159
+ throwMappedMultipartError(cause)
1160
+ }
1161
+
1162
+ return parts.sort((left, right) => left.partNumber - right.partNumber)
1163
+ }
1164
+
1165
+ async completeMultipartUpload(input: MultipartIdentity & { parts: readonly StoragePart[] }) {
1166
+ try {
1167
+ const response = await this.client.send(
1168
+ new CompleteMultipartUploadCommand({
1169
+ Bucket: input.bucket,
1170
+ Key: input.objectKey,
1171
+ UploadId: input.storageUploadId,
1172
+ MultipartUpload: {
1173
+ Parts: input.parts.map((part) => ({
1174
+ PartNumber: part.partNumber,
1175
+ ETag: part.etag,
1176
+ })),
1177
+ },
1178
+ }),
1179
+ )
1180
+
1181
+ return {
1182
+ etag: response.ETag ?? null,
1183
+ }
1184
+ } catch (cause: unknown) {
1185
+ throwMappedMultipartError(cause)
1186
+ }
1187
+ }
1188
+
1189
+ async abortMultipartUpload(input: MultipartIdentity): Promise<void> {
1190
+ try {
1191
+ await this.client.send(
1192
+ new AbortMultipartUploadCommand({
1193
+ Bucket: input.bucket,
1194
+ Key: input.objectKey,
1195
+ UploadId: input.storageUploadId,
1196
+ }),
1197
+ )
1198
+ } catch (cause: unknown) {
1199
+ if (hasErrorName(cause, 'NoSuchUpload')) {
1200
+ return
1201
+ }
1202
+
1203
+ throw cause
1204
+ }
1205
+ }
1206
+
1207
+ async headObject(input: { bucket: string; objectKey: string }) {
1208
+ try {
1209
+ const response = await this.client.send(
1210
+ new HeadObjectCommand({
1211
+ Bucket: input.bucket,
1212
+ Key: input.objectKey,
1213
+ }),
1214
+ )
1215
+
1216
+ return {
1217
+ etag: response.ETag ?? null,
1218
+ contentLength: response.ContentLength ?? null,
1219
+ metadata: response.Metadata ?? {},
1220
+ }
1221
+ } catch (cause: unknown) {
1222
+ if (hasErrorName(cause, 'NoSuchBucket')) {
1223
+ throw cause
1224
+ }
1225
+
1226
+ if (hasErrorName(cause, 'NoSuchKey')) {
1227
+ return null
1228
+ }
1229
+
1230
+ if (hasErrorName(cause, 'NotFound')) {
1231
+ // 某些 S3 兼容实现会把“对象不存在”和“Bucket 不存在”都表示成 404。
1232
+ // 额外探测 Bucket;只有 Bucket 确实可用时,才把这次 404 当成对象不存在。
1233
+ await this.assertBucketAvailable()
1234
+ return null
1235
+ }
1236
+
1237
+ throw cause
1238
+ }
1239
+ }
1240
+ }
1241
+ ```
1242
+
1243
+ ### 代码解释
1244
+
1245
+ #### 为什么必须循环 ListParts
1246
+
1247
+ MinIO/S3 一次通常最多返回 1000 片,而本协议最多允许 10,000 片。必须使用:
1248
+
1249
+ - `PartNumberMarker`
1250
+ - `NextPartNumberMarker`
1251
+ - `IsTruncated`
1252
+
1253
+ 只请求一次会让 1000 片之后的数据“看起来不存在”。
1254
+
1255
+ #### 为什么不去掉 ETag 的引号
1256
+
1257
+ ETag 是存储服务返回的不透明值。按原样放进 `CompleteMultipartUploadCommand`,不要自行去引号、拼接或当作 MD5。
1258
+
1259
+ #### 为什么设置 checksum 选项
1260
+
1261
+ 对不可回放的 Node 流,SDK 如果为了额外校验尝试预读或重试,会让行为变复杂。`WHEN_REQUIRED` 表示只在协议真正要求时计算或验证。
1262
+
1263
+ #### 为什么设置 `maxAttempts: 1`
1264
+
1265
+ `UploadPart` 的 Body 是正在到达的请求流,发送一次以后不能倒带。SDK 默认重试可能尝试重新使用已经消费过的流,因此这里关闭 SDK 级自动重试。需要重试时,由浏览器重新切出同一个 Blob,再发一次相同 `partNumber`。
1266
+
1267
+ 这里的 `maxAttempts: 1` 配在同一个 `S3Client` 上,所以 Create、List、Complete、Abort、Head 也都是单次尝试。这是有意把“请求是否安全重放”的判断放到业务层:初始化由 `clientUploadId` 和生命周期兜底,Complete 由 `completing + HeadObject` 恢复,Abort/过期由清理任务重试。若以后拆成 `partClient` 与 `controlClient`,也不能不加分析就给 Create/Complete 开自动重试。
1268
+
1269
+ #### 为什么启动时还要 HeadBucket
1270
+
1271
+ `HeadObject` 的 404 在不同 S3 兼容实现中不一定能稳定区分“对象不存在”和“Bucket 不存在”。Adapter 启动时先用 `HeadBucket` 快速失败;运行期间再遇到通用 `NotFound`,也会重新探测 Bucket。这样 Bucket 被删、名称写错或账号没有桶级权限时会明确报错,不会被误判成“完成对象还没出现”。
1272
+
1273
+ ### 验证
1274
+
1275
+ 完成 Module 接线后,启动 Server 时不应出现 S3Client 依赖注入错误。真正的 MinIO 连通性会在初始化接口中验证。
1276
+
1277
+ ### 常见错误
1278
+
1279
+ - `ListParts` 只调用一次。
1280
+ - Complete 前没有按 `partNumber` 升序排序。
1281
+ - 后端收到不可回放流后自动重试。正确做法是让前端重新发送这一片。
1282
+ - 把最终 Multipart ETag 当整个文件 MD5,用于去重或完整性证明。
1283
+ - 把 HeadObject 的所有 HTTP 404 都当成“对象不存在”,从而把 `NoSuchBucket` 配置错误也静默吞掉。通用 `NotFound` 必须再用 HeadBucket 确认 Bucket 可用。
1284
+
1285
+ ---
1286
+
1287
+ ## 第 9 步:定义对象 Key、DTO 和业务错误
1288
+
1289
+ ### 本步目标
1290
+
1291
+ 后端生成安全对象 Key;Zod 校验 JSON 和路径参数;所有可预期错误遵守当前项目的 `AppException + 错误目录` 规则。
1292
+
1293
+ ### 文件位置
1294
+
1295
+ 新建:
1296
+
1297
+ ```text
1298
+ apps/server/src/modules/upload/utils/object-key.ts
1299
+ ```
1300
+
1301
+ ### 完整代码
1302
+
1303
+ ```ts
1304
+ export function buildUploadObjectKey(
1305
+ ownerId: string,
1306
+ uploadSessionId: string,
1307
+ now = new Date(),
1308
+ ): string {
1309
+ const year = String(now.getUTCFullYear())
1310
+ const month = String(now.getUTCMonth() + 1).padStart(2, '0')
1311
+
1312
+ return `users/${ownerId}/${year}/${month}/${uploadSessionId}`
1313
+ }
1314
+ ```
1315
+
1316
+ 对象 Key 完全由后端字段组成。原始文件名只存数据库,不进入 Key,因此中文名、重复名、斜杠和特殊字符都不会影响对象路径。
1317
+
1318
+ ### 文件位置
1319
+
1320
+ 新建:
1321
+
1322
+ ```text
1323
+ apps/server/src/modules/upload/dto/initiate-multipart-upload.dto.ts
1324
+ ```
1325
+
1326
+ ### 完整代码
1327
+
1328
+ ```ts
1329
+ import { createZodDto } from 'nestjs-zod'
1330
+ import z from 'zod'
1331
+
1332
+ export const initiateMultipartUploadSchema = z
1333
+ .object({
1334
+ clientUploadId: z.string().uuid(),
1335
+ fileName: z
1336
+ .string()
1337
+ .trim()
1338
+ .min(1)
1339
+ .max(255)
1340
+ .refine((value) => !/[\u0000-\u001F\u007F]/u.test(value), 'fileName 不能包含控制字符'),
1341
+ fileSize: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
1342
+ contentType: z
1343
+ .string()
1344
+ .trim()
1345
+ .min(1)
1346
+ .max(255)
1347
+ .regex(
1348
+ /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$/,
1349
+ 'contentType 必须是安全的 MIME media type,例如 image/png',
1350
+ ),
1351
+ })
1352
+ .strict()
1353
+
1354
+ export class InitiateMultipartUploadDto extends createZodDto(initiateMultipartUploadSchema) {}
1355
+ ```
1356
+
1357
+ `contentType` 会进入发往 MinIO 的 HTTP Header,所以不能只检查换行;这里直接限制为常见、安全的 ASCII `type/subtype`。前端拿不到 MIME 时统一发送 `application/octet-stream`。`fileName` 虽然不进入对象 Key,也要拒绝控制字符;以后做下载时不能把它直接拼进 `Content-Disposition`,应使用成熟库或 RFC 5987 编码。
1358
+
1359
+ ### 文件位置
1360
+
1361
+ 新建:
1362
+
1363
+ ```text
1364
+ apps/server/src/modules/upload/dto/upload-params.dto.ts
1365
+ ```
1366
+
1367
+ ### 完整代码
1368
+
1369
+ ```ts
1370
+ import { UPLOAD_MAX_PARTS } from '@/modules/upload/upload.constants'
1371
+ import { createZodDto } from 'nestjs-zod'
1372
+ import z from 'zod'
1373
+
1374
+ export const uploadSessionParamsSchema = z.object({
1375
+ uploadSessionId: z.string().uuid(),
1376
+ })
1377
+
1378
+ export const uploadPartParamsSchema = uploadSessionParamsSchema.extend({
1379
+ partNumber: z.coerce.number().int().min(1).max(UPLOAD_MAX_PARTS),
1380
+ })
1381
+
1382
+ export class UploadSessionParamsDto extends createZodDto(uploadSessionParamsSchema) {}
1383
+
1384
+ export class UploadPartParamsDto extends createZodDto(uploadPartParamsSchema) {}
1385
+ ```
1386
+
1387
+ 路径参数原本是字符串,所以 `partNumber` 必须使用 `z.coerce.number()`。否则拿字符串 `"10"` 做数值比较时很容易产生隐蔽错误。
1388
+
1389
+ ### 文件位置
1390
+
1391
+ 新建:
1392
+
1393
+ ```text
1394
+ apps/server/src/modules/upload/upload.errors.ts
1395
+ ```
1396
+
1397
+ ### 完整代码
1398
+
1399
+ ```ts
1400
+ import type { AppErrorDefinition } from '@/common/exceptions/app.exception'
1401
+ import { HttpStatus } from '@nestjs/common'
1402
+
1403
+ export const UPLOAD_ERRORS = {
1404
+ FILE_EMPTY: {
1405
+ code: 'UPLOAD.FILE_EMPTY',
1406
+ publicMessage: '空文件不能使用分片上传',
1407
+ status: HttpStatus.UNPROCESSABLE_ENTITY,
1408
+ },
1409
+ FILE_TOO_LARGE: {
1410
+ code: 'UPLOAD.FILE_TOO_LARGE',
1411
+ publicMessage: '文件超过允许的最大大小',
1412
+ status: HttpStatus.PAYLOAD_TOO_LARGE,
1413
+ },
1414
+ CLIENT_UPLOAD_ID_CONFLICT: {
1415
+ code: 'UPLOAD.CLIENT_UPLOAD_ID_CONFLICT',
1416
+ publicMessage: '该客户端上传标识已用于另一个文件',
1417
+ status: HttpStatus.CONFLICT,
1418
+ },
1419
+ SESSION_NOT_FOUND: {
1420
+ code: 'UPLOAD.SESSION_NOT_FOUND',
1421
+ publicMessage: '上传会话不存在',
1422
+ status: HttpStatus.NOT_FOUND,
1423
+ },
1424
+ INVALID_STATE: {
1425
+ code: 'UPLOAD.INVALID_STATE',
1426
+ publicMessage: '当前上传状态不允许执行此操作',
1427
+ status: HttpStatus.CONFLICT,
1428
+ },
1429
+ UPLOAD_EXPIRED: {
1430
+ code: 'UPLOAD.UPLOAD_EXPIRED',
1431
+ publicMessage: '上传会话已过期,请重新开始',
1432
+ status: HttpStatus.GONE,
1433
+ },
1434
+ PART_NUMBER_INVALID: {
1435
+ code: 'UPLOAD.PART_NUMBER_INVALID',
1436
+ publicMessage: '分片编号超出当前文件范围',
1437
+ status: HttpStatus.UNPROCESSABLE_ENTITY,
1438
+ },
1439
+ PART_LENGTH_REQUIRED: {
1440
+ code: 'UPLOAD.PART_LENGTH_REQUIRED',
1441
+ publicMessage: '分片请求缺少有效的 Content-Length',
1442
+ status: HttpStatus.LENGTH_REQUIRED,
1443
+ },
1444
+ PART_SIZE_MISMATCH: {
1445
+ code: 'UPLOAD.PART_SIZE_MISMATCH',
1446
+ publicMessage: '分片实际大小与预期不一致',
1447
+ status: HttpStatus.UNPROCESSABLE_ENTITY,
1448
+ },
1449
+ UNSUPPORTED_CONTENT_ENCODING: {
1450
+ code: 'UPLOAD.UNSUPPORTED_CONTENT_ENCODING',
1451
+ publicMessage: '分片请求不支持压缩编码',
1452
+ status: HttpStatus.UNSUPPORTED_MEDIA_TYPE,
1453
+ },
1454
+ PARTS_INCOMPLETE: {
1455
+ code: 'UPLOAD.PARTS_INCOMPLETE',
1456
+ publicMessage: '分片尚未全部上传或大小不正确',
1457
+ status: HttpStatus.CONFLICT,
1458
+ },
1459
+ PARTS_STILL_ACTIVE: {
1460
+ code: 'UPLOAD.PARTS_STILL_ACTIVE',
1461
+ publicMessage: '仍有分片正在上传,请稍后重试',
1462
+ status: HttpStatus.CONFLICT,
1463
+ },
1464
+ PART_UPLOAD_ABORTED: {
1465
+ code: 'UPLOAD.PART_UPLOAD_ABORTED',
1466
+ publicMessage: '分片上传已取消',
1467
+ status: HttpStatus.REQUEST_TIMEOUT,
1468
+ },
1469
+ STORAGE_UPLOAD_MISSING: {
1470
+ code: 'UPLOAD.STORAGE_UPLOAD_MISSING',
1471
+ publicMessage: '存储中的上传会话已失效,请重新开始',
1472
+ status: HttpStatus.GONE,
1473
+ },
1474
+ STORAGE_UNAVAILABLE: {
1475
+ code: 'UPLOAD.STORAGE_UNAVAILABLE',
1476
+ publicMessage: '文件存储服务暂时不可用,请稍后重试',
1477
+ status: HttpStatus.SERVICE_UNAVAILABLE,
1478
+ },
1479
+ } as const satisfies Record<string, AppErrorDefinition>
1480
+ ```
1481
+
1482
+ ### 文件位置
1483
+
1484
+ 修改:
1485
+
1486
+ ```text
1487
+ apps/server/test/error-catalog.spec.ts
1488
+ ```
1489
+
1490
+ ### 完整代码
1491
+
1492
+ 将文件替换为:
1493
+
1494
+ ```ts
1495
+ import { COMMON_ERRORS } from '@/common/error/common.error'
1496
+ import { AUTH_ERRORS } from '@/modules/auth/auth.errors'
1497
+ import { UPLOAD_ERRORS } from '@/modules/upload/upload.errors'
1498
+ import { describe, expect, it } from 'vite-plus/test'
1499
+
1500
+ const definitions = [
1501
+ ...Object.values(COMMON_ERRORS),
1502
+ ...Object.values(AUTH_ERRORS),
1503
+ ...Object.values(UPLOAD_ERRORS),
1504
+ ]
1505
+
1506
+ describe('error catalog', () => {
1507
+ it('uses valid and globally unique error codes', () => {
1508
+ const codes = definitions.map((definition) => definition.code)
1509
+
1510
+ expect(new Set(codes).size).toBe(codes.length)
1511
+
1512
+ for (const definition of definitions) {
1513
+ expect(definition.code).toMatch(/^[A-Z][A-Z0-9_]*(?:\.[A-Z][A-Z0-9_]*)+$/)
1514
+ expect(definition.code.length).toBeLessThanOrEqual(80)
1515
+ expect(definition.status).toBeGreaterThanOrEqual(400)
1516
+ expect(definition.status).toBeLessThanOrEqual(599)
1517
+ expect(definition.publicMessage.trim()).not.toBe('')
1518
+ }
1519
+ })
1520
+
1521
+ it('keeps bearerChallenge and HTTP 401 in sync', () => {
1522
+ for (const definition of definitions) {
1523
+ const hasBearerChallenge =
1524
+ 'bearerChallenge' in definition && definition.bearerChallenge === true
1525
+
1526
+ expect(hasBearerChallenge).toBe(definition.status === 401)
1527
+ }
1528
+ })
1529
+ })
1530
+ ```
1531
+
1532
+ ### 验证
1533
+
1534
+ ```powershell
1535
+ pnpm --filter server test
1536
+ ```
1537
+
1538
+ 预期 `error catalog` 测试通过。
1539
+
1540
+ ### 常见错误
1541
+
1542
+ - 把 MinIO 原始错误文本直接返回浏览器,泄漏 Bucket、Key、节点地址或内部实现。
1543
+ - 新增错误后忘记加入错误目录测试。
1544
+ - 对“不存在”和“不属于当前用户”返回不同结果,导致攻击者枚举别人的会话。
1545
+ - MIME 类型来自客户端,只能当元数据,不能作为安全判断或病毒扫描结果。
1546
+
1547
+ ---
1548
+
1549
+ ## 第 10 步:实现上传 Repository
1550
+
1551
+ ### 本步目标
1552
+
1553
+ 封装所有数据库访问,并强制每个查询都带当前登录用户的 `ownerId`。
1554
+
1555
+ ### 文件位置
1556
+
1557
+ 新建:
1558
+
1559
+ ```text
1560
+ apps/server/src/modules/upload/upload.repository.ts
1561
+ ```
1562
+
1563
+ ### 完整代码
1564
+
1565
+ ```ts
1566
+ import { DRIZZLE, type DrizzleDB } from '@/database/db.module'
1567
+ import { uploadSessions } from '@/database/schema'
1568
+ import { Inject, Injectable } from '@nestjs/common'
1569
+ import { and, eq, inArray } from 'drizzle-orm'
1570
+
1571
+ export type UploadSession = typeof uploadSessions.$inferSelect
1572
+ export type CreateUploadSession = typeof uploadSessions.$inferInsert
1573
+
1574
+ @Injectable()
1575
+ export class UploadRepository {
1576
+ constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
1577
+
1578
+ async create(input: CreateUploadSession): Promise<UploadSession | null> {
1579
+ const [session] = await this.db
1580
+ .insert(uploadSessions)
1581
+ .values(input)
1582
+ .onConflictDoNothing({
1583
+ target: [uploadSessions.ownerId, uploadSessions.clientUploadId],
1584
+ })
1585
+ .returning()
1586
+
1587
+ return session ?? null
1588
+ }
1589
+
1590
+ async findById(ownerId: string, uploadSessionId: string): Promise<UploadSession | null> {
1591
+ const [session] = await this.db
1592
+ .select()
1593
+ .from(uploadSessions)
1594
+ .where(and(eq(uploadSessions.id, uploadSessionId), eq(uploadSessions.ownerId, ownerId)))
1595
+ .limit(1)
1596
+
1597
+ return session ?? null
1598
+ }
1599
+
1600
+ async findByClientUploadId(
1601
+ ownerId: string,
1602
+ clientUploadId: string,
1603
+ ): Promise<UploadSession | null> {
1604
+ const [session] = await this.db
1605
+ .select()
1606
+ .from(uploadSessions)
1607
+ .where(
1608
+ and(eq(uploadSessions.ownerId, ownerId), eq(uploadSessions.clientUploadId, clientUploadId)),
1609
+ )
1610
+ .limit(1)
1611
+
1612
+ return session ?? null
1613
+ }
1614
+
1615
+ async claimCompleting(ownerId: string, uploadSessionId: string): Promise<UploadSession | null> {
1616
+ const [session] = await this.db
1617
+ .update(uploadSessions)
1618
+ .set({
1619
+ status: 'completing',
1620
+ updatedAt: new Date(),
1621
+ })
1622
+ .where(
1623
+ and(
1624
+ eq(uploadSessions.id, uploadSessionId),
1625
+ eq(uploadSessions.ownerId, ownerId),
1626
+ eq(uploadSessions.status, 'uploading'),
1627
+ ),
1628
+ )
1629
+ .returning()
1630
+
1631
+ return session ?? null
1632
+ }
1633
+
1634
+ async resetCompleting(ownerId: string, uploadSessionId: string): Promise<void> {
1635
+ await this.db
1636
+ .update(uploadSessions)
1637
+ .set({
1638
+ status: 'uploading',
1639
+ updatedAt: new Date(),
1640
+ })
1641
+ .where(
1642
+ and(
1643
+ eq(uploadSessions.id, uploadSessionId),
1644
+ eq(uploadSessions.ownerId, ownerId),
1645
+ eq(uploadSessions.status, 'completing'),
1646
+ ),
1647
+ )
1648
+ }
1649
+
1650
+ async markCompleted(
1651
+ ownerId: string,
1652
+ uploadSessionId: string,
1653
+ objectEtag: string | null,
1654
+ ): Promise<UploadSession | null> {
1655
+ const now = new Date()
1656
+ const [session] = await this.db
1657
+ .update(uploadSessions)
1658
+ .set({
1659
+ status: 'completed',
1660
+ objectEtag,
1661
+ completedAt: now,
1662
+ updatedAt: now,
1663
+ })
1664
+ .where(
1665
+ and(
1666
+ eq(uploadSessions.id, uploadSessionId),
1667
+ eq(uploadSessions.ownerId, ownerId),
1668
+ eq(uploadSessions.status, 'completing'),
1669
+ ),
1670
+ )
1671
+ .returning()
1672
+
1673
+ return session ?? null
1674
+ }
1675
+
1676
+ async claimAborting(ownerId: string, uploadSessionId: string): Promise<UploadSession | null> {
1677
+ const [session] = await this.db
1678
+ .update(uploadSessions)
1679
+ .set({
1680
+ status: 'aborting',
1681
+ updatedAt: new Date(),
1682
+ })
1683
+ .where(
1684
+ and(
1685
+ eq(uploadSessions.id, uploadSessionId),
1686
+ eq(uploadSessions.ownerId, ownerId),
1687
+ inArray(uploadSessions.status, ['uploading', 'aborting', 'expired']),
1688
+ ),
1689
+ )
1690
+ .returning()
1691
+
1692
+ return session ?? null
1693
+ }
1694
+
1695
+ async markAborted(ownerId: string, uploadSessionId: string): Promise<UploadSession | null> {
1696
+ const [session] = await this.db
1697
+ .update(uploadSessions)
1698
+ .set({
1699
+ status: 'aborted',
1700
+ updatedAt: new Date(),
1701
+ })
1702
+ .where(
1703
+ and(
1704
+ eq(uploadSessions.id, uploadSessionId),
1705
+ eq(uploadSessions.ownerId, ownerId),
1706
+ eq(uploadSessions.status, 'aborting'),
1707
+ ),
1708
+ )
1709
+ .returning()
1710
+
1711
+ return session ?? null
1712
+ }
1713
+
1714
+ async markExpired(ownerId: string, uploadSessionId: string): Promise<UploadSession | null> {
1715
+ const [session] = await this.db
1716
+ .update(uploadSessions)
1717
+ .set({
1718
+ status: 'expired',
1719
+ updatedAt: new Date(),
1720
+ })
1721
+ .where(
1722
+ and(
1723
+ eq(uploadSessions.id, uploadSessionId),
1724
+ eq(uploadSessions.ownerId, ownerId),
1725
+ eq(uploadSessions.status, 'uploading'),
1726
+ ),
1727
+ )
1728
+ .returning()
1729
+
1730
+ return session ?? null
1731
+ }
1732
+ }
1733
+ ```
1734
+
1735
+ ### 代码解释
1736
+
1737
+ - `create` 使用唯一约束实现初始化幂等。
1738
+ - `claimCompleting` 是条件更新:只有一个请求能把 `uploading` 改成 `completing`。
1739
+ - `claimAborting` 同样避免“完成”和“取消”同时成功。
1740
+ - 用户 B 即使猜到用户 A 的 UUID,`id + ownerId` 查询仍返回空,统一表现为 404。
1741
+
1742
+ ### 验证
1743
+
1744
+ 完成 Service 后,应使用两个账号验证:
1745
+
1746
+ 1. 账号 A 初始化上传,记录 `uploadSessionId`。
1747
+ 2. 账号 B 使用该 ID 调用查询、分片、完成、取消。
1748
+ 3. 四类请求都应得到 `UPLOAD.SESSION_NOT_FOUND`,不能泄漏会话属于其他人。
1749
+
1750
+ ### 常见错误
1751
+
1752
+ - Repository 提供一个只按 ID 查询的公共方法,后来某个接口忘记补 owner 条件。
1753
+ - 先查询状态再无条件更新,两个完成请求同时进入。这里使用带旧状态条件的原子更新。
1754
+ - 取消已经 `completed` 的会话时直接删除最终对象。本文不会这么做。
1755
+
1756
+ ---
1757
+
1758
+ ## 第 11 步:实现上传 Service
1759
+
1760
+ ### 本步目标
1761
+
1762
+ 完成五个核心流程:
1763
+
1764
+ 1. 初始化 Multipart,并用 `clientUploadId` 保证幂等。
1765
+ 2. 校验并流式中转单个分片。
1766
+ 3. 从 MinIO 查询真实断点。
1767
+ 4. 校验全部分片后完成合并。
1768
+ 5. 幂等取消并释放未完成分片。
1769
+
1770
+ > 重要边界:下面是便于先跑通协议的教学基线,它要求调用方在 Complete/DELETE 前先停止并等待所有 PUT 结束。只靠会话状态无法看见已经开始的在途请求;对公网或多实例部署必须继续完成第 18 步的“活动分片租约”,否则不能宣称 Complete/Abort 已具备生产级竞态安全。
1771
+
1772
+ ### 文件位置
1773
+
1774
+ 新建:
1775
+
1776
+ ```text
1777
+ apps/server/src/modules/upload/upload.service.ts
1778
+ ```
1779
+
1780
+ ### 完整代码
1781
+
1782
+ ```ts
1783
+ import { AppException } from '@/common/exceptions/app.exception'
1784
+ import { Inject, Injectable, Logger } from '@nestjs/common'
1785
+ import { ConfigService } from '@nestjs/config'
1786
+ import { randomUUID } from 'node:crypto'
1787
+ import type { Readable } from 'node:stream'
1788
+ import type { InitiateMultipartUploadDto } from './dto/initiate-multipart-upload.dto'
1789
+ import {
1790
+ calculateExpectedPartSize,
1791
+ calculateTotalParts,
1792
+ UPLOAD_MAX_FILE_SIZE,
1793
+ UPLOAD_MAX_PARTS,
1794
+ UPLOAD_PART_SIZE,
1795
+ } from './upload.constants'
1796
+ import { UPLOAD_ERRORS } from './upload.errors'
1797
+ import { ExactSizeTransform, findExactSizeError } from './storage/exact-size.transform'
1798
+ import {
1799
+ STORAGE_PORT,
1800
+ StorageMultipartNotFoundError,
1801
+ type MultipartIdentity,
1802
+ type StoragePart,
1803
+ type StoragePort,
1804
+ } from './storage/storage.port'
1805
+ import { UploadRepository, type UploadSession } from './upload.repository'
1806
+ import { buildUploadObjectKey } from './utils/object-key'
1807
+
1808
+ export interface UploadedPartResponse {
1809
+ partNumber: number
1810
+ size: number
1811
+ etag: string
1812
+ }
1813
+
1814
+ export interface UploadStatusResponse {
1815
+ uploadSessionId: string
1816
+ fileName: string
1817
+ fileSize: number
1818
+ contentType: string
1819
+ partSize: number
1820
+ totalParts: number
1821
+ status: UploadSession['status']
1822
+ expiresAt: string
1823
+ uploadedParts: UploadedPartResponse[]
1824
+ }
1825
+
1826
+ export interface CompletedUploadResponse {
1827
+ uploadSessionId: string
1828
+ status: 'completed'
1829
+ objectKey: string
1830
+ etag: string | null
1831
+ }
1832
+
1833
+ interface UploadPartCommandInput {
1834
+ ownerId: string
1835
+ uploadSessionId: string
1836
+ partNumber: number
1837
+ contentLength: string | undefined
1838
+ contentEncoding: string | undefined
1839
+ body: Readable
1840
+ abortSignal: AbortSignal
1841
+ }
1842
+
1843
+ @Injectable()
1844
+ export class UploadService {
1845
+ private readonly logger = new Logger(UploadService.name)
1846
+ private readonly bucket: string
1847
+ private readonly sessionTtlMs: number
1848
+
1849
+ constructor(
1850
+ private readonly uploadRepository: UploadRepository,
1851
+ @Inject(STORAGE_PORT) private readonly storage: StoragePort,
1852
+ config: ConfigService,
1853
+ ) {
1854
+ this.bucket = config.getOrThrow<string>('storage.bucket')
1855
+ this.sessionTtlMs = config.getOrThrow<number>('storage.sessionTtlMs')
1856
+ }
1857
+
1858
+ async initiate(
1859
+ ownerId: string,
1860
+ input: InitiateMultipartUploadDto,
1861
+ ): Promise<UploadStatusResponse> {
1862
+ this.validateNewFile(input.fileSize)
1863
+
1864
+ const existing = await this.uploadRepository.findByClientUploadId(ownerId, input.clientUploadId)
1865
+
1866
+ if (existing) {
1867
+ this.assertSameClientFile(existing, input)
1868
+ return this.getStatus(ownerId, existing.id)
1869
+ }
1870
+
1871
+ const totalParts = calculateTotalParts(input.fileSize)
1872
+
1873
+ if (totalParts > UPLOAD_MAX_PARTS) {
1874
+ throw new AppException(UPLOAD_ERRORS.FILE_TOO_LARGE)
1875
+ }
1876
+
1877
+ const uploadSessionId = randomUUID()
1878
+ const objectKey = buildUploadObjectKey(ownerId, uploadSessionId)
1879
+ const storageResult = await this.callStorage(() =>
1880
+ this.storage.createMultipartUpload({
1881
+ bucket: this.bucket,
1882
+ objectKey,
1883
+ contentType: input.contentType.trim(),
1884
+ metadata: {
1885
+ 'upload-session-id': uploadSessionId,
1886
+ 'owner-id': ownerId,
1887
+ },
1888
+ }),
1889
+ )
1890
+
1891
+ const identity: MultipartIdentity = {
1892
+ bucket: this.bucket,
1893
+ objectKey,
1894
+ storageUploadId: storageResult.storageUploadId,
1895
+ }
1896
+
1897
+ try {
1898
+ const created = await this.uploadRepository.create({
1899
+ id: uploadSessionId,
1900
+ ownerId,
1901
+ clientUploadId: input.clientUploadId,
1902
+ bucket: this.bucket,
1903
+ objectKey,
1904
+ storageUploadId: storageResult.storageUploadId,
1905
+ originalName: input.fileName.trim(),
1906
+ contentType: input.contentType.trim(),
1907
+ fileSize: input.fileSize,
1908
+ partSize: UPLOAD_PART_SIZE,
1909
+ totalParts,
1910
+ status: 'uploading',
1911
+ expiresAt: new Date(Date.now() + this.sessionTtlMs),
1912
+ })
1913
+
1914
+ if (created) {
1915
+ return this.toStatusResponse(created, [])
1916
+ }
1917
+
1918
+ const winner = await this.uploadRepository.findByClientUploadId(ownerId, input.clientUploadId)
1919
+
1920
+ await this.safeAbort(identity)
1921
+
1922
+ if (!winner) {
1923
+ throw new Error('Upload session conflict winner was not found')
1924
+ }
1925
+
1926
+ this.assertSameClientFile(winner, input)
1927
+ return this.getStatus(ownerId, winner.id)
1928
+ } catch (cause: unknown) {
1929
+ await this.safeAbort(identity)
1930
+ throw cause
1931
+ }
1932
+ }
1933
+
1934
+ async getStatus(ownerId: string, uploadSessionId: string): Promise<UploadStatusResponse> {
1935
+ let session = await this.requireSession(ownerId, uploadSessionId)
1936
+
1937
+ if (session.status === 'expired') {
1938
+ throw new AppException(UPLOAD_ERRORS.UPLOAD_EXPIRED)
1939
+ }
1940
+
1941
+ if (session.status === 'uploading') {
1942
+ await this.ensureNotExpired(session)
1943
+ }
1944
+
1945
+ if (session.status === 'completing') {
1946
+ const recovered = await this.recoverCompletedObject(session)
1947
+
1948
+ if (recovered) {
1949
+ session = recovered
1950
+ }
1951
+ }
1952
+
1953
+ if (session.status !== 'uploading' && session.status !== 'completing') {
1954
+ return this.toStatusResponse(session, [])
1955
+ }
1956
+
1957
+ const parts = await this.callStorage(() =>
1958
+ this.storage.listParts(this.toMultipartIdentity(session)),
1959
+ )
1960
+
1961
+ return this.toStatusResponse(session, parts)
1962
+ }
1963
+
1964
+ async uploadPart(input: UploadPartCommandInput) {
1965
+ const session = await this.requireSession(input.ownerId, input.uploadSessionId)
1966
+
1967
+ if (session.status !== 'uploading') {
1968
+ throw new AppException(UPLOAD_ERRORS.INVALID_STATE)
1969
+ }
1970
+
1971
+ await this.ensureNotExpired(session)
1972
+
1973
+ if (input.partNumber < 1 || input.partNumber > session.totalParts) {
1974
+ throw new AppException(UPLOAD_ERRORS.PART_NUMBER_INVALID)
1975
+ }
1976
+
1977
+ if (input.contentEncoding && input.contentEncoding.toLowerCase() !== 'identity') {
1978
+ throw new AppException(UPLOAD_ERRORS.UNSUPPORTED_CONTENT_ENCODING)
1979
+ }
1980
+
1981
+ const expectedSize = calculateExpectedPartSize(
1982
+ session.fileSize,
1983
+ session.totalParts,
1984
+ input.partNumber,
1985
+ )
1986
+ const declaredSize = this.parseContentLength(input.contentLength)
1987
+
1988
+ if (declaredSize !== expectedSize) {
1989
+ throw new AppException(UPLOAD_ERRORS.PART_SIZE_MISMATCH)
1990
+ }
1991
+
1992
+ const exactSizeStream = new ExactSizeTransform(expectedSize)
1993
+ const onSourceError = (cause: Error) => exactSizeStream.destroy(cause)
1994
+ input.body.once('error', onSourceError)
1995
+
1996
+ try {
1997
+ const guardedBody = input.body.pipe(exactSizeStream)
1998
+ const result = await this.callStorage(() =>
1999
+ this.storage.uploadPart({
2000
+ ...this.toMultipartIdentity(session),
2001
+ partNumber: input.partNumber,
2002
+ body: guardedBody,
2003
+ contentLength: expectedSize,
2004
+ abortSignal: input.abortSignal,
2005
+ }),
2006
+ )
2007
+
2008
+ return {
2009
+ partNumber: input.partNumber,
2010
+ size: expectedSize,
2011
+ etag: result.etag,
2012
+ }
2013
+ } catch (cause: unknown) {
2014
+ // MinIO 提前拒绝时,停止继续写入目标 Transform;继续排空剩余请求体,
2015
+ // 避免 HTTP 连接因为无人消费请求流而长期停滞。这里不会把整片读进内存。
2016
+ input.body.unpipe(exactSizeStream)
2017
+ exactSizeStream.destroy()
2018
+
2019
+ if (!input.body.destroyed) {
2020
+ input.body.resume()
2021
+ }
2022
+
2023
+ throw cause
2024
+ } finally {
2025
+ input.body.unpipe(exactSizeStream)
2026
+ input.body.off('error', onSourceError)
2027
+
2028
+ if (!exactSizeStream.destroyed) {
2029
+ exactSizeStream.destroy()
2030
+ }
2031
+ }
2032
+ }
2033
+
2034
+ async complete(ownerId: string, uploadSessionId: string): Promise<CompletedUploadResponse> {
2035
+ let session = await this.requireSession(ownerId, uploadSessionId)
2036
+
2037
+ if (session.status === 'completed') {
2038
+ return this.toCompletedResponse(session)
2039
+ }
2040
+
2041
+ if (session.status === 'completing') {
2042
+ const recovered = await this.recoverCompletedObject(session)
2043
+
2044
+ if (recovered) {
2045
+ return this.toCompletedResponse(recovered)
2046
+ }
2047
+
2048
+ throw new AppException(UPLOAD_ERRORS.INVALID_STATE)
2049
+ }
2050
+
2051
+ if (session.status !== 'uploading') {
2052
+ throw new AppException(UPLOAD_ERRORS.INVALID_STATE)
2053
+ }
2054
+
2055
+ await this.ensureNotExpired(session)
2056
+
2057
+ const claimed = await this.uploadRepository.claimCompleting(ownerId, uploadSessionId)
2058
+
2059
+ if (!claimed) {
2060
+ session = await this.requireSession(ownerId, uploadSessionId)
2061
+
2062
+ if (session.status === 'completed') {
2063
+ return this.toCompletedResponse(session)
2064
+ }
2065
+
2066
+ throw new AppException(UPLOAD_ERRORS.INVALID_STATE)
2067
+ }
2068
+
2069
+ let completeWasAttempted = false
2070
+
2071
+ try {
2072
+ const parts = await this.callStorage(() =>
2073
+ this.storage.listParts(this.toMultipartIdentity(claimed)),
2074
+ )
2075
+
2076
+ this.assertCompleteParts(claimed, parts)
2077
+
2078
+ completeWasAttempted = true
2079
+ const completed = await this.callStorage(() =>
2080
+ this.storage.completeMultipartUpload({
2081
+ ...this.toMultipartIdentity(claimed),
2082
+ parts,
2083
+ }),
2084
+ )
2085
+
2086
+ const updated = await this.uploadRepository.markCompleted(
2087
+ ownerId,
2088
+ uploadSessionId,
2089
+ completed.etag,
2090
+ )
2091
+
2092
+ if (!updated) {
2093
+ const recovered = await this.recoverCompletedObject(claimed)
2094
+
2095
+ if (recovered) {
2096
+ return this.toCompletedResponse(recovered)
2097
+ }
2098
+
2099
+ throw new Error('Completed object could not be persisted')
2100
+ }
2101
+
2102
+ return this.toCompletedResponse(updated)
2103
+ } catch (cause: unknown) {
2104
+ if (!completeWasAttempted) {
2105
+ if (
2106
+ cause instanceof AppException &&
2107
+ cause.definition.code === UPLOAD_ERRORS.STORAGE_UPLOAD_MISSING.code
2108
+ ) {
2109
+ // UploadId 已经不存在,不能确定是外部清理还是曾经完成过。
2110
+ // 先尝试 HeadObject;仍无法恢复时不重新开放上传,交给后台清理流程判定。
2111
+ try {
2112
+ const recovered = await this.recoverCompletedObject(claimed)
2113
+
2114
+ if (recovered) {
2115
+ return this.toCompletedResponse(recovered)
2116
+ }
2117
+ } catch {
2118
+ // 保留原始 STORAGE_UPLOAD_MISSING 错误,并继续保持 completing。
2119
+ }
2120
+
2121
+ throw cause
2122
+ }
2123
+
2124
+ // ListParts 失败或分片不完整时还没有发送 Complete,
2125
+ // 因此可以安全恢复为 uploading,让调用方修正后重试。
2126
+ await this.uploadRepository.resetCompleting(ownerId, uploadSessionId)
2127
+ throw cause
2128
+ }
2129
+
2130
+ try {
2131
+ const recovered = await this.recoverCompletedObject(claimed)
2132
+
2133
+ if (recovered) {
2134
+ return this.toCompletedResponse(recovered)
2135
+ }
2136
+ } catch {
2137
+ // 无法确认 MinIO 是否已完成时保持 completing,
2138
+ // 避免错误地重新开放分片上传。
2139
+ throw cause
2140
+ }
2141
+
2142
+ // Complete 已经发出后,即使 HeadObject 暂时还看不到对象,也不能证明
2143
+ // MinIO 没有完成。保持 completing,由 GET 状态恢复或后台任务继续判定。
2144
+ throw cause
2145
+ }
2146
+ }
2147
+
2148
+ async abort(ownerId: string, uploadSessionId: string) {
2149
+ let session = await this.requireSession(ownerId, uploadSessionId)
2150
+
2151
+ if (session.status === 'aborted') {
2152
+ return {
2153
+ uploadSessionId,
2154
+ status: 'aborted' as const,
2155
+ }
2156
+ }
2157
+
2158
+ if (session.status === 'completed' || session.status === 'completing') {
2159
+ throw new AppException(UPLOAD_ERRORS.INVALID_STATE)
2160
+ }
2161
+
2162
+ const claimed = await this.uploadRepository.claimAborting(ownerId, uploadSessionId)
2163
+
2164
+ if (!claimed) {
2165
+ session = await this.requireSession(ownerId, uploadSessionId)
2166
+
2167
+ if (session.status === 'aborted') {
2168
+ return {
2169
+ uploadSessionId,
2170
+ status: 'aborted' as const,
2171
+ }
2172
+ }
2173
+
2174
+ throw new AppException(UPLOAD_ERRORS.INVALID_STATE)
2175
+ }
2176
+
2177
+ await this.callStorage(() =>
2178
+ this.storage.abortMultipartUpload(this.toMultipartIdentity(claimed)),
2179
+ )
2180
+
2181
+ const aborted = await this.uploadRepository.markAborted(ownerId, uploadSessionId)
2182
+
2183
+ if (!aborted) {
2184
+ const latest = await this.uploadRepository.findById(ownerId, uploadSessionId)
2185
+
2186
+ if (latest?.status === 'aborted') {
2187
+ return {
2188
+ uploadSessionId,
2189
+ status: 'aborted' as const,
2190
+ }
2191
+ }
2192
+
2193
+ throw new Error('Aborted upload could not be persisted')
2194
+ }
2195
+
2196
+ return {
2197
+ uploadSessionId,
2198
+ status: 'aborted' as const,
2199
+ }
2200
+ }
2201
+
2202
+ private validateNewFile(fileSize: number): void {
2203
+ if (fileSize === 0) {
2204
+ throw new AppException(UPLOAD_ERRORS.FILE_EMPTY)
2205
+ }
2206
+
2207
+ if (fileSize > UPLOAD_MAX_FILE_SIZE) {
2208
+ throw new AppException(UPLOAD_ERRORS.FILE_TOO_LARGE)
2209
+ }
2210
+ }
2211
+
2212
+ private assertSameClientFile(session: UploadSession, input: InitiateMultipartUploadDto): void {
2213
+ if (
2214
+ session.originalName !== input.fileName.trim() ||
2215
+ session.fileSize !== input.fileSize ||
2216
+ session.contentType !== input.contentType.trim()
2217
+ ) {
2218
+ throw new AppException(UPLOAD_ERRORS.CLIENT_UPLOAD_ID_CONFLICT)
2219
+ }
2220
+ }
2221
+
2222
+ private async requireSession(ownerId: string, uploadSessionId: string): Promise<UploadSession> {
2223
+ const session = await this.uploadRepository.findById(ownerId, uploadSessionId)
2224
+
2225
+ if (!session) {
2226
+ throw new AppException(UPLOAD_ERRORS.SESSION_NOT_FOUND)
2227
+ }
2228
+
2229
+ return session
2230
+ }
2231
+
2232
+ private async ensureNotExpired(session: UploadSession): Promise<void> {
2233
+ if (session.expiresAt.getTime() > Date.now()) {
2234
+ return
2235
+ }
2236
+
2237
+ const expired = await this.uploadRepository.markExpired(session.ownerId, session.id)
2238
+
2239
+ if (expired) {
2240
+ await this.safeAbort(this.toMultipartIdentity(expired))
2241
+ }
2242
+
2243
+ throw new AppException(UPLOAD_ERRORS.UPLOAD_EXPIRED)
2244
+ }
2245
+
2246
+ private parseContentLength(value: string | undefined): number {
2247
+ if (!value || !/^\d+$/.test(value)) {
2248
+ throw new AppException(UPLOAD_ERRORS.PART_LENGTH_REQUIRED)
2249
+ }
2250
+
2251
+ const parsed = Number(value)
2252
+
2253
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
2254
+ throw new AppException(UPLOAD_ERRORS.PART_LENGTH_REQUIRED)
2255
+ }
2256
+
2257
+ return parsed
2258
+ }
2259
+
2260
+ private assertCompleteParts(session: UploadSession, parts: readonly StoragePart[]): void {
2261
+ if (parts.length !== session.totalParts) {
2262
+ throw new AppException(UPLOAD_ERRORS.PARTS_INCOMPLETE)
2263
+ }
2264
+
2265
+ let totalBytes = 0
2266
+
2267
+ for (let index = 0; index < parts.length; index += 1) {
2268
+ const part = parts[index]
2269
+ const expectedPartNumber = index + 1
2270
+
2271
+ if (!part || part.partNumber !== expectedPartNumber || !part.etag) {
2272
+ throw new AppException(UPLOAD_ERRORS.PARTS_INCOMPLETE)
2273
+ }
2274
+
2275
+ const expectedSize = calculateExpectedPartSize(
2276
+ session.fileSize,
2277
+ session.totalParts,
2278
+ expectedPartNumber,
2279
+ )
2280
+
2281
+ if (part.size !== expectedSize) {
2282
+ throw new AppException(UPLOAD_ERRORS.PARTS_INCOMPLETE)
2283
+ }
2284
+
2285
+ totalBytes += part.size
2286
+ }
2287
+
2288
+ if (totalBytes !== session.fileSize) {
2289
+ throw new AppException(UPLOAD_ERRORS.PARTS_INCOMPLETE)
2290
+ }
2291
+ }
2292
+
2293
+ private async recoverCompletedObject(session: UploadSession): Promise<UploadSession | null> {
2294
+ const object = await this.callStorage(() =>
2295
+ this.storage.headObject({
2296
+ bucket: session.bucket,
2297
+ objectKey: session.objectKey,
2298
+ }),
2299
+ )
2300
+
2301
+ if (
2302
+ !object ||
2303
+ object.metadata['upload-session-id'] !== session.id ||
2304
+ object.contentLength !== session.fileSize
2305
+ ) {
2306
+ return null
2307
+ }
2308
+
2309
+ const updated = await this.uploadRepository.markCompleted(
2310
+ session.ownerId,
2311
+ session.id,
2312
+ object.etag,
2313
+ )
2314
+
2315
+ if (updated) {
2316
+ return updated
2317
+ }
2318
+
2319
+ const latest = await this.uploadRepository.findById(session.ownerId, session.id)
2320
+
2321
+ return latest?.status === 'completed' ? latest : null
2322
+ }
2323
+
2324
+ private toMultipartIdentity(session: UploadSession): MultipartIdentity {
2325
+ return {
2326
+ bucket: session.bucket,
2327
+ objectKey: session.objectKey,
2328
+ storageUploadId: session.storageUploadId,
2329
+ }
2330
+ }
2331
+
2332
+ private toStatusResponse(
2333
+ session: UploadSession,
2334
+ parts: readonly StoragePart[],
2335
+ ): UploadStatusResponse {
2336
+ return {
2337
+ uploadSessionId: session.id,
2338
+ fileName: session.originalName,
2339
+ fileSize: session.fileSize,
2340
+ contentType: session.contentType,
2341
+ partSize: session.partSize,
2342
+ totalParts: session.totalParts,
2343
+ status: session.status,
2344
+ expiresAt: session.expiresAt.toISOString(),
2345
+ uploadedParts: parts.map((part) => ({
2346
+ partNumber: part.partNumber,
2347
+ size: part.size,
2348
+ etag: part.etag,
2349
+ })),
2350
+ }
2351
+ }
2352
+
2353
+ private toCompletedResponse(session: UploadSession): CompletedUploadResponse {
2354
+ return {
2355
+ uploadSessionId: session.id,
2356
+ status: 'completed',
2357
+ objectKey: session.objectKey,
2358
+ etag: session.objectEtag,
2359
+ }
2360
+ }
2361
+
2362
+ private async callStorage<T>(operation: () => Promise<T>): Promise<T> {
2363
+ try {
2364
+ return await operation()
2365
+ } catch (cause: unknown) {
2366
+ if (cause instanceof AppException) {
2367
+ throw cause
2368
+ }
2369
+
2370
+ if (findExactSizeError(cause)) {
2371
+ throw new AppException(UPLOAD_ERRORS.PART_SIZE_MISMATCH)
2372
+ }
2373
+
2374
+ if (cause instanceof StorageMultipartNotFoundError) {
2375
+ throw new AppException(UPLOAD_ERRORS.STORAGE_UPLOAD_MISSING)
2376
+ }
2377
+
2378
+ if (this.isAbortError(cause)) {
2379
+ throw new AppException(UPLOAD_ERRORS.PART_UPLOAD_ABORTED)
2380
+ }
2381
+
2382
+ throw new AppException(UPLOAD_ERRORS.STORAGE_UNAVAILABLE, { cause })
2383
+ }
2384
+ }
2385
+
2386
+ private isAbortError(cause: unknown): boolean {
2387
+ let current = cause
2388
+
2389
+ for (let depth = 0; depth < 8; depth += 1) {
2390
+ if (
2391
+ typeof current === 'object' &&
2392
+ current !== null &&
2393
+ 'name' in current &&
2394
+ current.name === 'AbortError'
2395
+ ) {
2396
+ return true
2397
+ }
2398
+
2399
+ if (typeof current !== 'object' || current === null || !('cause' in current)) {
2400
+ return false
2401
+ }
2402
+
2403
+ current = current.cause
2404
+ }
2405
+
2406
+ return false
2407
+ }
2408
+
2409
+ private async safeAbort(identity: MultipartIdentity): Promise<void> {
2410
+ try {
2411
+ await this.storage.abortMultipartUpload(identity)
2412
+ } catch (cause: unknown) {
2413
+ const message = cause instanceof Error ? cause.message : String(cause)
2414
+ this.logger.warn(`Compensating AbortMultipartUpload failed: ${message}`)
2415
+ // 这里只做补偿。失败时由 Bucket 生命周期规则兜底清理孤儿分片。
2416
+ }
2417
+ }
2418
+ }
2419
+ ```
2420
+
2421
+ ### 关键逻辑解释
2422
+
2423
+ #### 1. 初始化为什么先创建 MinIO Multipart,再写数据库
2424
+
2425
+ MinIO 的 `UploadId` 只能由存储服务生成,所以先创建 Multipart,再保存到数据库。如果数据库写失败,`catch` 会尝试 Abort。
2426
+
2427
+ 如果两个相同 `clientUploadId` 请求并发到达,数据库唯一约束只允许一个成为 winner;loser 创建出的 MinIO Multipart 会被 Abort。
2428
+
2429
+ 仍然存在极小的崩溃窗口:
2430
+
2431
+ ```text
2432
+ MinIO Create 成功
2433
+ ↓
2434
+ Node 进程在数据库写入前被强制杀死
2435
+ ```
2436
+
2437
+ 这类孤儿分片无法靠应用补偿,必须由 MinIO Bucket 生命周期规则兜底。
2438
+
2439
+ #### 2. 后端为什么同时检查 Content-Length 和真实流
2440
+
2441
+ - `Content-Length` 不匹配时,不必连接 MinIO 就可以立即拒绝。
2442
+ - 客户端可以伪造普通 HTTP 请求,所以还要用 `ExactSizeTransform` 检查真正读到的字节。
2443
+ - 浏览器发送 Blob 时会自动生成 Content-Length,JavaScript 不能也不需要手动设置这个受限请求头。
2444
+ - Node.js 源 `Readable` 的 `error` 不会因为调用 `.pipe()` 就自动可靠地传给目标 `Transform`,所以代码显式调用 `exactSizeStream.destroy(cause)`,并在 `finally` 中移除监听器。
2445
+ - MinIO 或 SDK 提前失败时,代码会 `unpipe` 并销毁目标 Transform,再用 `resume()` 排空尚未到达的请求体;这样不会把整片缓存进内存,也不会让 HTTP 连接因为请求体无人消费而长期停滞。
2446
+
2447
+ #### 3. 为什么完成接口不接收前端 ETag 数组
2448
+
2449
+ 可能发生:
2450
+
2451
+ ```text
2452
+ MinIO 已保存分片
2453
+ ↓
2454
+ NestJS 返回响应前断网
2455
+ ↓
2456
+ 前端以为这一片失败
2457
+ ```
2458
+
2459
+ MinIO 才是分片真实状态。完成时重新 `ListParts`,检查:
2460
+
2461
+ - 数量是否等于 `totalParts`。
2462
+ - 编号是否连续且为 `1...totalParts`。
2463
+ - 每片大小是否正确。
2464
+ - 总字节数是否等于 `fileSize`。
2465
+ - 每片是否都有 ETag。
2466
+
2467
+ #### 4. `completing` 为什么要配合 HeadObject
2468
+
2469
+ 可能发生“MinIO 已合并成功,但数据库更新失败或响应丢失”。再次完成时用唯一对象 Key 查询最终对象,并检查:
2470
+
2471
+ - 对象 Metadata 的 `upload-session-id`。
2472
+ - 对象 Content-Length。
2473
+
2474
+ 确认是本会话的对象后,再把数据库修复为 `completed`。如果连 HeadObject 都失败,保持 `completing`,避免错误地重新开放分片上传。
2475
+
2476
+ 代码用 `completeWasAttempted` 区分两个阶段:ListParts 失败或分片不完整时尚未调用 MinIO Complete,可以安全把状态恢复为 `uploading`;Complete 一旦发出,结果就可能不确定,此后必须保持 `completing` 并用 HeadObject 或后台任务恢复,不能贸然重新开放分片。
2477
+
2478
+ #### 5. 后端为什么不自动重试 UploadPart
2479
+
2480
+ Node.js 请求流被读过后不能可靠回放。后端重试可能发送空流或半片。前端保留着原始 `File`,可以重新执行 `file.slice()`,所以分片重试应放在前端。
2481
+
2482
+ ### 验证
2483
+
2484
+ 完成 Controller 后,用 25 MiB 文件验证:
2485
+
2486
+ - 初始化返回 `totalParts: 3`、`partSize: 10485760`。
2487
+ - 前两片返回 `size: 10485760`。
2488
+ - 第三片返回 `size: 5242880`。
2489
+ - 少传一片就调用 complete,应返回 HTTP 409 和 `UPLOAD.PARTS_INCOMPLETE`。
2490
+ - 相同 `partNumber` 重传后,状态中仍只有一个该编号。
2491
+
2492
+ ### 常见错误
2493
+
2494
+ - 把整个 Service 包在一个 catch 中,任何程序错误都伪装成 MinIO 503。
2495
+ - 完成请求直接使用前端提交的 ETag,不重新 ListParts。
2496
+ - 只检查分片数量,不检查编号连续性和每片大小。
2497
+ - MinIO Complete 成功后数据库更新失败,却把状态直接重置为 uploading,导致再次上传到已经消失的 UploadId。
2498
+ - `safeAbort` 没有生命周期兜底,补偿失败后分片永久占空间。
2499
+
2500
+ ---
2501
+
2502
+ ## 第 12 步:实现 Controller
2503
+
2504
+ ### 本步目标
2505
+
2506
+ 把五个 API 暴露给浏览器,沿用现有全局 Session Guard,并在客户端断开时取消 NestJS 到 MinIO 的请求。
2507
+
2508
+ ### 文件位置
2509
+
2510
+ 新建:
2511
+
2512
+ ```text
2513
+ apps/server/src/modules/upload/upload.controller.ts
2514
+ ```
2515
+
2516
+ ### 完整代码
2517
+
2518
+ ```ts
2519
+ import { CurrentAuth } from '@/common/decorators/current-auth.decorator'
2520
+ import type { CurrentAuthType } from '@/modules/auth/session/session.types'
2521
+ import {
2522
+ Body,
2523
+ Controller,
2524
+ Delete,
2525
+ Get,
2526
+ Headers,
2527
+ HttpCode,
2528
+ HttpStatus,
2529
+ Param,
2530
+ Post,
2531
+ Put,
2532
+ Req,
2533
+ Res,
2534
+ } from '@nestjs/common'
2535
+ import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'
2536
+ import type { FastifyReply, FastifyRequest } from 'fastify'
2537
+ import type { Readable } from 'node:stream'
2538
+ import { InitiateMultipartUploadDto } from './dto/initiate-multipart-upload.dto'
2539
+ import { UploadPartParamsDto, UploadSessionParamsDto } from './dto/upload-params.dto'
2540
+ import { UploadService } from './upload.service'
2541
+
2542
+ type OctetStreamRequest = FastifyRequest & {
2543
+ body: Readable
2544
+ }
2545
+
2546
+ @ApiTags('大文件分片上传')
2547
+ @ApiBearerAuth('session')
2548
+ @Controller('uploads/multipart')
2549
+ export class UploadController {
2550
+ constructor(private readonly uploadService: UploadService) {}
2551
+
2552
+ @ApiOperation({ summary: '初始化大文件分片上传' })
2553
+ @Post()
2554
+ initiate(@CurrentAuth() auth: CurrentAuthType, @Body() body: InitiateMultipartUploadDto) {
2555
+ return this.uploadService.initiate(auth.userId, body)
2556
+ }
2557
+
2558
+ @ApiOperation({ summary: '查询上传状态和已上传分片' })
2559
+ @Get(':uploadSessionId')
2560
+ getStatus(@CurrentAuth() auth: CurrentAuthType, @Param() params: UploadSessionParamsDto) {
2561
+ return this.uploadService.getStatus(auth.userId, params.uploadSessionId)
2562
+ }
2563
+
2564
+ @ApiOperation({ summary: '上传一个原始二进制分片' })
2565
+ @ApiConsumes('application/octet-stream')
2566
+ @ApiBody({
2567
+ schema: {
2568
+ type: 'string',
2569
+ format: 'binary',
2570
+ },
2571
+ })
2572
+ @HttpCode(HttpStatus.OK)
2573
+ @Put(':uploadSessionId/parts/:partNumber')
2574
+ uploadPart(
2575
+ @CurrentAuth() auth: CurrentAuthType,
2576
+ @Param() params: UploadPartParamsDto,
2577
+ @Headers('content-length') contentLength: string | undefined,
2578
+ @Headers('content-encoding') contentEncoding: string | undefined,
2579
+ @Req() request: OctetStreamRequest,
2580
+ @Res({ passthrough: true }) reply: FastifyReply,
2581
+ ) {
2582
+ const abortController = new AbortController()
2583
+ const onAborted = () => abortController.abort()
2584
+ const onReplyClose = () => {
2585
+ if (!reply.raw.writableEnded) {
2586
+ abortController.abort()
2587
+ }
2588
+ }
2589
+
2590
+ request.raw.once('aborted', onAborted)
2591
+ reply.raw.once('close', onReplyClose)
2592
+
2593
+ return this.uploadService
2594
+ .uploadPart({
2595
+ ownerId: auth.userId,
2596
+ uploadSessionId: params.uploadSessionId,
2597
+ partNumber: params.partNumber,
2598
+ contentLength,
2599
+ contentEncoding,
2600
+ body: request.body,
2601
+ abortSignal: abortController.signal,
2602
+ })
2603
+ .finally(() => {
2604
+ // 前置校验失败时 Service 可能尚未 pipe 请求体。
2605
+ // 统一排空剩余字节,避免连接一直占用到代理超时;这里不会聚合 Buffer。
2606
+ if (!request.body.readableEnded && !request.body.destroyed) {
2607
+ request.body.resume()
2608
+ }
2609
+
2610
+ request.raw.off('aborted', onAborted)
2611
+ reply.raw.off('close', onReplyClose)
2612
+ })
2613
+ }
2614
+
2615
+ @ApiOperation({ summary: '校验并完成分片上传' })
2616
+ @HttpCode(HttpStatus.OK)
2617
+ @Post(':uploadSessionId/complete')
2618
+ complete(@CurrentAuth() auth: CurrentAuthType, @Param() params: UploadSessionParamsDto) {
2619
+ return this.uploadService.complete(auth.userId, params.uploadSessionId)
2620
+ }
2621
+
2622
+ @ApiOperation({ summary: '取消分片上传' })
2623
+ @HttpCode(HttpStatus.OK)
2624
+ @Delete(':uploadSessionId')
2625
+ abort(@CurrentAuth() auth: CurrentAuthType, @Param() params: UploadSessionParamsDto) {
2626
+ return this.uploadService.abort(auth.userId, params.uploadSessionId)
2627
+ }
2628
+ }
2629
+ ```
2630
+
2631
+ ### 代码解释
2632
+
2633
+ - 不加 `@Public()`,因此现有全局 `SessionAuthGuard` 会保护所有上传接口。
2634
+ - 每个方法都用 `@CurrentAuth()` 取得 `auth.userId`。
2635
+ - 分片端点不使用 `@Body()` DTO,因为它的 Body 是 Node.js 流。
2636
+ - `request.raw` 触发 `aborted` 时,`AbortController` 会取消 AWS SDK 请求。
2637
+ - 请求体已经发完、浏览器却在等待响应时断开,未必再触发 `request.raw.aborted`,所以还要监听响应流 `close`;只有 `writableEnded` 仍为 `false` 才视为异常断开。
2638
+ - `Content-Encoding` 只允许空或 `identity`,避免代理或客户端压缩后破坏字节数协议。
2639
+ - 即使状态、PartNumber 或 `Content-Length` 在 pipe 前就校验失败,Controller 的 `finally` 也会用 `resume()` 丢弃剩余请求体,避免原始流无人消费;它不会把请求体读进 Buffer。
2640
+
2641
+ ### 验证
2642
+
2643
+ 启动 Server:
2644
+
2645
+ ```powershell
2646
+ pnpm --filter server dev
2647
+ ```
2648
+
2649
+ 打开:
2650
+
2651
+ ```text
2652
+ http://127.0.0.1:13000/api-docs
2653
+ ```
2654
+
2655
+ 预期看到“大文件分片上传”分组和五个接口。
2656
+
2657
+ 不带 Token 请求初始化:
2658
+
2659
+ ```powershell
2660
+ curl.exe -i -X POST http://127.0.0.1:13000/uploads/multipart -H "Content-Type: application/json" --data '{"clientUploadId":"00000000-0000-4000-8000-000000000001","fileName":"demo.bin","fileSize":10485760,"contentType":"application/octet-stream"}'
2661
+ ```
2662
+
2663
+ 预期返回 401 和 `AUTH.SESSION_TOKEN_MISSING`。
2664
+
2665
+ ### 常见错误
2666
+
2667
+ - 给上传 Controller 加 `@Public()`,导致任意人消耗存储和带宽。
2668
+ - 分片端点写 `@Body() body: Buffer`,隐式整片缓存。
2669
+ - 在浏览器代码中手动设置 `Content-Length`。浏览器禁止设置,发送 Blob 时会自动处理。
2670
+ - 只监听前端取消,不取消后端正在进行的 MinIO 请求。
2671
+
2672
+ ---
2673
+
2674
+ ## 第 13 步:创建 Module 并接入 AppModule
2675
+
2676
+ ### 本步目标
2677
+
2678
+ 完成依赖注入:Controller → Service → Repository 和 Storage Port → MinIO Adapter。
2679
+
2680
+ ### 文件位置
2681
+
2682
+ 新建:
2683
+
2684
+ ```text
2685
+ apps/server/src/modules/upload/upload.module.ts
2686
+ ```
2687
+
2688
+ ### 完整代码
2689
+
2690
+ ```ts
2691
+ import { Module } from '@nestjs/common'
2692
+ import { MinioStorageAdapter } from './storage/minio-storage.adapter'
2693
+ import { STORAGE_PORT } from './storage/storage.port'
2694
+ import { UploadController } from './upload.controller'
2695
+ import { UploadRepository } from './upload.repository'
2696
+ import { UploadService } from './upload.service'
2697
+
2698
+ @Module({
2699
+ controllers: [UploadController],
2700
+ providers: [
2701
+ UploadRepository,
2702
+ UploadService,
2703
+ MinioStorageAdapter,
2704
+ {
2705
+ provide: STORAGE_PORT,
2706
+ useExisting: MinioStorageAdapter,
2707
+ },
2708
+ ],
2709
+ })
2710
+ export class UploadModule {}
2711
+ ```
2712
+
2713
+ ### 文件位置
2714
+
2715
+ 修改:
2716
+
2717
+ ```text
2718
+ apps/server/src/app.module.ts
2719
+ ```
2720
+
2721
+ ### 完整代码
2722
+
2723
+ 将文件替换为:
2724
+
2725
+ ```ts
2726
+ import {
2727
+ appConfig,
2728
+ databaseConfig,
2729
+ llmConfig,
2730
+ redisConfig,
2731
+ sessionConfig,
2732
+ storageConfig,
2733
+ } from '@/config'
2734
+ import { RedisModule } from '@nestjs-modules/ioredis'
2735
+ import { Module } from '@nestjs/common'
2736
+ import { ConfigModule, ConfigService } from '@nestjs/config'
2737
+ import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'
2738
+ import { ZodSerializerInterceptor, ZodValidationPipe } from 'nestjs-zod'
2739
+ import { AppController } from './app.controller'
2740
+ import { AppService } from './app.service'
2741
+ import { GlobalExceptionFilter } from './common/filters/global-exception.filter'
2742
+ import { DatabaseModule } from './database/db.module'
2743
+ import { AuthModule } from './modules/auth/auth.module'
2744
+ import { TestDbModule } from './modules/test/db/db.module'
2745
+ import { TestRedisModule } from './modules/test/redis/redis.module'
2746
+ import { UploadModule } from './modules/upload/upload.module'
2747
+ import { ENV_ARR } from './utils/env-arr'
2748
+
2749
+ @Module({
2750
+ imports: [
2751
+ ConfigModule.forRoot({
2752
+ isGlobal: true,
2753
+ envFilePath: ENV_ARR,
2754
+ load: [appConfig, databaseConfig, llmConfig, redisConfig, sessionConfig, storageConfig],
2755
+ }),
2756
+ DatabaseModule,
2757
+ RedisModule.forRootAsync({
2758
+ inject: [ConfigService],
2759
+ useFactory: (config) => ({
2760
+ type: 'single',
2761
+ options: {
2762
+ host: config.get('redis.host'),
2763
+ port: config.get('redis.port'),
2764
+ password: config.get('redis.password'),
2765
+ db: config.get('redis.db'),
2766
+ connectTimeout: 3000,
2767
+ commandTimeout: 2000,
2768
+ maxRetriesPerRequest: 1,
2769
+ enableOfflineQueue: false,
2770
+ },
2771
+ }),
2772
+ }),
2773
+ TestRedisModule,
2774
+ TestDbModule,
2775
+ AuthModule,
2776
+ UploadModule,
2777
+ ],
2778
+ controllers: [AppController],
2779
+ providers: [
2780
+ AppService,
2781
+ {
2782
+ provide: APP_PIPE,
2783
+ useClass: ZodValidationPipe,
2784
+ },
2785
+ {
2786
+ provide: APP_INTERCEPTOR,
2787
+ useClass: ZodSerializerInterceptor,
2788
+ },
2789
+ {
2790
+ provide: APP_FILTER,
2791
+ useClass: GlobalExceptionFilter,
2792
+ },
2793
+ ],
2794
+ })
2795
+ export class AppModule {}
2796
+ ```
2797
+
2798
+ ### 代码解释
2799
+
2800
+ `useExisting` 表示 `STORAGE_PORT` 和 `MinioStorageAdapter` 使用同一个实例,不会重复创建 S3Client。
2801
+
2802
+ `DatabaseModule` 已经是全局模块,`ConfigModule` 也是全局配置,因此 `UploadModule` 不需要重复导入它们。
2803
+
2804
+ ### 验证
2805
+
2806
+ ```powershell
2807
+ pnpm --filter server build
2808
+ pnpm --filter server test
2809
+ vp check
2810
+ ```
2811
+
2812
+ 预期:
2813
+
2814
+ - 没有 `Nest can't resolve dependencies`。
2815
+ - `vp check` 没有 TypeScript 路径、未使用导入或 AWS SDK 类型错误。
2816
+ - 错误目录测试通过。
2817
+
2818
+ 当前 Server 构建使用 SWC 且 `typeCheck: false`,所以 `build` 成功不能替代 `vp check`;两者用途不同。
2819
+
2820
+ ### 常见错误
2821
+
2822
+ - Provider 使用 `useClass` 又单独注册 Adapter,创建两个 S3Client 实例。
2823
+ - 忘记在 AppModule 导入 UploadModule,Swagger 中完全看不到接口。
2824
+ - 只在 `config/index.ts` 导出配置,没有加入 `load` 数组。
2825
+
2826
+ ---
2827
+
2828
+ ## 第 14 步:增加分片规则和流测试
2829
+
2830
+ ### 本步目标
2831
+
2832
+ 先用纯单元测试锁住最容易出错的两部分:最后一片计算和真实流字节数。
2833
+
2834
+ ### 文件位置
2835
+
2836
+ 新建:
2837
+
2838
+ ```text
2839
+ apps/server/test/upload-constants.spec.ts
2840
+ ```
2841
+
2842
+ ### 完整代码
2843
+
2844
+ ```ts
2845
+ import {
2846
+ calculateExpectedPartSize,
2847
+ calculateTotalParts,
2848
+ UPLOAD_PART_SIZE,
2849
+ } from '@/modules/upload/upload.constants'
2850
+ import { describe, expect, it } from 'vite-plus/test'
2851
+
2852
+ describe('upload constants', () => {
2853
+ it('splits a 25 MiB file into 10 + 10 + 5 MiB', () => {
2854
+ const fileSize = 25 * 1024 * 1024
2855
+ const totalParts = calculateTotalParts(fileSize)
2856
+
2857
+ expect(totalParts).toBe(3)
2858
+ expect(calculateExpectedPartSize(fileSize, totalParts, 1)).toBe(UPLOAD_PART_SIZE)
2859
+ expect(calculateExpectedPartSize(fileSize, totalParts, 2)).toBe(UPLOAD_PART_SIZE)
2860
+ expect(calculateExpectedPartSize(fileSize, totalParts, 3)).toBe(5 * 1024 * 1024)
2861
+ })
2862
+
2863
+ it('uses one full part when file size is exactly 10 MiB', () => {
2864
+ expect(calculateTotalParts(UPLOAD_PART_SIZE)).toBe(1)
2865
+ expect(calculateExpectedPartSize(UPLOAD_PART_SIZE, 1, 1)).toBe(UPLOAD_PART_SIZE)
2866
+ })
2867
+
2868
+ it('rejects a part number outside the file range', () => {
2869
+ expect(() => calculateExpectedPartSize(UPLOAD_PART_SIZE, 1, 2)).toThrow(RangeError)
2870
+ })
2871
+ })
2872
+ ```
2873
+
2874
+ ### 文件位置
2875
+
2876
+ 新建:
2877
+
2878
+ ```text
2879
+ apps/server/test/upload-stream.spec.ts
2880
+ ```
2881
+
2882
+ ### 完整代码
2883
+
2884
+ ```ts
2885
+ import { ExactSizeError, ExactSizeTransform } from '@/modules/upload/storage/exact-size.transform'
2886
+ import { Readable, Writable } from 'node:stream'
2887
+ import { pipeline } from 'node:stream/promises'
2888
+ import { describe, expect, it } from 'vite-plus/test'
2889
+
2890
+ async function consume(actualBytes: number, expectedBytes: number) {
2891
+ await pipeline(
2892
+ Readable.from([Buffer.alloc(actualBytes)]),
2893
+ new ExactSizeTransform(expectedBytes),
2894
+ new Writable({
2895
+ write(_chunk, _encoding, callback) {
2896
+ callback()
2897
+ },
2898
+ }),
2899
+ )
2900
+ }
2901
+
2902
+ describe('ExactSizeTransform', () => {
2903
+ it('accepts the exact byte count', async () => {
2904
+ await expect(consume(1024, 1024)).resolves.toBeUndefined()
2905
+ })
2906
+
2907
+ it('rejects a stream that is too large', async () => {
2908
+ await expect(consume(1025, 1024)).rejects.toBeInstanceOf(ExactSizeError)
2909
+ })
2910
+
2911
+ it('rejects a stream that ends early', async () => {
2912
+ await expect(consume(1023, 1024)).rejects.toBeInstanceOf(ExactSizeError)
2913
+ })
2914
+ })
2915
+ ```
2916
+
2917
+ ### 验证
2918
+
2919
+ ```powershell
2920
+ pnpm --filter server test
2921
+ vp check
2922
+ ```
2923
+
2924
+ 预期新增的两个测试文件和原错误目录测试全部通过,并且 Vite+ 的格式、Lint、TypeScript 检查通过。当前 Nest build 使用 SWC 且 `typeCheck: false`,所以“能 build”不能替代 `vp check`。
2925
+
2926
+ ### 还应补充的集成测试
2927
+
2928
+ 真正用于生产前,还应使用独立 PostgreSQL 和 MinIO 测试以下场景:
2929
+
2930
+ - 用户 B 无法读取或操作用户 A 的会话。
2931
+ - 同一个 `clientUploadId` 并发初始化只得到一个数据库会话。
2932
+ - 同一个 PartNumber 重传后只有一个分片。
2933
+ - 缺片、乱序或大小不正确时 Complete 返回 409。
2934
+ - Complete 响应丢失后,第二次 Complete 能通过 HeadObject 恢复。
2935
+ - Abort 重复调用仍然成功。
2936
+ - 过期会话不能继续上传。
2937
+ - `ListParts` 超过 1000 片时正确分页。
2938
+ - 请求源流报错会销毁传给 MinIO 的 Transform。
2939
+ - 浏览器请求体发完后异常断开,仍会取消到 MinIO 的请求。
2940
+ - 无效会话状态或错误 `Content-Length` 携带较大请求体时,请求体仍会被排空,后续请求可以复用连接。
2941
+
2942
+ 不要把真实 MinIO 集成测试和普通单元测试混用同一个生产 Bucket。
2943
+
2944
+ ### 常见错误
2945
+
2946
+ - 只运行 `nest build`,误以为 SWC 构建已经完成 TypeScript 类型检查。
2947
+ - 流测试只验证“正好大小”,没有覆盖过大和提前结束。
2948
+ - 集成测试直接连接共享或生产 Bucket,失败清理时污染真实数据。
2949
+ - 只测几十片,漏掉 `ListParts` 每页通常最多 1000 片的分页边界。
2950
+
2951
+ ---
2952
+
2953
+ ## 第 15 步:增加最小 React 前端联调代码
2954
+
2955
+ 这一部分不是后端必须文件,但你是前端开发者,建议先照着它验证完整协议,再封装成项目自己的 Hook 和页面。
2956
+
2957
+ ### 本步目标
2958
+
2959
+ - JSON 控制接口使用项目现有 Alova。
2960
+ - 分片 PUT 使用 XHR,获得稳定的上传进度、取消能力和 HTTP 状态。
2961
+ - 默认并发 3,最大 5。
2962
+ - 每片最多尝试 3 次。
2963
+ - 暂停后不调用 DELETE;恢复时使用同一个 `clientUploadId` 并重新查询状态。
2964
+
2965
+ ### 文件位置
2966
+
2967
+ 先修正开发代理:
2968
+
2969
+ ```text
2970
+ apps/web/.env.dev
2971
+ ```
2972
+
2973
+ 把:
2974
+
2975
+ ```dotenv
2976
+ VITE_API_URL = http://localhost:10000
2977
+ ```
2978
+
2979
+ 改为:
2980
+
2981
+ ```dotenv
2982
+ VITE_API_URL = http://localhost:13000
2983
+ ```
2984
+
2985
+ 修改环境变量后必须重启前端开发服务器。
2986
+
2987
+ ### 文件位置
2988
+
2989
+ 新建:
2990
+
2991
+ ```text
2992
+ apps/web/src/services/multipart-upload.ts
2993
+ ```
2994
+
2995
+ ### 完整代码
2996
+
2997
+ ```ts
2998
+ import { envVariables } from '@/utils/env'
2999
+ import alovaRequest from '@/utils/request'
3000
+
3001
+ export type UploadSessionStatus =
3002
+ 'uploading' | 'completing' | 'completed' | 'aborting' | 'aborted' | 'expired'
3003
+
3004
+ export interface UploadedPart {
3005
+ partNumber: number
3006
+ size: number
3007
+ etag: string
3008
+ }
3009
+
3010
+ export interface UploadStatusResponse {
3011
+ uploadSessionId: string
3012
+ fileName: string
3013
+ fileSize: number
3014
+ contentType: string
3015
+ partSize: number
3016
+ totalParts: number
3017
+ status: UploadSessionStatus
3018
+ expiresAt: string
3019
+ uploadedParts: UploadedPart[]
3020
+ }
3021
+
3022
+ export interface CompletedUploadResponse {
3023
+ uploadSessionId: string
3024
+ status: 'completed'
3025
+ objectKey: string
3026
+ etag: string | null
3027
+ }
3028
+
3029
+ export interface InitiateUploadInput {
3030
+ clientUploadId: string
3031
+ fileName: string
3032
+ fileSize: number
3033
+ contentType: string
3034
+ }
3035
+
3036
+ const API_PATH = 'uploads/multipart'
3037
+ const CONTROL_REQUEST_TIMEOUT_MS = 30 * 1000
3038
+ const ABORT_REQUEST_TIMEOUT_MS = 60 * 1000
3039
+ const COMPLETE_REQUEST_TIMEOUT_MS = 10 * 60 * 1000
3040
+ const uploadRequest = alovaRequest({
3041
+ isWrapped: false,
3042
+ isShowSuccessMessage: false,
3043
+ isShowErrorMessage: false,
3044
+ })
3045
+
3046
+ interface AbortableMethod<T> {
3047
+ send: () => Promise<T>
3048
+ abort: () => Promise<void>
3049
+ }
3050
+
3051
+ async function sendMethod<T>(method: AbortableMethod<T>, signal?: AbortSignal): Promise<T> {
3052
+ if (signal?.aborted) {
3053
+ throw new DOMException('Upload paused or cancelled', 'AbortError')
3054
+ }
3055
+
3056
+ const onAbort = () => {
3057
+ void method.abort().catch(() => undefined)
3058
+ }
3059
+
3060
+ signal?.addEventListener('abort', onAbort, { once: true })
3061
+
3062
+ try {
3063
+ return await method.send()
3064
+ } catch (cause: unknown) {
3065
+ if (signal?.aborted) {
3066
+ throw new DOMException('Upload paused or cancelled', 'AbortError')
3067
+ }
3068
+
3069
+ throw cause
3070
+ } finally {
3071
+ signal?.removeEventListener('abort', onAbort)
3072
+ }
3073
+ }
3074
+
3075
+ function authorization(accessToken: string) {
3076
+ return {
3077
+ Authorization: `Bearer ${accessToken}`,
3078
+ }
3079
+ }
3080
+
3081
+ export function buildUploadApiUrl(path: string): string {
3082
+ const rawBase = (envVariables.API_AFFIX || '').replace(/\/+$/, '')
3083
+ const base =
3084
+ !rawBase || rawBase.startsWith('/') || /^https?:\/\//.test(rawBase) ? rawBase : `/${rawBase}`
3085
+ const normalizedPath = path.replace(/^\/+/, '')
3086
+
3087
+ return `${base}/${normalizedPath}`
3088
+ }
3089
+
3090
+ export function initiateMultipartUpload(
3091
+ input: InitiateUploadInput,
3092
+ accessToken: string,
3093
+ signal?: AbortSignal,
3094
+ ) {
3095
+ const method = uploadRequest.Post<UploadStatusResponse>(API_PATH, input, {
3096
+ headers: authorization(accessToken),
3097
+ timeout: CONTROL_REQUEST_TIMEOUT_MS,
3098
+ })
3099
+
3100
+ return sendMethod(method, signal)
3101
+ }
3102
+
3103
+ export function getMultipartUploadStatus(
3104
+ uploadSessionId: string,
3105
+ accessToken: string,
3106
+ signal?: AbortSignal,
3107
+ ) {
3108
+ const method = uploadRequest.Get<UploadStatusResponse>(`${API_PATH}/${uploadSessionId}`, {
3109
+ headers: authorization(accessToken),
3110
+ timeout: CONTROL_REQUEST_TIMEOUT_MS,
3111
+ })
3112
+
3113
+ return sendMethod(method, signal)
3114
+ }
3115
+
3116
+ export function completeMultipartUpload(
3117
+ uploadSessionId: string,
3118
+ accessToken: string,
3119
+ signal?: AbortSignal,
3120
+ ) {
3121
+ const method = uploadRequest.Post<CompletedUploadResponse>(
3122
+ `${API_PATH}/${uploadSessionId}/complete`,
3123
+ undefined,
3124
+ {
3125
+ headers: authorization(accessToken),
3126
+ timeout: COMPLETE_REQUEST_TIMEOUT_MS,
3127
+ },
3128
+ )
3129
+
3130
+ return sendMethod(method, signal)
3131
+ }
3132
+
3133
+ export function abortMultipartUpload(
3134
+ uploadSessionId: string,
3135
+ accessToken: string,
3136
+ signal?: AbortSignal,
3137
+ ) {
3138
+ const method = uploadRequest.Delete<{
3139
+ uploadSessionId: string
3140
+ status: 'aborted'
3141
+ }>(`${API_PATH}/${uploadSessionId}`, undefined, {
3142
+ headers: authorization(accessToken),
3143
+ timeout: ABORT_REQUEST_TIMEOUT_MS,
3144
+ })
3145
+
3146
+ return sendMethod(method, signal)
3147
+ }
3148
+
3149
+ export function getUploadPartUrl(uploadSessionId: string, partNumber: number): string {
3150
+ return buildUploadApiUrl(`${API_PATH}/${uploadSessionId}/parts/${partNumber}`)
3151
+ }
3152
+ ```
3153
+
3154
+ ### 为什么这里必须 `isWrapped: false`
3155
+
3156
+ 当前请求封装默认期望:
3157
+
3158
+ ```json
3159
+ {
3160
+ "code": 200,
3161
+ "data": {}
3162
+ }
3163
+ ```
3164
+
3165
+ 但当前 NestJS Controller 成功时直接返回:
3166
+
3167
+ ```json
3168
+ {
3169
+ "uploadSessionId": "...",
3170
+ "status": "uploading"
3171
+ }
3172
+ ```
3173
+
3174
+ 不设置 `isWrapped: false` 时,HTTP 明明是 200,前端仍可能因为找不到外层 `code/data` 而判定失败。
3175
+
3176
+ ### 文件位置
3177
+
3178
+ 新建:
3179
+
3180
+ ```text
3181
+ apps/web/src/utils/file-upload/multipart-uploader.ts
3182
+ ```
3183
+
3184
+ ### 完整代码
3185
+
3186
+ ```ts
3187
+ import {
3188
+ completeMultipartUpload,
3189
+ getMultipartUploadStatus,
3190
+ getUploadPartUrl,
3191
+ initiateMultipartUpload,
3192
+ type CompletedUploadResponse,
3193
+ } from '@/services/multipart-upload'
3194
+
3195
+ const DEFAULT_CONCURRENCY = 3
3196
+ const MAX_CONCURRENCY = 5
3197
+ const MAX_ATTEMPTS = 3
3198
+ const PART_REQUEST_TIMEOUT_MS = 10 * 60 * 1000
3199
+ const COMPLETING_POLL_INTERVAL_MS = 1000
3200
+ const COMPLETING_MAX_POLLS = 60
3201
+
3202
+ export class UploadPartHttpError extends Error {
3203
+ readonly status: number
3204
+ readonly responseBody: unknown
3205
+
3206
+ constructor(status: number, responseBody: unknown) {
3207
+ super(`Upload part failed with HTTP ${status}`)
3208
+ this.name = UploadPartHttpError.name
3209
+ this.status = status
3210
+ this.responseBody = responseBody
3211
+ }
3212
+ }
3213
+
3214
+ export class UploadPartNetworkError extends Error {
3215
+ constructor(message = 'Upload part failed because of a network error') {
3216
+ super(message)
3217
+ this.name = UploadPartNetworkError.name
3218
+ }
3219
+ }
3220
+
3221
+ interface UploadChunkInput {
3222
+ url: string
3223
+ chunk: Blob
3224
+ accessToken: string
3225
+ signal: AbortSignal
3226
+ onProgress: (loaded: number) => void
3227
+ }
3228
+
3229
+ function parseResponseBody(text: string): unknown {
3230
+ if (!text) return undefined
3231
+
3232
+ try {
3233
+ return JSON.parse(text)
3234
+ } catch {
3235
+ return text
3236
+ }
3237
+ }
3238
+
3239
+ function uploadChunk(input: UploadChunkInput): Promise<void> {
3240
+ if (input.signal.aborted) {
3241
+ return Promise.reject(new DOMException('Upload paused or cancelled', 'AbortError'))
3242
+ }
3243
+
3244
+ return new Promise((resolve, reject) => {
3245
+ const xhr = new XMLHttpRequest()
3246
+ const onAbort = () => xhr.abort()
3247
+
3248
+ const cleanup = () => {
3249
+ input.signal.removeEventListener('abort', onAbort)
3250
+ }
3251
+
3252
+ xhr.open('PUT', input.url)
3253
+ xhr.timeout = PART_REQUEST_TIMEOUT_MS
3254
+ xhr.setRequestHeader('Authorization', `Bearer ${input.accessToken}`)
3255
+ xhr.setRequestHeader('Content-Type', 'application/octet-stream')
3256
+
3257
+ xhr.upload.onprogress = (event) => {
3258
+ input.onProgress(event.loaded)
3259
+ }
3260
+
3261
+ xhr.onload = () => {
3262
+ cleanup()
3263
+
3264
+ if (xhr.status >= 200 && xhr.status < 300) {
3265
+ resolve()
3266
+ return
3267
+ }
3268
+
3269
+ reject(new UploadPartHttpError(xhr.status, parseResponseBody(xhr.responseText)))
3270
+ }
3271
+
3272
+ xhr.onerror = () => {
3273
+ cleanup()
3274
+ reject(new UploadPartNetworkError())
3275
+ }
3276
+
3277
+ xhr.ontimeout = () => {
3278
+ cleanup()
3279
+ reject(new UploadPartNetworkError('Upload part request timed out'))
3280
+ }
3281
+
3282
+ xhr.onabort = () => {
3283
+ cleanup()
3284
+ reject(new DOMException('Upload paused or cancelled', 'AbortError'))
3285
+ }
3286
+
3287
+ input.signal.addEventListener('abort', onAbort, { once: true })
3288
+ xhr.send(input.chunk)
3289
+ })
3290
+ }
3291
+
3292
+ function isRetryable(error: unknown): boolean {
3293
+ if (error instanceof UploadPartNetworkError) {
3294
+ return true
3295
+ }
3296
+
3297
+ if (error instanceof UploadPartHttpError) {
3298
+ return error.status === 408 || error.status === 429 || error.status >= 500
3299
+ }
3300
+
3301
+ return false
3302
+ }
3303
+
3304
+ function wait(ms: number, signal: AbortSignal): Promise<void> {
3305
+ if (signal.aborted) {
3306
+ return Promise.reject(new DOMException('Upload paused or cancelled', 'AbortError'))
3307
+ }
3308
+
3309
+ return new Promise((resolve, reject) => {
3310
+ const timer = window.setTimeout(() => {
3311
+ signal.removeEventListener('abort', onAbort)
3312
+ resolve()
3313
+ }, ms)
3314
+
3315
+ const onAbort = () => {
3316
+ window.clearTimeout(timer)
3317
+ reject(new DOMException('Upload paused or cancelled', 'AbortError'))
3318
+ }
3319
+
3320
+ signal.addEventListener('abort', onAbort, { once: true })
3321
+ })
3322
+ }
3323
+
3324
+ async function uploadChunkWithRetry(input: UploadChunkInput): Promise<void> {
3325
+ let lastError: unknown
3326
+
3327
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
3328
+ try {
3329
+ input.onProgress(0)
3330
+ await uploadChunk(input)
3331
+ return
3332
+ } catch (error: unknown) {
3333
+ lastError = error
3334
+
3335
+ if (attempt === MAX_ATTEMPTS || !isRetryable(error) || input.signal.aborted) {
3336
+ throw error
3337
+ }
3338
+
3339
+ const baseDelay = 500 * 2 ** (attempt - 1)
3340
+ const jitter = Math.floor(Math.random() * 300)
3341
+ await wait(baseDelay + jitter, input.signal)
3342
+ }
3343
+ }
3344
+
3345
+ throw lastError
3346
+ }
3347
+
3348
+ function throwIfAborted(signal?: AbortSignal): void {
3349
+ if (signal?.aborted) {
3350
+ throw new DOMException('Upload paused or cancelled', 'AbortError')
3351
+ }
3352
+ }
3353
+
3354
+ export type UploadPhase = 'initializing' | 'uploading' | 'completing' | 'completed'
3355
+
3356
+ export interface UploadSessionSnapshot {
3357
+ uploadSessionId?: string
3358
+ clientUploadId: string
3359
+ fileName: string
3360
+ fileSize: number
3361
+ contentType: string
3362
+ lastModified: number
3363
+ }
3364
+
3365
+ export interface UploadMultipartFileOptions {
3366
+ file: File
3367
+ clientUploadId: string
3368
+ accessToken: string
3369
+ concurrency?: number
3370
+ signal?: AbortSignal
3371
+ onSession?: (session: UploadSessionSnapshot) => void
3372
+ onPhaseChange?: (phase: UploadPhase) => void
3373
+ onProgress?: (input: { loaded: number; total: number; percentage: number }) => void
3374
+ }
3375
+
3376
+ async function waitForCompletedStatus(
3377
+ uploadSessionId: string,
3378
+ accessToken: string,
3379
+ signal?: AbortSignal,
3380
+ ): Promise<void> {
3381
+ for (let poll = 0; poll < COMPLETING_MAX_POLLS; poll += 1) {
3382
+ throwIfAborted(signal)
3383
+ const status = await getMultipartUploadStatus(uploadSessionId, accessToken, signal)
3384
+ throwIfAborted(signal)
3385
+
3386
+ if (status.status === 'completed') {
3387
+ return
3388
+ }
3389
+
3390
+ if (status.status !== 'completing') {
3391
+ throw new Error(`Upload left completing state: ${status.status}`)
3392
+ }
3393
+
3394
+ if (signal) {
3395
+ await wait(COMPLETING_POLL_INTERVAL_MS, signal)
3396
+ } else {
3397
+ await new Promise<void>((resolve) => {
3398
+ window.setTimeout(resolve, COMPLETING_POLL_INTERVAL_MS)
3399
+ })
3400
+ }
3401
+ }
3402
+
3403
+ throw new Error('Upload is still completing; retry status query later')
3404
+ }
3405
+
3406
+ async function completeWithRecovery(
3407
+ uploadSessionId: string,
3408
+ accessToken: string,
3409
+ ): Promise<CompletedUploadResponse> {
3410
+ try {
3411
+ return await completeMultipartUpload(uploadSessionId, accessToken)
3412
+ } catch (completeError: unknown) {
3413
+ let status: Awaited<ReturnType<typeof getMultipartUploadStatus>>
3414
+
3415
+ try {
3416
+ status = await getMultipartUploadStatus(uploadSessionId, accessToken)
3417
+ } catch {
3418
+ throw completeError
3419
+ }
3420
+
3421
+ if (status.status === 'completing') {
3422
+ try {
3423
+ await waitForCompletedStatus(uploadSessionId, accessToken)
3424
+ } catch {
3425
+ throw completeError
3426
+ }
3427
+ } else if (status.status !== 'completed') {
3428
+ throw completeError
3429
+ }
3430
+
3431
+ // 第一次 Complete 可能已成功,只是响应在网络中丢失。
3432
+ // 再调用一次幂等 Complete,取回完整的对象信息。
3433
+ return completeMultipartUpload(uploadSessionId, accessToken)
3434
+ }
3435
+ }
3436
+
3437
+ export async function uploadMultipartFile(
3438
+ options: UploadMultipartFileOptions,
3439
+ ): Promise<CompletedUploadResponse> {
3440
+ const { file, clientUploadId, accessToken, onProgress, onSession, onPhaseChange } = options
3441
+ const concurrency = Math.min(
3442
+ MAX_CONCURRENCY,
3443
+ Math.max(1, Math.floor(options.concurrency ?? DEFAULT_CONCURRENCY)),
3444
+ )
3445
+ const controller = new AbortController()
3446
+ const abortWorkers = () => controller.abort()
3447
+
3448
+ throwIfAborted(options.signal ?? controller.signal)
3449
+
3450
+ options.signal?.addEventListener('abort', abortWorkers, { once: true })
3451
+
3452
+ try {
3453
+ onPhaseChange?.('initializing')
3454
+ throwIfAborted(controller.signal)
3455
+ const initiated = await initiateMultipartUpload(
3456
+ {
3457
+ clientUploadId,
3458
+ fileName: file.name,
3459
+ fileSize: file.size,
3460
+ contentType: file.type || 'application/octet-stream',
3461
+ },
3462
+ accessToken,
3463
+ controller.signal,
3464
+ )
3465
+ onSession?.({
3466
+ uploadSessionId: initiated.uploadSessionId,
3467
+ clientUploadId,
3468
+ fileName: file.name,
3469
+ fileSize: file.size,
3470
+ contentType: file.type || 'application/octet-stream',
3471
+ lastModified: file.lastModified,
3472
+ })
3473
+
3474
+ throwIfAborted(controller.signal)
3475
+ const status = await getMultipartUploadStatus(
3476
+ initiated.uploadSessionId,
3477
+ accessToken,
3478
+ controller.signal,
3479
+ )
3480
+ throwIfAborted(controller.signal)
3481
+
3482
+ if (status.status === 'completing') {
3483
+ onPhaseChange?.('completing')
3484
+ await waitForCompletedStatus(status.uploadSessionId, accessToken)
3485
+ const completed = await completeWithRecovery(status.uploadSessionId, accessToken)
3486
+ onPhaseChange?.('completed')
3487
+ return completed
3488
+ }
3489
+
3490
+ if (status.status === 'completed') {
3491
+ onPhaseChange?.('completing')
3492
+ const completed = await completeWithRecovery(status.uploadSessionId, accessToken)
3493
+ onPhaseChange?.('completed')
3494
+ return completed
3495
+ }
3496
+
3497
+ if (status.status !== 'uploading') {
3498
+ throw new Error(`Upload cannot continue from status ${status.status}`)
3499
+ }
3500
+
3501
+ onPhaseChange?.('uploading')
3502
+
3503
+ const uploadedPartNumbers = new Set(status.uploadedParts.map((part) => part.partNumber))
3504
+ const loadedByPart = new Map<number, number>(
3505
+ status.uploadedParts.map((part) => [part.partNumber, part.size]),
3506
+ )
3507
+
3508
+ const reportProgress = () => {
3509
+ const loaded = Array.from(loadedByPart.values()).reduce((sum, value) => sum + value, 0)
3510
+
3511
+ onProgress?.({
3512
+ loaded,
3513
+ total: file.size,
3514
+ percentage: file.size === 0 ? 0 : (loaded / file.size) * 100,
3515
+ })
3516
+ }
3517
+
3518
+ reportProgress()
3519
+
3520
+ let nextPartNumber = 1
3521
+
3522
+ const worker = async () => {
3523
+ for (;;) {
3524
+ while (nextPartNumber <= status.totalParts && uploadedPartNumbers.has(nextPartNumber)) {
3525
+ nextPartNumber += 1
3526
+ }
3527
+
3528
+ if (nextPartNumber > status.totalParts) {
3529
+ return
3530
+ }
3531
+
3532
+ const partNumber = nextPartNumber
3533
+ nextPartNumber += 1
3534
+
3535
+ const start = (partNumber - 1) * status.partSize
3536
+ const end = Math.min(start + status.partSize, file.size)
3537
+ const chunk = file.slice(start, end, 'application/octet-stream')
3538
+
3539
+ loadedByPart.set(partNumber, 0)
3540
+ reportProgress()
3541
+
3542
+ await uploadChunkWithRetry({
3543
+ url: getUploadPartUrl(status.uploadSessionId, partNumber),
3544
+ chunk,
3545
+ accessToken,
3546
+ signal: controller.signal,
3547
+ onProgress: (loaded) => {
3548
+ loadedByPart.set(partNumber, Math.min(loaded, chunk.size))
3549
+ reportProgress()
3550
+ },
3551
+ })
3552
+
3553
+ uploadedPartNumbers.add(partNumber)
3554
+ loadedByPart.set(partNumber, chunk.size)
3555
+ reportProgress()
3556
+ }
3557
+ }
3558
+
3559
+ const workerCount = Math.min(concurrency, status.totalParts)
3560
+
3561
+ try {
3562
+ await Promise.all(Array.from({ length: workerCount }, () => worker()))
3563
+ } catch (error: unknown) {
3564
+ controller.abort()
3565
+ throw error
3566
+ }
3567
+
3568
+ throwIfAborted(controller.signal)
3569
+ onPhaseChange?.('completing')
3570
+
3571
+ // Complete 一旦发出就是服务端合并操作,不再把“暂停”解释为撤销合并。
3572
+ const completed = await completeWithRecovery(status.uploadSessionId, accessToken)
3573
+ onPhaseChange?.('completed')
3574
+ return completed
3575
+ } finally {
3576
+ options.signal?.removeEventListener('abort', abortWorkers)
3577
+ }
3578
+ }
3579
+ ```
3580
+
3581
+ ### 文件位置:增加一个可运行的联调页
3582
+
3583
+ 新建:
3584
+
3585
+ ```text
3586
+ apps/web/src/pages/upload-demo/index.tsx
3587
+ ```
3588
+
3589
+ 下面页面故意把 Token 做成输入框,方便你先验证上传协议。接入正式业务时,应从项目登录状态读取 Access Token,不要让用户手工填写。
3590
+
3591
+ ### 完整代码
3592
+
3593
+ ```tsx
3594
+ import { useRef, useState } from 'react'
3595
+ import { abortMultipartUpload, initiateMultipartUpload } from '@/services/multipart-upload'
3596
+ import {
3597
+ uploadMultipartFile,
3598
+ type UploadPhase,
3599
+ type UploadSessionSnapshot,
3600
+ } from '@/utils/file-upload/multipart-uploader'
3601
+
3602
+ const RESUME_STORAGE_KEY = 'multipart-upload-demo'
3603
+
3604
+ type ResumeRecord = UploadSessionSnapshot
3605
+
3606
+ function readResumeRecord(): ResumeRecord | null {
3607
+ try {
3608
+ const value = localStorage.getItem(RESUME_STORAGE_KEY)
3609
+ return value ? (JSON.parse(value) as ResumeRecord) : null
3610
+ } catch {
3611
+ return null
3612
+ }
3613
+ }
3614
+
3615
+ function matchesFile(record: ResumeRecord, file: File): boolean {
3616
+ return (
3617
+ record.fileName === file.name &&
3618
+ record.fileSize === file.size &&
3619
+ record.lastModified === file.lastModified &&
3620
+ record.contentType === (file.type || 'application/octet-stream')
3621
+ )
3622
+ }
3623
+
3624
+ function getErrorMessage(cause: unknown): string {
3625
+ return cause instanceof Error ? cause.message : String(cause)
3626
+ }
3627
+
3628
+ const UploadDemo = () => {
3629
+ const initialSession = readResumeRecord()
3630
+ const sessionRef = useRef<ResumeRecord | null>(initialSession)
3631
+ const controllerRef = useRef<AbortController | null>(null)
3632
+ const activeTaskRef = useRef<Promise<unknown> | null>(null)
3633
+ const cancellingRef = useRef(false)
3634
+ const initialPhase = initialSession ? 'paused' : 'idle'
3635
+ const phaseRef = useRef<UploadPhase | 'paused' | 'idle'>(initialPhase)
3636
+
3637
+ const [file, setFile] = useState<File | null>(null)
3638
+ const [accessToken, setAccessToken] = useState('')
3639
+ const [session, setSession] = useState<ResumeRecord | null>(initialSession)
3640
+ const [phase, setPhase] = useState<UploadPhase | 'paused' | 'idle'>(initialPhase)
3641
+ const [percentage, setPercentage] = useState(0)
3642
+ const [message, setMessage] = useState(
3643
+ initialSession
3644
+ ? '检测到未完成记录,请重新选择原文件并填写 Access Token'
3645
+ : '请选择文件并填写 Access Token',
3646
+ )
3647
+ const [storageWarning, setStorageWarning] = useState('')
3648
+ const [running, setRunning] = useState(false)
3649
+ const [cancelling, setCancelling] = useState(false)
3650
+
3651
+ const changePhase = (next: UploadPhase | 'paused' | 'idle') => {
3652
+ phaseRef.current = next
3653
+ setPhase(next)
3654
+ }
3655
+
3656
+ const saveSession = (next: ResumeRecord) => {
3657
+ sessionRef.current = next
3658
+ setSession(next)
3659
+
3660
+ try {
3661
+ localStorage.setItem(RESUME_STORAGE_KEY, JSON.stringify(next))
3662
+ setStorageWarning('')
3663
+ } catch {
3664
+ setStorageWarning('浏览器无法保存恢复记录;当前上传仍会继续,但刷新页面后无法自动恢复。')
3665
+ }
3666
+ }
3667
+
3668
+ const clearSession = () => {
3669
+ sessionRef.current = null
3670
+ setSession(null)
3671
+
3672
+ try {
3673
+ localStorage.removeItem(RESUME_STORAGE_KEY)
3674
+ setStorageWarning('')
3675
+ } catch {
3676
+ setStorageWarning('浏览器无法清理恢复记录,请手工清除此站点的数据。')
3677
+ }
3678
+ }
3679
+
3680
+ const startOrResume = async () => {
3681
+ if (activeTaskRef.current || cancellingRef.current) {
3682
+ return
3683
+ }
3684
+
3685
+ if (!file) {
3686
+ setMessage('请先选择文件')
3687
+ return
3688
+ }
3689
+
3690
+ if (!accessToken.trim()) {
3691
+ setMessage('请先填写 Access Token')
3692
+ return
3693
+ }
3694
+
3695
+ const saved = sessionRef.current
3696
+ if (saved && !matchesFile(saved, file)) {
3697
+ setMessage('存在未完成会话:请选择原文件恢复,或先点“取消并释放”')
3698
+ return
3699
+ }
3700
+
3701
+ const resumeRecord: ResumeRecord = saved ?? {
3702
+ clientUploadId: crypto.randomUUID(),
3703
+ fileName: file.name,
3704
+ fileSize: file.size,
3705
+ contentType: file.type || 'application/octet-stream',
3706
+ lastModified: file.lastModified,
3707
+ }
3708
+ saveSession(resumeRecord)
3709
+
3710
+ const controller = new AbortController()
3711
+ controllerRef.current = controller
3712
+ setRunning(true)
3713
+ setMessage(saved ? '正在恢复上传' : '正在初始化上传')
3714
+
3715
+ const task = uploadMultipartFile({
3716
+ file,
3717
+ clientUploadId: resumeRecord.clientUploadId,
3718
+ accessToken: accessToken.trim(),
3719
+ concurrency: 3,
3720
+ signal: controller.signal,
3721
+ onSession: saveSession,
3722
+ onPhaseChange: (nextPhase) => {
3723
+ changePhase(nextPhase)
3724
+ setMessage(
3725
+ nextPhase === 'completing'
3726
+ ? '分片已发送,MinIO 正在合并;此阶段不要再点暂停'
3727
+ : `当前阶段:${nextPhase}`,
3728
+ )
3729
+ },
3730
+ onProgress: (progress) => setPercentage(progress.percentage),
3731
+ })
3732
+
3733
+ activeTaskRef.current = task
3734
+
3735
+ try {
3736
+ const result = await task
3737
+ clearSession()
3738
+ setMessage(`上传完成:${result.objectKey}`)
3739
+ } catch (cause: unknown) {
3740
+ if (cause instanceof DOMException && cause.name === 'AbortError') {
3741
+ changePhase('paused')
3742
+ setMessage('已暂停。恢复时必须重新创建 AbortController。')
3743
+ } else {
3744
+ setMessage(`上传失败:${getErrorMessage(cause)}`)
3745
+ }
3746
+ } finally {
3747
+ if (activeTaskRef.current === task) {
3748
+ activeTaskRef.current = null
3749
+ }
3750
+ if (controllerRef.current === controller) {
3751
+ controllerRef.current = null
3752
+ }
3753
+ setRunning(false)
3754
+ }
3755
+ }
3756
+
3757
+ const pause = () => {
3758
+ if (phaseRef.current === 'completing') {
3759
+ return
3760
+ }
3761
+
3762
+ controllerRef.current?.abort()
3763
+ }
3764
+
3765
+ const cancelAndRelease = async () => {
3766
+ if (cancellingRef.current) {
3767
+ return
3768
+ }
3769
+
3770
+ if (!accessToken.trim()) {
3771
+ setMessage('取消会话也需要 Access Token')
3772
+ return
3773
+ }
3774
+
3775
+ if (phaseRef.current === 'completing') {
3776
+ setMessage('MinIO 已进入合并阶段,不能再取消;请等待状态恢复。')
3777
+ return
3778
+ }
3779
+
3780
+ cancellingRef.current = true
3781
+ setCancelling(true)
3782
+ setMessage('正在停止分片并释放 MinIO 会话')
3783
+
3784
+ try {
3785
+ controllerRef.current?.abort()
3786
+
3787
+ try {
3788
+ await activeTaskRef.current
3789
+ } catch {
3790
+ // 先等所有正在进行的 XHR 和后端 PUT 结束,再调用 DELETE。
3791
+ }
3792
+
3793
+ let current = sessionRef.current
3794
+ if (!current) {
3795
+ setMessage('上传已经结束,没有可取消的会话')
3796
+ return
3797
+ }
3798
+
3799
+ let uploadSessionId = current.uploadSessionId
3800
+ if (!uploadSessionId) {
3801
+ const recovered = await initiateMultipartUpload(
3802
+ {
3803
+ clientUploadId: current.clientUploadId,
3804
+ fileName: current.fileName,
3805
+ fileSize: current.fileSize,
3806
+ contentType: current.contentType,
3807
+ },
3808
+ accessToken.trim(),
3809
+ )
3810
+ uploadSessionId = recovered.uploadSessionId
3811
+ current = {
3812
+ ...current,
3813
+ uploadSessionId,
3814
+ }
3815
+ saveSession(current)
3816
+ }
3817
+
3818
+ await abortMultipartUpload(uploadSessionId, accessToken.trim())
3819
+ clearSession()
3820
+ changePhase('idle')
3821
+ setPercentage(0)
3822
+ setMessage('已取消,并请求 MinIO 释放未完成分片')
3823
+ } catch (cause: unknown) {
3824
+ setMessage(`取消失败:${getErrorMessage(cause)}。恢复记录已保留。`)
3825
+ } finally {
3826
+ cancellingRef.current = false
3827
+ setCancelling(false)
3828
+ }
3829
+ }
3830
+
3831
+ return (
3832
+ <main className="mx-auto flex min-h-screen max-w-2xl flex-col gap-4 p-8">
3833
+ <h1 className="text-2xl font-semibold">10 MiB 分片上传联调</h1>
3834
+
3835
+ <label className="flex flex-col gap-2">
3836
+ <span>Access Token</span>
3837
+ <textarea
3838
+ className="min-h-24 rounded border p-2"
3839
+ disabled={running || cancelling}
3840
+ value={accessToken}
3841
+ onChange={(event) => setAccessToken(event.target.value)}
3842
+ />
3843
+ </label>
3844
+
3845
+ <input
3846
+ disabled={running || cancelling}
3847
+ type="file"
3848
+ onChange={(event) => setFile(event.target.files?.[0] ?? null)}
3849
+ />
3850
+
3851
+ <progress className="w-full" max={100} value={percentage} />
3852
+ <div>{percentage.toFixed(2)}%</div>
3853
+ <div>{message}</div>
3854
+ {storageWarning ? <div className="text-amber-700">{storageWarning}</div> : null}
3855
+ <div>会话:{session?.uploadSessionId ?? '等待初始化'}</div>
3856
+
3857
+ <div className="flex gap-3">
3858
+ <button
3859
+ className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
3860
+ disabled={running || cancelling}
3861
+ type="button"
3862
+ onClick={() => void startOrResume()}
3863
+ >
3864
+ {phase === 'paused' ? '恢复' : '开始上传'}
3865
+ </button>
3866
+ <button
3867
+ className="rounded border px-4 py-2 disabled:opacity-50"
3868
+ disabled={!running || cancelling || phase === 'completing'}
3869
+ type="button"
3870
+ onClick={pause}
3871
+ >
3872
+ 暂停
3873
+ </button>
3874
+ <button
3875
+ className="rounded border border-red-500 px-4 py-2 text-red-600 disabled:opacity-50"
3876
+ disabled={cancelling || phase === 'completing'}
3877
+ type="button"
3878
+ onClick={() => void cancelAndRelease()}
3879
+ >
3880
+ {cancelling ? '正在取消' : '取消并释放'}
3881
+ </button>
3882
+ </div>
3883
+ </main>
3884
+ )
3885
+ }
3886
+
3887
+ export default UploadDemo
3888
+ ```
3889
+
3890
+ ### 文件位置:注册联调路由
3891
+
3892
+ 修改:
3893
+
3894
+ ```text
3895
+ apps/web/src/router/index.tsx
3896
+ ```
3897
+
3898
+ 在 `routes` 数组中增加:
3899
+
3900
+ ```tsx
3901
+ {
3902
+ path: '/upload-demo',
3903
+ id: 'upload-demo',
3904
+ element: lazyLoad('upload-demo'),
3905
+ },
3906
+ ```
3907
+
3908
+ 启动前端后访问:
3909
+
3910
+ ```text
3911
+ http://127.0.0.1:9999/upload-demo
3912
+ ```
3913
+
3914
+ 暂停会中断当前分片 XHR,也会通过 Alova Method 的 `abort()` 中断正在进行的初始化或状态 GET,但不会 DELETE MinIO Multipart。恢复时必须重新选择同一个文件,并使用 LocalStorage 保存的同一个 `clientUploadId`。已经 `abort()` 的 `AbortController` 永远不能恢复,所以每次点击“恢复”都必须创建新实例。
3915
+
3916
+ 示例会在初始化请求发出**之前**先保存 `clientUploadId`、文件名、文件大小、MIME 和 `lastModified`,服务端响应后再补上 `uploadSessionId`,但不会把整个文件塞进 LocalStorage。这样即使“服务端已创建会话、初始化响应却丢失”,下次仍会复用同一个 `clientUploadId` 找回会话。LocalStorage 写入失败不会中断本次上传,只是无法跨刷新恢复。
3917
+
3918
+ 真正取消时先停止并等待当前 PUT 结束,再调用 DELETE;若初始化响应丢失且本地还没有 `uploadSessionId`,取消流程会先用同一个 `clientUploadId` 幂等初始化以找回会话,然后 Abort。DELETE 失败会保留恢复记录并显示错误,不会产生未处理的 Promise rejection。进入 `completing` 后,暂停和取消按钮都会禁用。
3919
+
3920
+ ### 为什么分片用 XHR,而控制接口用 Alova
3921
+
3922
+ - Alova 继续统一处理 JSON、认证失败和项目请求约定。
3923
+ - XHR 对上传进度、HTTP 状态、取消的行为稳定且直观。
3924
+ - 不会绕过项目的后端协议,只是为二进制分片选择更合适的浏览器传输 API。
3925
+
3926
+ XHR 的 `timeout` 是 10 分钟,超时与普通网络错误一样允许重试。`MAX_ATTEMPTS = 3` 表示总尝试 3 次,并不是“首次请求之外再重试 3 次”。每次尝试前都把该片进度归零,避免失败重试时总进度虚高。
3927
+
3928
+ 进度到 100% 只表示浏览器字节已经发送完,不表示最终对象已经可用。页面必须继续显示 `completing`,等 Complete 成功后才进入 `completed`。Complete 一旦发到后端,暂停按钮不再撤销这次合并。如果 Complete 成功但 HTTP 响应丢失,前端会查询状态:`completing` 就轮询,`completed` 就再次调用幂等 Complete 取回完整响应;若状态仍是 `uploading`,保留原错误,等用户恢复后重试。
3929
+
3930
+ 开发环境的 `/api` 由 Vite 代理并去掉前缀;`VITE_API_URL` 只是 Vite 开发代理目标,不应该拿它在浏览器里直接拼 XHR URL。生产优先保持同源:继续让 `apps/web/.env` 使用 `VITE_API_AFFIX=/api`,由站点 Nginx 把 `/api` 反代到 NestJS。
3931
+
3932
+ 只有确认前端和 API 必须跨域时,才在实际生产构建读取的环境文件中设置:
3933
+
3934
+ ```dotenv
3935
+ # apps/web/.env.prod
3936
+ VITE_API_AFFIX=https://api.example.com/api
3937
+ ```
3938
+
3939
+ 当前 `apps/web/package.json` 的 build 脚本是 `vp build --mode dev`,它不会读取 `.env.prod`。如果部署约定使用 `.env.prod`,应把脚本改为 `vp build --mode prod`;也可以由 CI 在构建时显式注入 `VITE_API_AFFIX`。跨域时再配置 NestJS/API 网关 CORS:请求头至少允许 `Authorization`、`Content-Type`,方法至少允许 `GET`、`POST`、`PUT`、`DELETE`、`OPTIONS`;仍然不需要 MinIO CORS。
3940
+
3941
+ 如果后续确认当前 Alova Axios Adapter 的上传进度类型完全满足项目需要,可以再把 XHR 换成 Alova Method 的上传进度能力,后端 API 无需修改。
3942
+
3943
+ ### 验证
3944
+
3945
+ 在浏览器开发者工具中检查一次 25 MiB 上传,预期看到:
3946
+
3947
+ - 一个初始化 POST。
3948
+ - 一个状态 GET。
3949
+ - 三个分片 PUT,每个请求体分别是 10 MiB、10 MiB、5 MiB。
3950
+ - 最后一个 Complete POST。
3951
+ - 任意时刻最多只有 3 个分片 PUT 处于 pending。
3952
+ - 分片 PUT 的 URL 指向 `/api/uploads/multipart/...`,Network 面板中没有任何请求直接访问 9000 或 9001。
3953
+
3954
+ ### 常见错误
3955
+
3956
+ - 使用 `file.arrayBuffer()` 读取整个大文件,再自行切数组。正确做法是直接 `file.slice()`。
3957
+ - 对上万个分片直接 `Promise.all(parts.map(...))`,瞬间创建大量请求。本文始终只有 3 个 worker。
3958
+ - 401、403、409、422 也自动重试。这里只重试网络错误、408、429 和 5xx。
3959
+ - 暂停时调用 DELETE,导致恢复时 MinIO UploadId 已被删除。
3960
+ - 每次恢复都生成新的 `clientUploadId`,结果不能命中原会话。
3961
+ - 手动设置 `Content-Length`。浏览器不允许,XHR 发送 Blob 时会自动设置。
3962
+
3963
+ ---
3964
+
3965
+ ## 第 16 步:端到端验收
3966
+
3967
+ ### 本步目标
3968
+
3969
+ 确认上传正确、断点可恢复、越权被拒绝、内存不会随整个文件大小线性增长。
3970
+
3971
+ ### 文件位置
3972
+
3973
+ 本步不再新增业务代码,使用前面完成的这些位置做验收:
3974
+
3975
+ ```text
3976
+ apps/web/src/pages/upload-demo/index.tsx
3977
+ apps/web/src/router/index.tsx
3978
+ apps/server/src/modules/upload/*
3979
+ docker-compose.yml
3980
+ ```
3981
+
3982
+ ### 启动位置
3983
+
3984
+ 全部命令都从模板根目录运行。数据库必须预先使用经审阅的合规基线准备完成;当前不得在启动前执行仓库完整迁移链:
3985
+
3986
+ ```powershell
3987
+ docker compose up -d postgres redis minio minio-init
3988
+ pnpm --filter server dev
3989
+ ```
3990
+
3991
+ 另开终端:
3992
+
3993
+ ```powershell
3994
+ pnpm --filter web dev
3995
+ ```
3996
+
3997
+ 先访问 `http://127.0.0.1:13000/api-docs`,使用 `/auth/register` 和 `/auth/login` 准备测试账号,并复制登录响应里的 `accessToken`。再打开 `http://127.0.0.1:9999/upload-demo`,把 Token 粘进联调页。
3998
+
3999
+ ### 创建测试文件
4000
+
4001
+ ```powershell
4002
+ node -e "require('node:fs').writeFileSync('upload-test-25m.bin', Buffer.alloc(25 * 1024 * 1024))"
4003
+ ```
4004
+
4005
+ 该文件应被切为:
4006
+
4007
+ ```text
4008
+ Part 1 = 10,485,760 bytes
4009
+ Part 2 = 10,485,760 bytes
4010
+ Part 3 = 5,242,880 bytes
4011
+ ```
4012
+
4013
+ ### 验证
4014
+
4015
+ 按下面的必测清单逐项验收,不能只验证“正常上传成功”:
4016
+
4017
+ 1. 正常上传:最终 MinIO Bucket 中只有一个 25 MiB 对象。
4018
+ 2. 断点续传:上传第一片后暂停;状态只返回第一片;恢复后跳过第一片。
4019
+ 3. 分片覆盖:同一 PartNumber 上传两次,ListParts 中仍只有一个编号。
4020
+ 4. 缺片完成:只传第 1、3 片就 Complete,返回 409。
4021
+ 5. 错误大小:前两片传 10 MiB - 1,返回 422。
4022
+ 6. 越权:另一个账号使用同一会话 ID,返回统一 404。
4023
+ 7. 取消幂等:DELETE 两次都得到 aborted。
4024
+ 8. 完成幂等:Complete 响应成功后再调用一次,仍返回同一完成对象。
4025
+ 9. 浏览器取消:XHR 中断后,NestJS 到 MinIO 的当前请求也停止。
4026
+ 10. 内存观察:上传 1 GiB 文件时,Server RSS 不应随着 1 GiB 文件总大小线性增长;它只应保留网络和 Transform 的有限缓冲。
4027
+
4028
+ 完成手工验收后,停止开发服务器并运行模板级检查:
4029
+
4030
+ ```powershell
4031
+ vp run ready
4032
+ ```
4033
+
4034
+ 根目录的 `ready` 会执行 `vp check`、各工作区测试和各工作区构建。只有这一整套通过,才能说明教程代码与模板其余部分没有明显集成冲突。
4035
+
4036
+ ### 查看 MinIO 分片
4037
+
4038
+ 未完成上传通常不会在普通对象列表中显示为最终对象。可使用 MinIO Console 或 `mc` 查看正在进行的 Multipart。完成后散片被合并为一个对象;取消后散片被清理。
4039
+
4040
+ ### 删除本地测试文件
4041
+
4042
+ 验证结束后可删除:
4043
+
4044
+ ```powershell
4045
+ Remove-Item -LiteralPath '.\upload-test-25m.bin'
4046
+ ```
4047
+
4048
+ 这是可重新生成的测试文件,不影响 MinIO 中已经上传的对象。
4049
+
4050
+ ### 常见错误
4051
+
4052
+ - 没有先登录取得 `accessToken`,把 401 当成上传协议错误。
4053
+ - 修改 `.env.dev` 后没有重启前端,浏览器仍然代理到旧的 10000 端口。
4054
+ - 暂停后复用已经 aborted 的 `AbortController`,恢复请求立即失败。
4055
+ - 换了另一个同名文件却只比较文件名;至少同时比较大小、MIME 和 `lastModified`。
4056
+ - 只看进度 100% 就关闭页面,没有等待 `completing` 进入 `completed`。
4057
+ - 只测正常上传,没有测越权、缺片、重试、暂停和重复 Complete/DELETE。
4058
+
4059
+ ---
4060
+
4061
+ ## 第 17 步:生产反向代理配置
4062
+
4063
+ ### 本步目标
4064
+
4065
+ 只代理浏览器到 NestJS 的上传路由,并关闭请求缓冲。MinIO 不对浏览器开放,所以不需要签名 Host 透传和 MinIO CORS。
4066
+
4067
+ ### 文件位置
4068
+
4069
+ 实际位置取决于部署仓库,例如:
4070
+
4071
+ ```text
4072
+ deploy/nginx/conf.d/api.conf
4073
+ ```
4074
+
4075
+ 当前模板没有这个文件,下面是部署时应加入的 Nginx 片段:
4076
+
4077
+ ```nginx
4078
+ upstream nest_api {
4079
+ server server:13000;
4080
+ }
4081
+
4082
+ server {
4083
+ listen 443 ssl;
4084
+ server_name api.example.com;
4085
+
4086
+ # 当前模板浏览器请求带 /api 前缀;proxy_pass 的 URI 部分会去掉 /api。
4087
+ location ^~ /api/uploads/multipart {
4088
+ client_max_body_size 12m;
4089
+ client_body_timeout 600s;
4090
+
4091
+ proxy_http_version 1.1;
4092
+ proxy_request_buffering off;
4093
+ proxy_read_timeout 600s;
4094
+ proxy_send_timeout 600s;
4095
+
4096
+ proxy_set_header Host $host;
4097
+ proxy_set_header X-Forwarded-Proto $scheme;
4098
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
4099
+ proxy_pass http://nest_api/uploads/multipart;
4100
+ }
4101
+ }
4102
+ ```
4103
+
4104
+ 按当前模板,浏览器请求 `/api/uploads/multipart/abc/parts/1`,NestJS 最终收到 `/uploads/multipart/abc/parts/1`。如果你将来删除 `VITE_API_AFFIX=/api`,必须同时修改这个 location 和 `proxy_pass`,不能只改前端一边。
4105
+
4106
+ ### 配置解释
4107
+
4108
+ - `client_max_body_size 12m`:给 10 MiB 原始分片留少量余量。
4109
+ - `proxy_http_version 1.1`:保证关闭请求缓冲时可以正常向上游流式发送;某些 Nginx 版本在 HTTP/1.0 上游模式下仍会先缓冲 chunked 请求。
4110
+ - `proxy_request_buffering off`:Nginx 收到数据就向 NestJS 转发,而不是先写完临时文件。
4111
+ - 超时要覆盖慢网络上传单片所需时间。
4112
+ - Host 和查询参数仍应由正常反向代理保留,但本方案没有 MinIO 预签名 URL,所以它们不参与 MinIO 签名验证。
4113
+ - 本协议要求分片请求具有明确 `Content-Length`。浏览器用 XHR 发送 Blob 时会自动产生;反向代理、WAF 和服务网格不能删除或改写它。
4114
+
4115
+ ### 必须检查的其他限制
4116
+
4117
+ - 云负载均衡请求体上限。
4118
+ - WAF 请求体上限。
4119
+ - API Gateway 或 Serverless 单请求上限。
4120
+ - Ingress Controller 的 body size 和 buffering 配置。
4121
+
4122
+ 如果平台单请求硬限制只有 4 MiB 或 6 MiB,本教程固定 10 MiB 的后端中转方案不能直接部署;要么换平台,要么重新设计全链路分片协议。不能只改前端常量。
4123
+
4124
+ ### 验证
4125
+
4126
+ 先在 Nginx 所在机器或容器中检查语法并重载:
4127
+
4128
+ ```bash
4129
+ nginx -t
4130
+ nginx -s reload
4131
+ ```
4132
+
4133
+ 再用浏览器联调页上传 25 MiB 文件,在 Network 中确认请求地址仍是 `/api/uploads/multipart/...`,而 NestJS 日志里的路由是 `/uploads/multipart/...`。分片请求应带 `Content-Length: 10485760`,且没有任何浏览器请求直接访问 MinIO 9000/9001。
4134
+
4135
+ 如需单独确认 body 上限,可向任意不存在的会话发送 13 MiB 测试体:它应在 Nginx 层返回 413;10 MiB 请求则应进入 NestJS,最终得到认证或会话类错误,而不是 Nginx 413。
4136
+
4137
+ ### 常见错误
4138
+
4139
+ - 只把 Nginx 限制从 1 MiB 调大,但没有关闭 request buffering。
4140
+ - 忘记 `proxy_http_version 1.1`,导致 chunked 请求仍被缓冲。
4141
+ - 把 `/api/uploads/...` 原样转给 NestJS,导致 Controller 路由多出一层 `/api` 而 404。
4142
+ - 某一层代理删除 `Content-Length`,后端返回 `UPLOAD.PART_LENGTH_REQUIRED`。
4143
+ - 把 MinIO 9000/9001 一起代理给公网,扩大攻击面。
4144
+ - 复制浏览器直传教程的 CORS、预签名 Host 配置到后端中转方案。
4145
+
4146
+ ---
4147
+
4148
+ ## 第 18 步:过期清理和生产安全
4149
+
4150
+ ### 本步目标
4151
+
4152
+ 前 17 步给出的是“可以跑通并理解完整链路”的教学基线。本步列出真正对公网或多实例上线前必须补齐的工程化能力。没有完成本步时,不要把前面的最小实现描述成已经具备生产级并发安全。
4153
+
4154
+ ### 文件位置
4155
+
4156
+ 需要新增或继续修改:
4157
+
4158
+ ```text
4159
+ apps/server/src/database/schema.ts
4160
+ apps/server/src/main.ts
4161
+ apps/server/src/common/adapters/fastify.adapter.ts
4162
+ apps/server/src/config/storage.config.ts
4163
+ apps/server/src/modules/upload/upload.errors.ts
4164
+ apps/server/src/modules/upload/upload.repository.ts
4165
+ apps/server/src/modules/upload/upload.service.ts
4166
+ apps/server/src/modules/upload/upload-cleanup.service.ts
4167
+ apps/server/src/modules/upload/upload.module.ts
4168
+ apps/server/test/error-catalog.spec.ts
4169
+ apps/server/drizzle/*_upload_part_leases.sql
4170
+ deploy/minio/lifecycle.json(或等价基础设施配置)
4171
+ deploy/minio/upload-service-policy.json
4172
+ ```
4173
+
4174
+ `deploy/*` 当前不在模板中,实际可放进你的部署仓库;这里给的是职责位置,不要求机械创建同名目录。
4175
+
4176
+ ### 1. 先解决“在途分片”和完成/取消竞态
4177
+
4178
+ 仅靠 `upload_sessions.status` 不够。一个 PUT 已经读到 `uploading` 并开始向 MinIO 发送后,另一个请求仍可能把会话改成 `completing` 或 `aborting`。因此可能出现:
4179
+
4180
+ ```text
4181
+ PUT Part 2 已在途中
4182
+ ├─ Complete 先 ListParts,暂时看不到 Part 2
4183
+ └─ Abort 先成功,但 Part 2 随后才结束
4184
+ ```
4185
+
4186
+ 生产实现应在 `apps/server/src/database/schema.ts` 增加 `upload_part_leases` 表,每一个正在处理的分片请求占一条有 TTL 的租约。生成出来的迁移应至少等价于:
4187
+
4188
+ ```sql
4189
+ create table upload_part_leases (
4190
+ id uuid primary key,
4191
+ upload_session_id uuid not null
4192
+ references upload_sessions(id) on delete cascade,
4193
+ owner_id uuid not null
4194
+ references users(id) on delete restrict,
4195
+ part_number integer not null
4196
+ check (part_number between 1 and 10000),
4197
+ expires_at timestamptz not null,
4198
+ created_at timestamptz not null default now(),
4199
+ check (expires_at > created_at),
4200
+ unique (upload_session_id, part_number)
4201
+ );
4202
+
4203
+ create index upload_part_leases_session_expires_idx
4204
+ on upload_part_leases (upload_session_id, expires_at);
4205
+
4206
+ create index upload_part_leases_expires_idx
4207
+ on upload_part_leases (expires_at);
4208
+ ```
4209
+
4210
+ 第一个索引服务 Complete/Abort 按会话检查活动租约,第二个索引服务后台任务全局清除过期租约。两个外键防止租约指向不存在的会话或用户;删除上传会话时只级联删除这些短期租约,不会反向删除用户或 MinIO 对象。`expires_at > created_at` 防止创建一开始就无效的租约。`unique (upload_session_id, part_number)` 阻止同一片被两个请求同时占用;如果确实要允许同 Part 并行竞速,就必须先定义胜者和取消策略,不能直接删除约束。
4211
+
4212
+ Repository 和 Service 必须遵守下面顺序:
4213
+
4214
+ 1. `beginPartLease` 在短事务中取得“按 uploadSessionId 计算的 PostgreSQL advisory transaction lock”。
4215
+ 2. 在同一事务里重新检查会话仍为 `uploading` 且未过期,然后插入租约;事务马上提交,不能拿着数据库事务传 10 MiB 流。
4216
+ 3. 分片上传期间每 30 秒续租,例如把 `expiresAt` 延长到当前时间后 2 分钟;续租 UPDATE 必须按 `leaseId + uploadSessionId` 条件更新,并检查受影响行数。租约已过期、被清理或 UPDATE 返回 0 行都算续租失败。请求结束时在 `finally` 删除租约。**任何一次续租失败都必须立刻触发该 PUT 的 `AbortController.abort()`**,不能让失去有效租约的请求继续写 MinIO。
4217
+ 4. `claimCompleting` 和 `claimAborting` 也先取得同一把 advisory lock,在同一短事务内清除过期租约并检查有效租约。若仍有租约,抛出 `UPLOAD_ERRORS.PARTS_STILL_ACTIVE`(409),并保持原来的 `uploading` 状态;第 9 步已经把该错误加入 `apps/server/src/modules/upload/upload.errors.ts` 和统一错误目录测试。不能先改成 `completing/aborting` 再把调用方卡住。
4218
+ 5. 没有有效租约时,事务内再把状态改成 `completing/aborting`。状态一旦“封口”,新的 `beginPartLease` 就不能插入。
4219
+ 6. 前端先停止并等待所有 PUT 结束,再调用 Complete 或 DELETE。联调页的“取消并释放”已经按这个顺序演示。
4220
+ 7. 状态封口且租约归零后才执行 MinIO Complete/Abort。此时没有在途 Part,一次成功的 Abort 才能可靠地释放该 UploadId;控制命令的临时失败由清理任务重试。
4221
+ 8. 如果 Complete 已发出但结果不确定,会话会保持 `completing`。后续重试或清理任务必须在同一 advisory lock 下先 HeadObject;对象存在则补写 `completed`,对象不存在、没有有效租约且 UploadId 仍存在时,才重新 ListParts/Complete。不能永远只返回 `INVALID_STATE`,也不能直接改回 `uploading`。
4222
+
4223
+ advisory lock 只保护“登记租约/封口”这几个很短的事务;不要在整个 10 MiB 上传期间持有数据库锁或长事务,否则并发稍高就会耗尽连接池。
4224
+
4225
+ ### 2. 实现应用层过期清理
4226
+
4227
+ `upload-cleanup.service.ts` 应由独立定时任务触发,批量领取:
4228
+
4229
+ ```text
4230
+ status in (uploading, aborting, expired, completing)
4231
+ expiresAt <= now,或 updatedAt 长时间没有变化
4232
+ ```
4233
+
4234
+ 不要把 `FOR UPDATE SKIP LOCKED` 的数据库事务一直保持到 MinIO 请求结束。应在 `upload_sessions` 增加等价的任务租约字段:
4235
+
4236
+ ```text
4237
+ cleanupClaimId uuid nullable
4238
+ cleanupLeaseUntil timestamptz nullable
4239
+ nextCleanupAt timestamptz nullable
4240
+ lastCleanupError text nullable
4241
+ ```
4242
+
4243
+ 并为待清理状态的 `status + nextCleanupAt` 建索引。一次领取的正确边界是:
4244
+
4245
+ 1. 在短事务内用 `FOR UPDATE SKIP LOCKED` 选出一小批候选行。
4246
+ 2. 为每行写入新的 `cleanupClaimId` 和几分钟后的 `cleanupLeaseUntil`,然后立即提交事务。
4247
+ 3. 事务外调用 MinIO Head/List/Complete/Abort,不占用数据库连接和行锁。
4248
+ 4. 成功或失败后,只有 `cleanupClaimId` 仍等于本 Worker Token 的 UPDATE 才能修改状态;失败时记录错误、设置指数退避后的 `nextCleanupAt` 并释放 Claim。
4249
+ 5. Worker 崩溃后,只有 `cleanupLeaseUntil <= now()` 的任务才能被另一个实例重新领取。
4250
+
4251
+ 如果只在事务里 SELECT 后立刻提交、却没有持久化 Claim 租约,多实例仍会同时对同一 UploadId 调用 Complete/Abort;如果拿着行锁等待 MinIO,又会制造长事务。这两种实现都不合格。
4252
+
4253
+ 推荐处理逻辑:
4254
+
4255
+ - 使用 PostgreSQL `FOR UPDATE SKIP LOCKED` 分批领取,避免多个实例处理同一会话。
4256
+ - 使用第 5 步已增加的 `status + expiresAt`、`status + updatedAt` 索引做全局扫描;以 `ownerId` 开头的索引不能有效支撑这一查询。
4257
+ - 先删除已经过期的活动租约。
4258
+ - `uploading/expired/aborting`:确认没有有效租约后调用 Abort,成功后标记 `aborted` 或 `expired`。
4259
+ - `completing`:先 `HeadObject`。对象已存在则补写 `completed`;对象不存在、没有有效租约且 UploadId 仍存在时,在同一会话锁下重试 ListParts/Complete 或按业务策略 Abort;不能直接改回 `uploading`。
4260
+ - 单批限制数量并记录日志、指标和最后错误,避免一次扫描拖垮 MinIO。
4261
+ - 用户硬删除前必须先取消未完成 Multipart,并按业务保留或删除最终对象;不要把 `upload_sessions` 级联删除后再尝试寻找已经丢失的 UploadId/ObjectKey。基线表使用 `onDelete: 'restrict'` 正是为了阻止这种顺序错误。
4262
+
4263
+ 不要在 GET、PUT、Complete 等用户请求里顺手扫描全表。清理必须是独立后台任务;如果不想在 NestJS 进程内调度,也可以由 Kubernetes CronJob 或任务系统调用同一 Service。
4264
+
4265
+ ### 3. 配置 MinIO 生命周期兜底
4266
+
4267
+ 在 Bucket `uploads` 上增加等价规则:
4268
+
4269
+ ```json
4270
+ {
4271
+ "Rules": [
4272
+ {
4273
+ "ID": "abort-incomplete-multipart-after-7-days",
4274
+ "Status": "Enabled",
4275
+ "Filter": { "Prefix": "" },
4276
+ "AbortIncompleteMultipartUpload": {
4277
+ "DaysAfterInitiation": 7
4278
+ }
4279
+ }
4280
+ ]
4281
+ }
4282
+ ```
4283
+
4284
+ 上面是可作为 `deploy/minio/lifecycle.json` 使用的完整规则集合结构,而不是孤立的 Rule 片段。应用会话是 24 小时,MinIO 兜底设为 7 天,给应用补偿和排障留时间。通过 MinIO Console、`mc ilm` 或基础设施代码写入时,先读取并合并 Bucket 已有生命周期配置,不能用一个新规则覆盖其他保留策略。
4285
+
4286
+ 生命周期只是最后保险,不能替代应用主动 Abort,也不能替代在途租约。
4287
+
4288
+ ### 4. 使用最小权限服务账号
4289
+
4290
+ 生产环境不要使用 `MINIO_ROOT_USER`。部署仓库中新建:
4291
+
4292
+ ```text
4293
+ deploy/minio/upload-service-policy.json
4294
+ ```
4295
+
4296
+ 完整内容:
4297
+
4298
+ ```json
4299
+ {
4300
+ "Version": "2012-10-17",
4301
+ "Statement": [
4302
+ {
4303
+ "Sid": "BucketProbe",
4304
+ "Effect": "Allow",
4305
+ "Action": ["s3:ListBucket"],
4306
+ "Resource": ["arn:aws:s3:::uploads"]
4307
+ },
4308
+ {
4309
+ "Sid": "MultipartUploadOnly",
4310
+ "Effect": "Allow",
4311
+ "Action": [
4312
+ "s3:PutObject",
4313
+ "s3:GetObject",
4314
+ "s3:AbortMultipartUpload",
4315
+ "s3:ListMultipartUploadParts"
4316
+ ],
4317
+ "Resource": ["arn:aws:s3:::uploads/users/*"]
4318
+ }
4319
+ ]
4320
+ }
4321
+ ```
4322
+
4323
+ 权限含义:
4324
+
4325
+ - `s3:PutObject` 覆盖 Create、UploadPart 和 Complete。
4326
+ - `s3:ListMultipartUploadParts` 只列出某个已知 UploadId 的 Parts。
4327
+ - `s3:AbortMultipartUpload` 释放未完成分片。
4328
+ - `s3:GetObject` 是 HeadObject 所需权限,用来恢复 Complete 状态。
4329
+ - `s3:ListBucket` 是启动时 HeadBucket 探针所需桶级权限。
4330
+ - 没有授予 `DeleteObject`、Bucket 策略、生命周期或管理员权限。
4331
+
4332
+ 本教程的清理任务从数据库取得已知 UploadId,所以默认不需要 `s3:ListBucketMultipartUploads`。只有另做“扫描整个 Bucket 的孤儿 Multipart 对账任务”时才把它加到桶级 Statement。生命周期规则更建议由部署账号配置,不要扩大应用运行时账号权限。
4333
+
4334
+ 使用与 MinIO Server 一起经过验证并固定版本的 `mc`,由部署管理员执行:
4335
+
4336
+ ```powershell
4337
+ mc --version
4338
+ mc alias set prod-minio https://minio.internal.example.com $env:MINIO_ADMIN_USER $env:MINIO_ADMIN_PASSWORD
4339
+ mc admin policy create prod-minio upload-service deploy/minio/upload-service-policy.json
4340
+ mc admin user add prod-minio upload-app $env:UPLOAD_SERVICE_SECRET
4341
+ mc admin policy attach prod-minio upload-service --user upload-app
4342
+ mc admin user info prod-minio upload-app
4343
+ ```
4344
+
4345
+ `MINIO_ADMIN_PASSWORD` 和 `UPLOAD_SERVICE_SECRET` 应由部署平台的 Secret 注入,不要写进仓库或命令脚本。不同固定版本的 `mc` 若命令名有变化,以该版本 `mc admin policy --help` 为准,并把实际命令固化到基础设施代码中。
4346
+
4347
+ NestJS 使用 `upload-app` 的 Access Key 和 Secret,不再使用 Root。随后用这组运行时凭证做一次 HeadBucket、初始化、上传、Complete、Abort 验收,并验证它不能改 Bucket 策略、生命周期或删除最终对象。
4348
+
4349
+ Bucket 保持私有。浏览器下载文件时,由后端做权限校验后中转下载,或单独生成短时下载签名;下载方式与本教程的上传中转可以独立选择。
4350
+
4351
+ ### 5. 加密 NestJS 到 MinIO 的链路并管理 Secret
4352
+
4353
+ 浏览器使用 HTTPS 只保护“浏览器 → NestJS”。后端中转还存在“`NestJS → MinIO`”这一段,生产配置位置通常是部署 Secret/环境变量:
4354
+
4355
+ ```dotenv
4356
+ STORAGE_ENDPOINT=https://minio.internal.example.com
4357
+ STORAGE_ACCESS_KEY_ID=upload-app
4358
+ STORAGE_SECRET_ACCESS_KEY=<由 Secret 注入>
4359
+ ```
4360
+
4361
+ 生产优先使用 `https://` 并校验证书。内部 CA 应加入容器或 Node.js 信任链,例如由平台挂载 CA 文件并设置 `NODE_EXTRA_CA_CERTS`;不要用 `NODE_TLS_REJECT_UNAUTHORIZED=0` 绕过验证。只有隔离且受控的私网,或 Service Mesh 已经为这段链路提供经过验证的 mTLS 时,才可以经过安全评审继续使用 `http://`。
4362
+
4363
+ MinIO Access Key、Secret、内部 CA 和管理员凭证都放在 Secret Manager、Kubernetes Secret 或等价设施中,定期轮换。它们永远不能进入 `apps/web/.env*`,因为任何 `VITE_` 变量都会打包进浏览器代码。
4364
+
4365
+ ### 6. 限流、可信代理、CORS 和内容安全
4366
+
4367
+ 前端并发 3 只是体验设置,恶意客户端可以绕过。后端或网关还应限制单用户会话数、单用户活动分片数、单 IP 速率和全局到 MinIO 的并发。多实例计数器或信号量必须有 TTL。
4368
+
4369
+ 如果限流读取客户端 IP,`apps/server/src/common/adapters/fastify.adapter.ts` 只能信任明确的反向代理 IP/CIDR 或固定跳数,不能设置全局 `trustProxy: true`。同时让最外层代理覆盖客户端自己传入的 `X-Forwarded-For`、`X-Forwarded-Proto`,并阻止绕过网关直接访问 NestJS。
4370
+
4371
+ 后端中转只是不需要 **MinIO CORS**。如果浏览器跨域访问 NestJS,生产环境应把 `apps/server/src/main.ts` 改成明确的前端域名,例如:
4372
+
4373
+ ```ts
4374
+ app.enableCors({
4375
+ origin: ['https://web.example.com'],
4376
+ allowedHeaders: ['Authorization', 'Content-Type'],
4377
+ methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
4378
+ })
4379
+ ```
4380
+
4381
+ 这里实际配置字段是 `methods`,不是 `allowedMethods`。如果前后端通过同一站点的 `/api` 反代访问,则通常不发生浏览器跨域,但仍应保留边界检查和认证。
4382
+
4383
+ `contentType`、扩展名和客户端哈希都不可信。未知文件下载使用 `Content-Disposition: attachment` 和 `X-Content-Type-Options: nosniff`;有合规要求时先上传到隔离前缀,扫描通过后再发布。Multipart ETag 不是全文件 MD5。
4384
+
4385
+ ### 验证
4386
+
4387
+ 上线前至少增加并通过以下自动化测试:
4388
+
4389
+ 1. 一个 PUT 尚未结束时调用 Complete,Complete 不得先合并,也不能漏掉该 Part。
4390
+ 2. 一个 PUT 尚未结束时调用 DELETE,先停止 PUT;租约归零后 Abort,最终 `ListParts` 得到 `NoSuchUpload`。
4391
+ 3. 模拟进程在上传中崩溃,租约过期后清理任务可以回收会话。
4392
+ 4. 模拟 MinIO Complete 成功、数据库更新失败,清理任务通过 HeadObject 恢复为 completed。
4393
+ 5. 两个 Server 实例同时跑清理,只能有一个实例领取同一会话。
4394
+ 6. 检查 Bucket 生命周期规则确实包含 7 天未完成 Multipart 清理,并且没有覆盖已有规则。
4395
+ 7. 从允许的前端 Origin 发起 `OPTIONS` 预检,应返回允许的 Origin、Headers 和 Methods;从未允许的 Origin 发起预检,不能得到跨域授权。
4396
+ 8. 使用运行时 MinIO 账号完成上传,但修改生命周期、Bucket 策略和删除对象都应被拒绝。
4397
+ 9. 抓取 NestJS 到 MinIO 的连接,确认生产链路使用有效 TLS;内部 CA 失效时应用应启动失败,而不是静默跳过证书验证。
4398
+ 10. 伪造 `X-Forwarded-For` 直连 NestJS 不得改变限流看到的客户端身份。
4399
+
4400
+ ### 常见错误
4401
+
4402
+ - 只把状态改成 `aborting`,却不等待已经开始的 PUT,随后调用一次 Abort 就标记 `aborted`。
4403
+ - 用进程内 Map 统计活动分片,却部署多个 NestJS 实例;另一个实例的在途请求完全不可见。
4404
+ - 用数据库计数器但没有租约 TTL;进程崩溃后计数永远无法归零。
4405
+ - 为了同步而持有长事务直到 10 MiB 上传结束,最终耗尽数据库连接。
4406
+ - 把 MinIO 生命周期当主动清理,允许垃圾分片占用 7 天空间。
4407
+ - 生产继续使用 Root 账号、明文跨不可信网络访问 MinIO、`trustProxy: true` 或 `origin: ['*']`。
4408
+
4409
+ ---
4410
+
4411
+ ## 故障排查表
4412
+
4413
+ | 现象 | 最可能原因 | 优先检查位置 | 处理 |
4414
+ | ------------------------ | --------------------------------------- | ---------------------------------------- | ----------------------------------------------------- |
4415
+ | 401 | 没传 Session Token | 前端 `Authorization`、现有 Session Guard | 传 `Bearer <token>`,不要给 Controller 加 `@Public()` |
4416
+ | 413 | Nginx/Ingress/平台限制小于 10 MiB | 网关配置 | 调整到 12 MiB 左右,并检查云平台硬限制 |
4417
+ | 415 | 没注册原始流解析器或 Content-Type 错误 | `fastify.adapter.ts` | 注册 `application/octet-stream`,XHR 设置正确类型 |
4418
+ | 411 | 请求没有有效 Content-Length | 代理、非浏览器客户端 | 浏览器直接发送 Blob;检查代理是否移除该头 |
4419
+ | 422 PART_SIZE_MISMATCH | 切片下标错或数据中途断开 | 前端 `file.slice`、ExactSizeTransform | 非末片严格 10 MiB,PartNumber 从 1 开始 |
4420
+ | 409 PARTS_INCOMPLETE | 缺片、片大小错误或上传尚未结束 | `ListParts` 结果 | 等全部 worker 完成,再调用 Complete |
4421
+ | 409 PARTS_STILL_ACTIVE | 仍有 PUT 持有有效分片租约 | `upload_part_leases`、前端 worker | 先停止并等待 PUT;租约归零后重试 Complete/DELETE |
4422
+ | `NoSuchUpload` | 会话过期、已 Abort、UploadId/Key 不匹配 | 数据库会话和 MinIO | 返回 410,重新初始化;不要把 MinIO UploadId 交前端 |
4423
+ | `EntityTooSmall` | 中间片小于 S3 最小值 | 前端切片和后端校验 | 本协议中间片固定 10 MiB |
4424
+ | `InvalidPartOrder` | Complete Parts 未排序 | MinIO Adapter | 按 PartNumber 升序传给 Complete |
4425
+ | 503 | MinIO 不可达、凭证错、Bucket 不存在 | Storage env、Compose 日志 | 区分宿主机 `127.0.0.1` 和 Compose `minio` |
4426
+ | 后端 200,前端却报失败 | Alova 按包装响应解析 | `multipart-upload.ts` | 使用 `isWrapped: false` |
4427
+ | 前端一直连 10000 | 开发代理和 Server 端口不一致 | `apps/web/.env.dev` | 改成 13000 并重启前端 |
4428
+ | 内存明显随文件总大小上涨 | 使用 Buffer、临时聚合或无限并发 | Fastify parser、Controller、前端 worker | 保持 Readable 流,去掉 `toBuffer()`,并发限制为 3 |
4429
+ | 暂停后无法恢复 | 暂停时 DELETE 或 clientUploadId 变了 | 前端暂停逻辑 | 暂停只 Abort XHR,恢复复用原 clientUploadId |
4430
+ | 超过 1000 片后完成失败 | ListParts 未分页 | `minio-storage.adapter.ts` | 循环处理 marker 和 IsTruncated |
4431
+
4432
+ ---
4433
+
4434
+ ## 最终文件清单
4435
+
4436
+ 完成教程后,应新增:
4437
+
4438
+ ```text
4439
+ apps/server/src/config/storage.config.ts
4440
+ apps/server/src/modules/upload/upload.constants.ts
4441
+ apps/server/src/modules/upload/upload.errors.ts
4442
+ apps/server/src/modules/upload/upload.repository.ts
4443
+ apps/server/src/modules/upload/upload.service.ts
4444
+ apps/server/src/modules/upload/upload.controller.ts
4445
+ apps/server/src/modules/upload/upload.module.ts
4446
+ apps/server/src/modules/upload/dto/initiate-multipart-upload.dto.ts
4447
+ apps/server/src/modules/upload/dto/upload-params.dto.ts
4448
+ apps/server/src/modules/upload/storage/storage.port.ts
4449
+ apps/server/src/modules/upload/storage/minio-storage.adapter.ts
4450
+ apps/server/src/modules/upload/storage/exact-size.transform.ts
4451
+ apps/server/src/modules/upload/utils/object-key.ts
4452
+ apps/server/test/upload-constants.spec.ts
4453
+ apps/server/test/upload-stream.spec.ts
4454
+ apps/web/src/services/multipart-upload.ts
4455
+ apps/web/src/utils/file-upload/multipart-uploader.ts
4456
+ apps/web/src/pages/upload-demo/index.tsx
4457
+ ```
4458
+
4459
+ 应修改:
4460
+
4461
+ ```text
4462
+ docker-compose.yml
4463
+ pnpm-workspace.yaml
4464
+ apps/server/package.json
4465
+ apps/server/.env.development.local
4466
+ apps/server/.gitignore
4467
+ apps/server/src/config/index.ts
4468
+ apps/server/src/common/adapters/fastify.adapter.ts
4469
+ apps/server/src/database/schema.ts
4470
+ apps/server/src/app.module.ts
4471
+ apps/server/test/error-catalog.spec.ts
4472
+ apps/web/.env.dev
4473
+ apps/web/src/router/index.tsx
4474
+ ```
4475
+
4476
+ 对公网或多实例上线时,第 18 步还要求增加活动分片租约、清理任务和部署侧 MinIO 策略文件;它们属于生产加固,不要与上面的教学基线文件清单混为一谈。
4477
+
4478
+ ## 上线前最终检查
4479
+
4480
+ - [ ] 每片固定 10 MiB,只有最后一片允许更小。
4481
+ - [ ] PartNumber 从 1 开始。
4482
+ - [ ] 前端默认并发 3,最多 5。
4483
+ - [ ] 分片请求体是 Blob,不是整个文件 ArrayBuffer。
4484
+ - [ ] Fastify 直接返回 Readable,不转 Buffer、不落临时文件。
4485
+ - [ ] 同时校验 Content-Length 和真实流字节数。
4486
+ - [ ] 所有数据库查询包含 ownerId。
4487
+ - [ ] Bucket、Object Key、MinIO UploadId 都由后端控制。
4488
+ - [ ] Complete 使用分页 ListParts,并校验连续编号、大小和总字节数。
4489
+ - [ ] Complete 和 Abort 都具备状态条件与幂等行为。
4490
+ - [ ] MinIO 不需要公网地址或 MinIO CORS;如 API 跨域,已正确配置 NestJS/API CORS。
4491
+ - [ ] 9000/9001 不暴露公网,本地 Compose 只绑定 127.0.0.1。
4492
+ - [ ] Nginx 允许 10 MiB 请求,使用 HTTP/1.1,关闭 request buffering,并保留 Content-Length。
4493
+ - [ ] 生产 MinIO 使用固定版本和最小权限服务账号。
4494
+ - [ ] NestJS 到 MinIO 使用经过验证的 TLS(或经安全评审的等价 mTLS),凭证只从 Secret 注入。
4495
+ - [ ] `trustProxy` 只信任真实代理 IP/CIDR/跳数,且不能绕过网关直连 NestJS。
4496
+ - [ ] 对公网/多实例部署已实现活动分片租约,Complete/Abort 会先封口并等待租约归零。
4497
+ - [ ] 应用有过期清理,Bucket 有未完成 Multipart 生命周期兜底。
4498
+ - [ ] Multipart ETag 没有被当作整个文件 MD5。
4499
+ - [ ] 全仓迁移阻断已经解除,`vp run ready`、经审阅的数据库迁移和 25 MiB 三片验收全部通过。
4500
+
4501
+ 当前仓库已经具备“浏览器 10 MiB 分片 → NestJS 流式中转 → MinIO Multipart → 断点续传”的主要代码,但是否完整满足第 1~17 步仍应以专项测试和实际联调为准;当前尚缺上传专项测试。完成第 18 步的竞态、清理和权限加固后,才适合对公网或多实例上线。后端仍承担双向带宽,容量规划必须按“用户上传流量约两次经过 Server 网络栈”计算。
4502
+
4503
+ ## 文档变更记录
4504
+
4505
+ | 日期 | 变更摘要 | 影响范围 |
4506
+ | ---------- | -------------------------------------------------------------------------------- | ------------------------ |
4507
+ | 2026-09-18 | 标明当前仓库完整迁移链受 `0003`/`0004` 阻断,移除可被直接复制执行的全量迁移命令 | 迁移生成、启动与上线检查 |