create-bubbles 0.1.23 → 0.1.24

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 (230) hide show
  1. package/index.js +1 -1
  2. package/package.json +25 -24
  3. package/template-taro-react-oxc/.env.development +1 -1
  4. package/template-taro-react-oxc/.env.production +1 -1
  5. package/template-taro-react-oxc/project.config.json +1 -1
  6. package/template-taro-vue-eslint/project.config.json +2 -2
  7. package/{template-vp-monorepo-react-nestjs → template-vp-monorepo-react-hono}/.spaces/server//347/231/273/345/275/225/JWT/344/270/216RefreshToken/350/275/256/346/215/242/351/211/264/346/235/203/346/236/266/346/236/204/345/256/236/347/216/260/346/214/207/345/215/227.md +588 -1133
  8. package/template-vp-monorepo-react-hono/.vite-hooks/commit-msg +2 -0
  9. package/template-vp-monorepo-react-hono/.vite-hooks/pre-commit +2 -0
  10. package/template-vp-monorepo-react-hono/.vscode/extensions.json +3 -0
  11. package/template-vp-monorepo-react-hono/.vscode/settings.json +23 -0
  12. package/template-vp-monorepo-react-hono/AGENTS.md +16 -0
  13. package/template-vp-monorepo-react-hono/README.md +54 -0
  14. package/template-vp-monorepo-react-hono/apps/server/.env.example +13 -0
  15. package/template-vp-monorepo-react-hono/apps/server/README.md +75 -0
  16. package/template-vp-monorepo-react-hono/apps/server/drizzle/0000_flaky_madame_masque.sql +18 -0
  17. package/template-vp-monorepo-react-hono/apps/server/drizzle/meta/0000_snapshot.json +153 -0
  18. package/template-vp-monorepo-react-hono/apps/server/drizzle/meta/_journal.json +13 -0
  19. package/template-vp-monorepo-react-hono/apps/server/drizzle.config.ts +19 -0
  20. package/template-vp-monorepo-react-hono/apps/server/package.json +46 -0
  21. package/template-vp-monorepo-react-hono/apps/server/src/app.test.ts +57 -0
  22. package/template-vp-monorepo-react-hono/apps/server/src/app.ts +68 -0
  23. package/template-vp-monorepo-react-hono/apps/server/src/common/errors/app-error.ts +23 -0
  24. package/template-vp-monorepo-react-hono/apps/server/src/common/http/response.ts +21 -0
  25. package/template-vp-monorepo-react-hono/apps/server/src/common/logger.ts +30 -0
  26. package/template-vp-monorepo-react-hono/apps/server/src/config/env.ts +54 -0
  27. package/template-vp-monorepo-react-hono/apps/server/src/index.bun.ts +60 -0
  28. package/template-vp-monorepo-react-hono/apps/server/src/index.ts +73 -0
  29. package/template-vp-monorepo-react-hono/apps/server/src/infrastructure/database/client.ts +46 -0
  30. package/template-vp-monorepo-react-hono/apps/server/src/infrastructure/database/schema/index.ts +1 -0
  31. package/template-vp-monorepo-react-hono/apps/server/src/infrastructure/database/schema/job-runs.ts +42 -0
  32. package/template-vp-monorepo-react-hono/apps/server/src/infrastructure/queue/connection.ts +6 -0
  33. package/template-vp-monorepo-react-hono/apps/server/src/infrastructure/redis/client.ts +39 -0
  34. package/template-vp-monorepo-react-hono/apps/server/src/infrastructure/redis/options.ts +28 -0
  35. package/template-vp-monorepo-react-hono/apps/server/src/modules/health/health.routes.ts +17 -0
  36. package/template-vp-monorepo-react-hono/apps/server/src/modules/health/health.service.ts +59 -0
  37. package/template-vp-monorepo-react-hono/apps/server/src/modules/jobs/jobs.queue.ts +46 -0
  38. package/template-vp-monorepo-react-hono/apps/server/src/modules/jobs/jobs.repository.ts +101 -0
  39. package/template-vp-monorepo-react-hono/apps/server/src/modules/jobs/jobs.routes.ts +46 -0
  40. package/template-vp-monorepo-react-hono/apps/server/src/modules/jobs/jobs.schemas.ts +9 -0
  41. package/template-vp-monorepo-react-hono/apps/server/src/modules/jobs/jobs.service.ts +57 -0
  42. package/template-vp-monorepo-react-hono/apps/server/src/modules/jobs/jobs.types.ts +11 -0
  43. package/template-vp-monorepo-react-hono/apps/server/src/modules/jobs/jobs.worker.ts +108 -0
  44. package/template-vp-monorepo-react-hono/apps/server/src/runtime/shutdown.ts +38 -0
  45. package/template-vp-monorepo-react-hono/apps/server/src/worker.ts +48 -0
  46. package/template-vp-monorepo-react-hono/apps/server/tsconfig.json +15 -0
  47. package/template-vp-monorepo-react-hono/apps/server/vite.config.ts +15 -0
  48. package/template-vp-monorepo-react-hono/apps/web/.env +2 -0
  49. package/template-vp-monorepo-react-hono/apps/web/.env.dev +1 -0
  50. package/template-vp-monorepo-react-hono/apps/web/.env.prod +1 -0
  51. package/template-vp-monorepo-react-hono/apps/web/index.html +13 -0
  52. package/template-vp-monorepo-react-hono/apps/web/package.json +35 -0
  53. package/template-vp-monorepo-react-hono/apps/web/public/favicon.svg +1 -0
  54. package/template-vp-monorepo-react-hono/apps/web/public/icons.svg +24 -0
  55. package/template-vp-monorepo-react-hono/apps/web/src/App.tsx +25 -0
  56. package/template-vp-monorepo-react-hono/apps/web/src/assets/hero.png +0 -0
  57. package/template-vp-monorepo-react-hono/apps/web/src/assets/react.svg +1 -0
  58. package/template-vp-monorepo-react-hono/apps/web/src/assets/vite.svg +1 -0
  59. package/template-vp-monorepo-react-hono/apps/web/src/components/Loading/PageLoading.tsx +11 -0
  60. package/template-vp-monorepo-react-hono/apps/web/src/locales/en_US.json +4 -0
  61. package/template-vp-monorepo-react-hono/apps/web/src/locales/zh_CN.json +4 -0
  62. package/template-vp-monorepo-react-hono/apps/web/src/main.tsx +5 -0
  63. package/template-vp-monorepo-react-hono/apps/web/src/pages/home/index.tsx +47 -0
  64. package/template-vp-monorepo-react-hono/apps/web/src/router/index.tsx +25 -0
  65. package/template-vp-monorepo-react-hono/apps/web/src/router/modules/.gitkeep +0 -0
  66. package/template-vp-monorepo-react-hono/apps/web/src/styles/index.css +7 -0
  67. package/template-vp-monorepo-react-hono/apps/web/src/types/auto-imports.d.ts +58 -0
  68. package/template-vp-monorepo-react-hono/apps/web/src/utils/env/index.ts +6 -0
  69. package/template-vp-monorepo-react-hono/apps/web/src/utils/request/core/index.ts +420 -0
  70. package/template-vp-monorepo-react-hono/apps/web/src/utils/request/core/utils.ts +39 -0
  71. package/template-vp-monorepo-react-hono/apps/web/src/utils/request/index.ts +61 -0
  72. package/template-vp-monorepo-react-hono/apps/web/tsconfig.json +18 -0
  73. package/template-vp-monorepo-react-hono/apps/web/vite.config.ts +45 -0
  74. package/template-vp-monorepo-react-hono/commitlint.config.js +1 -0
  75. package/template-vp-monorepo-react-hono/docker-compose.yml +41 -0
  76. package/template-vp-monorepo-react-hono/mise.toml +4 -0
  77. package/template-vp-monorepo-react-hono/package.json +37 -0
  78. package/template-vp-monorepo-react-hono/packages/i18n/core/package.json +20 -0
  79. package/template-vp-monorepo-react-hono/packages/i18n/core/src/format.ts +9 -0
  80. package/template-vp-monorepo-react-hono/packages/i18n/core/src/i18n.ts +73 -0
  81. package/template-vp-monorepo-react-hono/packages/i18n/core/src/index.ts +24 -0
  82. package/template-vp-monorepo-react-hono/packages/i18n/core/src/storage.ts +47 -0
  83. package/template-vp-monorepo-react-hono/packages/i18n/core/src/store.ts +48 -0
  84. package/template-vp-monorepo-react-hono/packages/i18n/core/src/types.ts +36 -0
  85. package/template-vp-monorepo-react-hono/packages/i18n/core/tsconfig.json +4 -0
  86. package/template-vp-monorepo-react-hono/packages/i18n/core/vite.config.ts +13 -0
  87. package/template-vp-monorepo-react-hono/packages/i18n/react/package.json +28 -0
  88. package/template-vp-monorepo-react-hono/packages/i18n/react/src/index.tsx +26 -0
  89. package/template-vp-monorepo-react-hono/packages/i18n/react/src/use-store.ts +14 -0
  90. package/template-vp-monorepo-react-hono/packages/i18n/react/tsconfig.json +9 -0
  91. package/template-vp-monorepo-react-hono/packages/i18n/react/vite.config.ts +16 -0
  92. package/template-vp-monorepo-react-hono/packages/i18n/vue/package.json +27 -0
  93. package/template-vp-monorepo-react-hono/packages/i18n/vue/src/index.ts +52 -0
  94. package/template-vp-monorepo-react-hono/packages/i18n/vue/src/use-store.ts +19 -0
  95. package/template-vp-monorepo-react-hono/packages/i18n/vue/tsconfig.json +8 -0
  96. package/template-vp-monorepo-react-hono/packages/i18n/vue/vite.config.ts +16 -0
  97. package/template-vp-monorepo-react-hono/pnpm-workspace.yaml +57 -0
  98. package/template-vp-monorepo-react-hono/scripts/clean.mjs +48 -0
  99. package/template-vp-monorepo-react-hono/tsconfig.base.json +10 -0
  100. package/template-vp-monorepo-react-hono/tsconfig.bundler.json +12 -0
  101. package/template-vp-monorepo-react-hono/tsconfig.json +11 -0
  102. package/template-vp-monorepo-react-hono/tsconfig.lib.json +10 -0
  103. package/template-vp-monorepo-react-hono/vite.config.ts +20 -0
  104. package/template-vp-monorepo-react-nestjs/.spaces/server/todo.md +991 -0
  105. package/template-vp-monorepo-react-nestjs/.vscode/launch.json +16 -0
  106. package/template-vp-monorepo-react-nestjs/.vscode/settings.json +21 -18
  107. package/template-vp-monorepo-react-nestjs/apps/server/.env.development +3 -39
  108. package/template-vp-monorepo-react-nestjs/apps/server/.swcrc +8 -0
  109. package/template-vp-monorepo-react-nestjs/apps/server/drizzle.config.ts +8 -3
  110. package/template-vp-monorepo-react-nestjs/apps/server/nest-cli.json +8 -1
  111. package/template-vp-monorepo-react-nestjs/apps/server/package.json +11 -6
  112. package/template-vp-monorepo-react-nestjs/apps/server/src/app.controller.ts +2 -0
  113. package/template-vp-monorepo-react-nestjs/apps/server/src/app.module.ts +20 -15
  114. package/template-vp-monorepo-react-nestjs/apps/server/src/common/adapters/fastify.adapter.ts +16 -0
  115. package/template-vp-monorepo-react-nestjs/apps/server/src/common/constants/auth.ts +2 -0
  116. package/template-vp-monorepo-react-nestjs/apps/server/src/common/constants/response.ts +0 -4
  117. package/template-vp-monorepo-react-nestjs/apps/server/src/common/constants/session.constants.ts +28 -0
  118. package/template-vp-monorepo-react-nestjs/apps/server/src/common/decorators/current-auth.decorator.ts +16 -0
  119. package/template-vp-monorepo-react-nestjs/apps/server/src/common/decorators/public.decorator.ts +4 -0
  120. package/template-vp-monorepo-react-nestjs/apps/server/src/common/dto/api-failure.dto.ts +25 -0
  121. package/template-vp-monorepo-react-nestjs/apps/server/src/common/error/common.error.ts +40 -0
  122. package/template-vp-monorepo-react-nestjs/apps/server/src/common/exceptions/app.exception.ts +55 -0
  123. package/template-vp-monorepo-react-nestjs/apps/server/src/common/filters/global-exception.filter.ts +384 -0
  124. package/template-vp-monorepo-react-nestjs/apps/server/src/common/openapi/response-headers.ts +9 -0
  125. package/template-vp-monorepo-react-nestjs/apps/server/src/config/index.ts +1 -1
  126. package/template-vp-monorepo-react-nestjs/apps/server/src/config/session.config.ts +36 -0
  127. package/template-vp-monorepo-react-nestjs/apps/server/src/database/schema.ts +4 -64
  128. package/template-vp-monorepo-react-nestjs/apps/server/src/main.ts +29 -8
  129. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/auth.controller.ts +55 -0
  130. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/auth.errors.ts +42 -0
  131. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/auth.module.ts +26 -0
  132. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/auth.repository.ts +40 -0
  133. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/auth.service.ts +129 -0
  134. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/dto/auth-repsponse.dto.ts +31 -0
  135. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/dto/login.dto.ts +11 -0
  136. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/dto/register.dto.ts +17 -0
  137. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/guards/session-auth.guard.ts +50 -0
  138. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/password.service.ts +24 -0
  139. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/session/session-store.service.ts +198 -0
  140. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/session/session-token.service.ts +29 -0
  141. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/session/session.script.ts +221 -0
  142. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/session/session.types.ts +24 -0
  143. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/test/db/db.controller.ts +1 -1
  144. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/test/db/dto/create-user.dto.ts +3 -5
  145. package/template-vp-monorepo-react-nestjs/apps/server/src/modules/test/redis/redis.service.ts +1 -1
  146. package/template-vp-monorepo-react-nestjs/apps/server/src/utils/env-arr.ts +3 -0
  147. package/template-vp-monorepo-react-nestjs/apps/server/test/error-catalog.spec.ts +30 -0
  148. package/template-vp-monorepo-react-nestjs/apps/server/tsconfig.build.json +11 -1
  149. package/template-vp-monorepo-react-nestjs/apps/server/tsconfig.json +12 -20
  150. package/template-vp-monorepo-react-nestjs/apps/server/vite.config.ts +15 -0
  151. package/template-vp-monorepo-react-nestjs/apps/web/package.json +1 -0
  152. package/template-vp-monorepo-react-nestjs/apps/web/tsconfig.json +5 -15
  153. package/template-vp-monorepo-react-nestjs/apps/web-vue/commitlint.config.js +1 -1
  154. package/template-vp-monorepo-react-nestjs/apps/web-vue/lefthook.yml +1 -1
  155. package/template-vp-monorepo-react-nestjs/apps/web-vue/package.json +1 -3
  156. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/api/index.ts +5 -5
  157. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/components/Icon/svg-icon.vue +3 -3
  158. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/hooks/chart/lib.ts +6 -6
  159. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/hooks/chart/useEcharts.ts +34 -29
  160. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/i18n/instance.ts +2 -0
  161. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/layout/default/index.vue +17 -32
  162. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/locales/en_US.json +4 -1
  163. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/locales/zh_CN.json +4 -1
  164. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/router/guard/index.ts +3 -3
  165. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/router/guard/permissionGuard.ts +7 -7
  166. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/router/index.ts +7 -7
  167. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/router/interface.ts +6 -6
  168. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/store/index.ts +6 -6
  169. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/store/modules/user.ts +9 -9
  170. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/styles/index.scss +3 -3
  171. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/types/antdv-next.d.ts +1 -0
  172. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/utils/env.ts +1 -1
  173. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/utils/request/core/index.ts +63 -45
  174. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/utils/request/core/utils.ts +12 -6
  175. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/utils/request/index.ts +1 -1
  176. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/views/example/echart/config.ts +687 -687
  177. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/views/example/echart/index.vue +7 -7
  178. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/views/example/tree-chart.vue +2 -7
  179. package/template-vp-monorepo-react-nestjs/apps/web-vue/src/views/home/index.vue +13 -3
  180. package/template-vp-monorepo-react-nestjs/apps/web-vue/tsconfig.json +5 -17
  181. package/template-vp-monorepo-react-nestjs/apps/web-vue/uno.config.ts +5 -5
  182. package/template-vp-monorepo-react-nestjs/i18n.config.ts +22 -0
  183. package/template-vp-monorepo-react-nestjs/package.json +12 -1
  184. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/README.md +113 -0
  185. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/docs/excel-sync-plan.md +618 -0
  186. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/package.json +31 -0
  187. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/cli.ts +5 -0
  188. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/command.ts +512 -0
  189. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/config.ts +15 -0
  190. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/files.ts +67 -0
  191. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/index.ts +19 -0
  192. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/load-config.ts +228 -0
  193. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/report.ts +57 -0
  194. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/scanner.test.ts +81 -0
  195. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/scanner.ts +290 -0
  196. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/src/sync.ts +274 -0
  197. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/test/command.test.ts +209 -0
  198. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/test/config.test.ts +19 -0
  199. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/test/load-config.test.ts +194 -0
  200. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/test/report.test.ts +63 -0
  201. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/test/sync.test.ts +203 -0
  202. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/test/temporary-directory.ts +25 -0
  203. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/tsconfig.json +9 -0
  204. package/template-vp-monorepo-react-nestjs/packages/i18n/cli/vite.config.ts +18 -0
  205. package/template-vp-monorepo-react-nestjs/packages/i18n/core/tsconfig.json +1 -12
  206. package/template-vp-monorepo-react-nestjs/packages/i18n/react/tsconfig.json +3 -10
  207. package/template-vp-monorepo-react-nestjs/packages/i18n/vue/tsconfig.json +3 -10
  208. package/template-vp-monorepo-react-nestjs/packages/shared/package.json +40 -0
  209. package/template-vp-monorepo-react-nestjs/packages/shared/src/index.ts +13 -0
  210. package/template-vp-monorepo-react-nestjs/packages/shared/src/types/auth.ts +35 -0
  211. package/template-vp-monorepo-react-nestjs/packages/shared/src/types/common.ts +11 -0
  212. package/template-vp-monorepo-react-nestjs/packages/shared/src/types/index.ts +11 -0
  213. package/template-vp-monorepo-react-nestjs/packages/shared/src/utils/account.ts +5 -0
  214. package/template-vp-monorepo-react-nestjs/packages/shared/src/utils/index.ts +2 -0
  215. package/template-vp-monorepo-react-nestjs/packages/shared/src/utils/session-terminal.ts +11 -0
  216. package/template-vp-monorepo-react-nestjs/packages/shared/tsconfig.json +4 -0
  217. package/template-vp-monorepo-react-nestjs/packages/shared/vite.config.ts +17 -0
  218. package/template-vp-monorepo-react-nestjs/pnpm-workspace.yaml +6 -6
  219. package/template-vp-monorepo-react-nestjs/scripts/clean.mjs +48 -0
  220. package/template-vp-monorepo-react-nestjs/tsconfig.base.json +10 -0
  221. package/template-vp-monorepo-react-nestjs/tsconfig.bundler.json +12 -0
  222. package/template-vp-monorepo-react-nestjs/tsconfig.json +8 -6
  223. package/template-vp-monorepo-react-nestjs/tsconfig.lib.json +10 -0
  224. package/template-vp-monorepo-react-nestjs/apps/server/src/common/constants/decorator.ts +0 -2
  225. package/template-vp-monorepo-react-nestjs/apps/server/src/common/decorators/bypass.ts +0 -12
  226. package/template-vp-monorepo-react-nestjs/apps/server/src/common/interceptors/transform.ts +0 -34
  227. package/template-vp-monorepo-react-nestjs/apps/server/src/common/model/response.ts +0 -23
  228. package/template-vp-monorepo-react-nestjs/apps/server/src/config/auth.config.ts +0 -64
  229. package/template-vp-monorepo-react-nestjs/apps/web-vue/pnpm-workspace.yaml +0 -16
  230. /package/{template-vp-monorepo-react-nestjs/apps/server/src/config/security.config.ts → template-vp-monorepo-react-hono/.agents/auth.config.ts} +0 -0
@@ -10,7 +10,7 @@
10
10
 
11
11
  完成步骤 0~15 后,请求会按下面的方式工作。附录 A、B 是后续生产化参考,不属于本教程的强制施工步骤:
12
12
 
13
- ~~~mermaid
13
+ ```mermaid
14
14
  flowchart LR
15
15
  A["浏览器"] --> B["登录或刷新"]
16
16
  B --> C["短期 Access JWT"]
@@ -23,7 +23,7 @@ flowchart LR
23
23
  H --> J["JWT 版本与 Auth State 比较"]
24
24
  I --> J
25
25
  J --> K["业务 Controller"]
26
- ~~~
26
+ ```
27
27
 
28
28
  最终具备以下能力:
29
29
 
@@ -43,17 +43,17 @@ flowchart LR
43
43
 
44
44
  不要只设计一个 version。
45
45
 
46
- | 字段 | 什么时候增加 | 影响范围 |
47
- | --- | --- | --- |
48
- | accountAuthEpoch | 修改密码、冻结账号、退出全部设备 | 该账号的全部旧 Access JWT 和 Refresh Family |
49
- | sessionVersion | 当前设备退出、管理员踢某个设备、Refresh 重放 | 仅一个登录 Session |
50
- | refreshGeneration | 每次成功刷新 | 仅 Refresh Token 轮换链 |
46
+ | 字段 | 什么时候增加 | 影响范围 |
47
+ | ----------------- | -------------------------------------------- | ------------------------------------------- |
48
+ | accountAuthEpoch | 修改密码、冻结账号、退出全部设备 | 该账号的全部旧 Access JWT 和 Refresh Family |
49
+ | sessionVersion | 当前设备退出、管理员踢某个设备、Refresh 重放 | 仅一个登录 Session |
50
+ | refreshGeneration | 每次成功刷新 | 仅 Refresh Token 轮换链 |
51
51
 
52
52
  Refresh Token 正常轮换时,只增加 refreshGeneration,不增加前两个版本。
53
53
 
54
54
  Access JWT 的载荷使用:
55
55
 
56
- ~~~json
56
+ ```json
57
57
  {
58
58
  "sub": "123",
59
59
  "sid": "session-uuid",
@@ -65,7 +65,7 @@ Access JWT 的载荷使用:
65
65
  "iat": 1,
66
66
  "exp": 2
67
67
  }
68
- ~~~
68
+ ```
69
69
 
70
70
  版本比较不是简单的不相等:
71
71
 
@@ -85,9 +85,9 @@ Access JWT 的载荷使用:
85
85
 
86
86
  下面所有命令都从仓库根目录执行:
87
87
 
88
- ~~~text
88
+ ```text
89
89
  C:\malei\stu\bubbles\packages\create-bubbles\template-vp-monorepo-react-nestjs
90
- ~~~
90
+ ```
91
91
 
92
92
  项目真实技术栈是 NestJS 11、Fastify、Drizzle、PostgreSQL、ioredis 和 Vite+。
93
93
 
@@ -95,7 +95,7 @@ C:\malei\stu\bubbles\packages\create-bubbles\template-vp-monorepo-react-nestjs
95
95
 
96
96
  - 使用 vp,不把教程改写成 npm 或 pnpm 工作流。
97
97
  - Fastify Cookie 使用 @fastify/cookie,不使用 Express 的 res.cookie。
98
- - 代码使用单引号、无分号和 @/* 路径别名。
98
+ - 代码使用单引号、无分号和 @/\* 路径别名。
99
99
  - Swagger 的 addBearerAuth 只是文档声明,真正鉴权来自 APP_GUARD。
100
100
  - 现有 Bypass 装饰器只控制 ResponseInterceptor,不能代替 Public。
101
101
  - Refresh Token 原文不能进入数据库、日志和异常。
@@ -105,7 +105,7 @@ C:\malei\stu\bubbles\packages\create-bubbles\template-vp-monorepo-react-nestjs
105
105
 
106
106
  施工完成后,Auth 相关目录大致如下:
107
107
 
108
- ~~~text
108
+ ```text
109
109
  apps/server/src/
110
110
  ├─ common/
111
111
  │ ├─ constants/
@@ -142,7 +142,7 @@ apps/server/src/
142
142
  ├─ auth.service.ts
143
143
  ├─ auth.controller.ts
144
144
  └─ auth.module.ts
145
- ~~~
145
+ ```
146
146
 
147
147
  # 步骤 0:先确认原项目能正常构建
148
148
 
@@ -152,17 +152,17 @@ apps/server/src/
152
152
 
153
153
  ## 执行命令
154
154
 
155
- ~~~powershell
155
+ ```powershell
156
156
  git status --short
157
157
  vp install
158
158
  vp run "server#build"
159
- ~~~
159
+ ```
160
160
 
161
161
  如果 vp 本身表现异常,再执行:
162
162
 
163
- ~~~powershell
163
+ ```powershell
164
164
  vp env doctor
165
- ~~~
165
+ ```
166
166
 
167
167
  ## 完成检查点
168
168
 
@@ -180,22 +180,22 @@ vp env doctor
180
180
 
181
181
  ## 执行命令
182
182
 
183
- ~~~powershell
183
+ ```powershell
184
184
  vp add @nestjs/jwt @fastify/cookie fastify@5.8.5 argon2 --filter server --save-catalog --allow-build argon2
185
- ~~~
185
+ ```
186
186
 
187
187
  ## 检查文件
188
188
 
189
189
  打开 apps/server/package.json,确认 dependencies 中出现:
190
190
 
191
- ~~~json
191
+ ```json
192
192
  {
193
193
  "@fastify/cookie": "catalog:",
194
194
  "@nestjs/jwt": "catalog:",
195
195
  "argon2": "catalog:",
196
196
  "fastify": "catalog:"
197
197
  }
198
- ~~~
198
+ ```
199
199
 
200
200
  同时确认 pnpm-workspace.yaml 的 catalog 中出现对应版本。
201
201
 
@@ -207,9 +207,9 @@ vp add @nestjs/jwt @fastify/cookie fastify@5.8.5 argon2 --filter server --save-c
207
207
 
208
208
  ## 验收命令
209
209
 
210
- ~~~powershell
210
+ ```powershell
211
211
  vp run "server#build"
212
- ~~~
212
+ ```
213
213
 
214
214
  ## 完成检查点
215
215
 
@@ -228,13 +228,13 @@ vp run "server#build"
228
228
 
229
229
  新增文件:
230
230
 
231
- ~~~text
231
+ ```text
232
232
  apps/server/src/config/auth.config.ts
233
- ~~~
233
+ ```
234
234
 
235
235
  粘贴完整代码:
236
236
 
237
- ~~~ts
237
+ ```ts
238
238
  import { registerAs } from '@nestjs/config'
239
239
 
240
240
  export interface AuthConfig {
@@ -295,58 +295,57 @@ export default registerAs(
295
295
  cookieName: process.env.REFRESH_COOKIE_NAME ?? 'refresh_token',
296
296
  cookiePath: process.env.REFRESH_COOKIE_PATH ?? '/auth',
297
297
  cookieSecure:
298
- process.env.REFRESH_COOKIE_SECURE === 'true' ||
299
- process.env.NODE_ENV === 'production',
298
+ process.env.REFRESH_COOKIE_SECURE === 'true' || process.env.NODE_ENV === 'production',
300
299
  cookieDomain: process.env.REFRESH_COOKIE_DOMAIN || undefined,
301
300
  eventChannel: process.env.AUTH_EVENT_CHANNEL ?? 'auth-state:invalidate',
302
301
  }),
303
302
  )
304
- ~~~
303
+ ```
305
304
 
306
305
  这里的 15 分钟 tombstone 大于:
307
306
 
308
- ~~~text
307
+ ```text
309
308
  10 分钟 Access JWT
310
309
  + 30 秒 L1 陈旧窗口
311
310
  + 30 秒时钟误差
312
- ~~~
311
+ ```
313
312
 
314
313
  ## 2.2 导出 authConfig
315
314
 
316
315
  修改文件:
317
316
 
318
- ~~~text
317
+ ```text
319
318
  apps/server/src/config/index.ts
320
- ~~~
319
+ ```
321
320
 
322
321
  替换成:
323
322
 
324
- ~~~ts
323
+ ```ts
325
324
  export { default as appConfig } from './app.config'
326
325
  export { default as authConfig } from './auth.config'
327
326
  export { default as databaseConfig } from './database.config'
328
327
  export { default as llmConfig } from './llm.config'
329
328
  export { default as redisConfig } from './redis.config'
330
- ~~~
329
+ ```
331
330
 
332
331
  ## 2.3 删除旧的 JWT_SECRET 配置入口
333
332
 
334
333
  修改文件:
335
334
 
336
- ~~~text
335
+ ```text
337
336
  apps/server/src/config/app.config.ts
338
- ~~~
337
+ ```
339
338
 
340
339
  替换成:
341
340
 
342
- ~~~ts
341
+ ```ts
343
342
  import { registerAs } from '@nestjs/config'
344
343
 
345
344
  export default registerAs('app', () => ({
346
345
  port: Number.parseInt(process.env.PORT ?? '3000', 10),
347
346
  env: process.env.NODE_ENV ?? 'development',
348
347
  }))
349
- ~~~
348
+ ```
350
349
 
351
350
  以后只使用 JWT_ACCESS_SECRET,不再同时维护 JWT_SECRET 和 JWT_ACCESS_SECRET 两套名字。
352
351
 
@@ -354,30 +353,25 @@ export default registerAs('app', () => ({
354
353
 
355
354
  打开:
356
355
 
357
- ~~~text
356
+ ```text
358
357
  apps/server/src/app.module.ts
359
- ~~~
358
+ ```
360
359
 
361
360
  把 config import 改成:
362
361
 
363
- ~~~ts
362
+ ```ts
364
363
  import { appConfig, authConfig, databaseConfig, llmConfig, redisConfig } from '@/config'
365
- ~~~
364
+ ```
366
365
 
367
366
  把 ConfigModule.forRoot 中的 envFilePath 和 load 改成:
368
367
 
369
- ~~~ts
368
+ ```ts
370
369
  ConfigModule.forRoot({
371
370
  isGlobal: true,
372
- envFilePath: [
373
- '.env.' + nodeEnv + '.local',
374
- '.env.local',
375
- '.env.' + nodeEnv,
376
- '.env',
377
- ],
371
+ envFilePath: ['.env.' + nodeEnv + '.local', '.env.local', '.env.' + nodeEnv, '.env'],
378
372
  load: [appConfig, authConfig, databaseConfig, llmConfig, redisConfig],
379
373
  })
380
- ~~~
374
+ ```
381
375
 
382
376
  local 文件排在前面。dotenv 在不启用 override 时保留先读到的值,因此真实本地密钥会优先生效。
383
377
 
@@ -385,20 +379,20 @@ local 文件排在前面。dotenv 在不启用 override 时保留先读到的值
385
379
 
386
380
  先生成两个不同的随机值:
387
381
 
388
- ~~~powershell
382
+ ```powershell
389
383
  node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))"
390
384
  node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))"
391
- ~~~
385
+ ```
392
386
 
393
387
  新增文件:
394
388
 
395
- ~~~text
389
+ ```text
396
390
  apps/server/.env.development.local
397
- ~~~
391
+ ```
398
392
 
399
393
  写入下面内容,把两个占位值替换成刚生成的两个不同随机值:
400
394
 
401
- ~~~dotenv
395
+ ```dotenv
402
396
  JWT_ACCESS_SECRET=替换成第一个随机值
403
397
  REFRESH_TOKEN_PEPPER=替换成第二个随机值
404
398
  JWT_ISSUER=bubbles-auth
@@ -414,15 +408,15 @@ REFRESH_COOKIE_SECURE=false
414
408
  AUTH_LOCAL_CACHE_TTL_MS=30000
415
409
  AUTH_TOMBSTONE_TTL_SECONDS=900
416
410
  AUTH_EVENT_CHANNEL=auth-state:invalidate
417
- ~~~
411
+ ```
418
412
 
419
- 注意:生产 HTTPS 可以使用 __Host-refresh_token,但 __Host- 前缀不能设置 Domain,Path 必须是 /。本教程开发阶段使用 refresh_token 和 /auth,避免浏览器因前缀规则拒收 Cookie。
413
+ 注意:生产 HTTPS 可以使用 **Host-refresh_token,但 **Host- 前缀不能设置 Domain,Path 必须是 /。本教程开发阶段使用 refresh_token 和 /auth,避免浏览器因前缀规则拒收 Cookie。
420
414
 
421
415
  ## 验收命令
422
416
 
423
- ~~~powershell
417
+ ```powershell
424
418
  vp run "server#build"
425
- ~~~
419
+ ```
426
420
 
427
421
  如果启动时报 JWT_ACCESS_SECRET 未配置,说明 local 文件没有按上述路径创建,或 ConfigModule 的 envFilePath 没改对。
428
422
 
@@ -441,13 +435,13 @@ vp run "server#build"
441
435
 
442
436
  ## 修改文件
443
437
 
444
- ~~~text
438
+ ```text
445
439
  apps/server/src/main.ts
446
- ~~~
440
+ ```
447
441
 
448
442
  替换为下面的完整内容:
449
443
 
450
- ~~~ts
444
+ ```ts
451
445
  import fastifyCookie from '@fastify/cookie'
452
446
  import { NestFactory } from '@nestjs/core'
453
447
  import { AppModule } from '@/app.module'
@@ -457,10 +451,7 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'
457
451
  import { cleanupOpenApiDoc } from 'nestjs-zod'
458
452
 
459
453
  async function bootstrap() {
460
- const app = await NestFactory.create<NestFastifyApplication>(
461
- AppModule,
462
- new FastifyAdapter(),
463
- )
454
+ const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter())
464
455
 
465
456
  await app.register(fastifyCookie)
466
457
 
@@ -490,7 +481,7 @@ async function bootstrap() {
490
481
  }
491
482
 
492
483
  void bootstrap()
493
- ~~~
484
+ ```
494
485
 
495
486
  不要写 Express 风格的 res.cookie。后续 Controller 会使用 FastifyReply.setCookie。
496
487
 
@@ -503,9 +494,9 @@ Swagger 的 addBearerAuth 只定义了文档里的认证方案:
503
494
 
504
495
  ## 验收命令
505
496
 
506
- ~~~powershell
497
+ ```powershell
507
498
  vp run "server#build"
508
- ~~~
499
+ ```
509
500
 
510
501
  ## 完成检查点
511
502
 
@@ -525,13 +516,13 @@ vp run "server#build"
525
516
 
526
517
  修改文件:
527
518
 
528
- ~~~text
519
+ ```text
529
520
  apps/server/src/database/schema.ts
530
- ~~~
521
+ ```
531
522
 
532
523
  替换为下面的完整内容:
533
524
 
534
- ~~~ts
525
+ ```ts
535
526
  import {
536
527
  index,
537
528
  integer,
@@ -545,16 +536,9 @@ import {
545
536
  varchar,
546
537
  } from 'drizzle-orm/pg-core'
547
538
 
548
- export const userStatusEnum = pgEnum('user_status', [
549
- 'active',
550
- 'locked',
551
- 'disabled',
552
- ])
539
+ export const userStatusEnum = pgEnum('user_status', ['active', 'locked', 'disabled'])
553
540
 
554
- export const authSessionStatusEnum = pgEnum('auth_session_status', [
555
- 'active',
556
- 'revoked',
557
- ])
541
+ export const authSessionStatusEnum = pgEnum('auth_session_status', ['active', 'revoked'])
558
542
 
559
543
  export const refreshFamilyStatusEnum = pgEnum('refresh_family_status', [
560
544
  'active',
@@ -648,10 +632,7 @@ export const refreshTokens = pgTable(
648
632
  },
649
633
  (table) => [
650
634
  uniqueIndex('refresh_tokens_token_hash_uidx').on(table.tokenHash),
651
- uniqueIndex('refresh_tokens_family_generation_uidx').on(
652
- table.familyId,
653
- table.generation,
654
- ),
635
+ uniqueIndex('refresh_tokens_family_generation_uidx').on(table.familyId, table.generation),
655
636
  index('refresh_tokens_family_status_idx').on(table.familyId, table.status),
656
637
  ],
657
638
  )
@@ -693,7 +674,7 @@ export const authOutbox = pgTable(
693
674
  ),
694
675
  ],
695
676
  )
696
- ~~~
677
+ ```
697
678
 
698
679
  ## 4.2 修正 Drizzle 环境文件加载
699
680
 
@@ -701,24 +682,19 @@ export const authOutbox = pgTable(
701
682
 
702
683
  修改文件:
703
684
 
704
- ~~~text
685
+ ```text
705
686
  apps/server/drizzle.config.ts
706
- ~~~
687
+ ```
707
688
 
708
689
  替换为:
709
690
 
710
- ~~~ts
691
+ ```ts
711
692
  import { config } from 'dotenv'
712
693
  import { defineConfig } from 'drizzle-kit'
713
694
 
714
695
  const nodeEnv = process.env.NODE_ENV ?? 'development'
715
696
 
716
- for (const path of [
717
- '.env.' + nodeEnv + '.local',
718
- '.env.local',
719
- '.env.' + nodeEnv,
720
- '.env',
721
- ]) {
697
+ for (const path of ['.env.' + nodeEnv + '.local', '.env.local', '.env.' + nodeEnv, '.env']) {
722
698
  config({ path })
723
699
  }
724
700
 
@@ -734,7 +710,7 @@ export default defineConfig({
734
710
  url: process.env.DATABASE_URL,
735
711
  },
736
712
  })
737
- ~~~
713
+ ```
738
714
 
739
715
  ## 4.3 修正现有测试 DTO
740
716
 
@@ -742,13 +718,13 @@ export default defineConfig({
742
718
 
743
719
  修改:
744
720
 
745
- ~~~text
721
+ ```text
746
722
  apps/server/src/modules/test/db/dto/create-user.dto.ts
747
- ~~~
723
+ ```
748
724
 
749
725
  把 omit 改为:
750
726
 
751
- ~~~ts
727
+ ```ts
752
728
  export const createUserSchema = baseSchema.omit({
753
729
  id: true,
754
730
  passwordHash: true,
@@ -758,7 +734,7 @@ export const createUserSchema = baseSchema.omit({
758
734
  createdAt: true,
759
735
  updatedAt: true,
760
736
  })
761
- ~~~
737
+ ```
762
738
 
763
739
  这个 TestDbModule 只能当数据库示例,不能当正式注册接口。正式注册接口接收 password,由服务端生成 passwordHash,绝不能让客户端上传密码哈希。
764
740
 
@@ -768,9 +744,9 @@ export const createUserSchema = baseSchema.omit({
768
744
 
769
745
  执行:
770
746
 
771
- ~~~powershell
747
+ ```powershell
772
748
  vp run "server#db:generate"
773
- ~~~
749
+ ```
774
750
 
775
751
  不要马上 migrate。先打开 apps/server/drizzle 下新生成的 SQL,人工确认:
776
752
 
@@ -783,17 +759,17 @@ vp run "server#db:generate"
783
759
 
784
760
  确认 SQL 后再执行:
785
761
 
786
- ~~~powershell
762
+ ```powershell
787
763
  vp run "server#db:migrate"
788
764
  vp run "server#build"
789
- ~~~
765
+ ```
790
766
 
791
767
  ## 完成检查点
792
768
 
793
769
  - 五张 Auth 相关表已经存在。
794
770
  - 旧 users 数据没有因为 password_hash 而迁移失败。
795
771
  - 两个 Refresh Token 唯一约束已存在。
796
- - 迁移所用 DATABASE_URL 与服务运行时 DB_* 指向同一数据库。
772
+ - 迁移所用 DATABASE*URL 与服务运行时 DB*\* 指向同一数据库。
797
773
  - 构建成功。
798
774
 
799
775
  # 步骤 5:实现密码和 Token 安全原语
@@ -811,30 +787,30 @@ vp run "server#build"
811
787
 
812
788
  新增:
813
789
 
814
- ~~~text
790
+ ```text
815
791
  apps/server/src/common/constants/auth.ts
816
- ~~~
792
+ ```
817
793
 
818
794
  粘贴:
819
795
 
820
- ~~~ts
796
+ ```ts
821
797
  export const IS_PUBLIC_KEY = Symbol('is_public')
822
798
  export const AUTH_CONSISTENCY_KEY = Symbol('auth_consistency')
823
799
 
824
800
  export type AuthConsistency = 'bounded' | 'strong'
825
- ~~~
801
+ ```
826
802
 
827
803
  ## 5.2 新增 Auth 类型
828
804
 
829
805
  新增:
830
806
 
831
- ~~~text
807
+ ```text
832
808
  apps/server/src/modules/auth/auth.types.ts
833
- ~~~
809
+ ```
834
810
 
835
811
  粘贴:
836
812
 
837
- ~~~ts
813
+ ```ts
838
814
  export interface AccessTokenClaims {
839
815
  sub: string
840
816
  sid: string
@@ -879,19 +855,19 @@ export interface IssuedTokenPair {
879
855
  accessExpiresIn: number
880
856
  refreshToken: string
881
857
  }
882
- ~~~
858
+ ```
883
859
 
884
860
  ## 5.3 新增 PasswordService
885
861
 
886
862
  新增:
887
863
 
888
- ~~~text
864
+ ```text
889
865
  apps/server/src/modules/auth/password.service.ts
890
- ~~~
866
+ ```
891
867
 
892
868
  粘贴:
893
869
 
894
- ~~~ts
870
+ ```ts
895
871
  import { Injectable } from '@nestjs/common'
896
872
  import * as argon2 from 'argon2'
897
873
 
@@ -924,7 +900,7 @@ export class PasswordService {
924
900
  }
925
901
  }
926
902
  }
927
- ~~~
903
+ ```
928
904
 
929
905
  当邮箱不存在时仍执行一次 Argon2 验证,减少通过响应时间枚举邮箱的差异。
930
906
 
@@ -932,13 +908,13 @@ export class PasswordService {
932
908
 
933
909
  新增:
934
910
 
935
- ~~~text
911
+ ```text
936
912
  apps/server/src/modules/auth/token.service.ts
937
- ~~~
913
+ ```
938
914
 
939
915
  粘贴:
940
916
 
941
- ~~~ts
917
+ ```ts
942
918
  import { Injectable, UnauthorizedException } from '@nestjs/common'
943
919
  import { ConfigService } from '@nestjs/config'
944
920
  import { JwtService } from '@nestjs/jwt'
@@ -962,9 +938,7 @@ export class RefreshTokenCodec {
962
938
  hash(raw: string) {
963
939
  const auth = this.config.getOrThrow<AuthConfig>('auth')
964
940
 
965
- return createHmac('sha256', auth.refreshPepper)
966
- .update(raw)
967
- .digest('hex')
941
+ return createHmac('sha256', auth.refreshPepper).update(raw).digest('hex')
968
942
  }
969
943
  }
970
944
 
@@ -1021,8 +995,7 @@ export class AccessTokenService {
1021
995
  Number.isSafeInteger(claims.iat) &&
1022
996
  Number.isSafeInteger(claims.exp) &&
1023
997
  claims.exp > claims.iat &&
1024
- claims.exp - claims.iat <=
1025
- auth.accessTtlSeconds + auth.clockToleranceSeconds
998
+ claims.exp - claims.iat <= auth.accessTtlSeconds + auth.clockToleranceSeconds
1026
999
 
1027
1000
  if (!validShape) {
1028
1001
  throw new UnauthorizedException('访问令牌载荷非法')
@@ -1031,7 +1004,7 @@ export class AccessTokenService {
1031
1004
  return claims
1032
1005
  }
1033
1006
  }
1034
- ~~~
1007
+ ```
1035
1008
 
1036
1009
  Access JWT 的 iss、aud、exp 和签名算法稍后由 JwtModule 统一配置。不要手工从请求载荷信任这些字段。
1037
1010
 
@@ -1039,9 +1012,9 @@ Refresh Token 数据库只保存 hash。raw 只在创建时返回给 Controller
1039
1012
 
1040
1013
  ## 验收命令
1041
1014
 
1042
- ~~~powershell
1015
+ ```powershell
1043
1016
  vp run "server#build"
1044
- ~~~
1017
+ ```
1045
1018
 
1046
1019
  ## 完成检查点
1047
1020
 
@@ -1057,7 +1030,7 @@ vp run "server#build"
1057
1030
 
1058
1031
  此时不要急着加入 30 秒缓存。先做一版所有保护接口都实时读取 Redis 的实现,验证下面的闭环:
1059
1032
 
1060
- ~~~text
1033
+ ```text
1061
1034
  登录写入 Auth State
1062
1035
  -> JWT 带 ae 和 sv
1063
1036
  -> Guard 用一个 Lua 原子读取 Account + Session
@@ -1065,7 +1038,7 @@ vp run "server#build"
1065
1038
  -> Redis 缺失或落后时,用一条 PostgreSQL JOIN 回源
1066
1039
  -> 踢人或改密写入更高版本
1067
1040
  -> 下一次强校验拒绝旧 JWT
1068
- ~~~
1041
+ ```
1069
1042
 
1070
1043
  这里有四条硬性不变量:
1071
1044
 
@@ -1078,13 +1051,13 @@ vp run "server#build"
1078
1051
 
1079
1052
  新增:
1080
1053
 
1081
- ~~~text
1054
+ ```text
1082
1055
  apps/server/src/modules/auth/state/auth-state.service.ts
1083
- ~~~
1056
+ ```
1084
1057
 
1085
1058
  粘贴完整代码:
1086
1059
 
1087
- ~~~ts
1060
+ ```ts
1088
1061
  import { InjectRedis } from '@nestjs-modules/ioredis'
1089
1062
  import {
1090
1063
  Inject,
@@ -1253,10 +1226,7 @@ export class AuthStateService {
1253
1226
  private readonly config: ConfigService,
1254
1227
  ) {}
1255
1228
 
1256
- async verify(
1257
- claims: AccessTokenClaims,
1258
- _consistency: AuthConsistency = 'strong',
1259
- ) {
1229
+ async verify(claims: AccessTokenClaims, _consistency: AuthConsistency = 'strong') {
1260
1230
  for (let attempt = 0; attempt < 2; attempt += 1) {
1261
1231
  const shared = await this.readShared(claims.sub, claims.sid)
1262
1232
 
@@ -1273,41 +1243,26 @@ export class AuthStateService {
1273
1243
  return
1274
1244
  }
1275
1245
 
1276
- const database = await this.loadDatabaseSnapshot(
1277
- claims.sub,
1278
- claims.sid,
1279
- )
1246
+ const database = await this.loadDatabaseSnapshot(claims.sub, claims.sid)
1280
1247
 
1281
1248
  if (!database) {
1282
1249
  throw new UnauthorizedException('登录状态不存在')
1283
1250
  }
1284
1251
 
1285
- if (
1286
- database.account.epoch < claims.ae ||
1287
- database.session.version < claims.sv
1288
- ) {
1289
- throw new ServiceUnavailableException(
1290
- '鉴权事实库版本落后于 JWT',
1291
- )
1252
+ if (database.account.epoch < claims.ae || database.session.version < claims.sv) {
1253
+ throw new ServiceUnavailableException('鉴权事实库版本落后于 JWT')
1292
1254
  }
1293
1255
 
1294
- const [accountProjection, sessionProjection] =
1295
- await Promise.all([
1296
- this.projectAccount(database.account),
1297
- this.projectSession(database.session),
1298
- ])
1256
+ const [accountProjection, sessionProjection] = await Promise.all([
1257
+ this.projectAccount(database.account),
1258
+ this.projectSession(database.session),
1259
+ ])
1299
1260
 
1300
- if (
1301
- accountProjection === 'stale' ||
1302
- sessionProjection === 'stale'
1303
- ) {
1261
+ if (accountProjection === 'stale' || sessionProjection === 'stale') {
1304
1262
  continue
1305
1263
  }
1306
1264
 
1307
- const repaired = await this.readShared(
1308
- claims.sub,
1309
- claims.sid,
1310
- )
1265
+ const repaired = await this.readShared(claims.sub, claims.sid)
1311
1266
 
1312
1267
  this.throwIfSharedIsNewer(repaired, claims)
1313
1268
 
@@ -1323,18 +1278,14 @@ export class AuthStateService {
1323
1278
  }
1324
1279
  }
1325
1280
 
1326
- throw new ServiceUnavailableException(
1327
- '鉴权状态正在变化,请稍后重试',
1328
- )
1281
+ throw new ServiceUnavailableException('鉴权状态正在变化,请稍后重试')
1329
1282
  }
1330
1283
 
1331
1284
  async writeThroughAccount(state: AccountAuthState) {
1332
1285
  const result = await this.projectAccount(state)
1333
1286
 
1334
1287
  if (result === 'stale') {
1335
- throw new ServiceUnavailableException(
1336
- '账号鉴权状态已经存在更高版本',
1337
- )
1288
+ throw new ServiceUnavailableException('账号鉴权状态已经存在更高版本')
1338
1289
  }
1339
1290
  }
1340
1291
 
@@ -1342,9 +1293,7 @@ export class AuthStateService {
1342
1293
  const result = await this.projectSession(state)
1343
1294
 
1344
1295
  if (result === 'stale') {
1345
- throw new ServiceUnavailableException(
1346
- 'Session 鉴权状态已经存在更高版本',
1347
- )
1296
+ throw new ServiceUnavailableException('Session 鉴权状态已经存在更高版本')
1348
1297
  }
1349
1298
  }
1350
1299
 
@@ -1377,11 +1326,7 @@ export class AuthStateService {
1377
1326
  ] = raw.map((value) => String(value ?? ''))
1378
1327
 
1379
1328
  return {
1380
- account: this.parseAccount(
1381
- userId,
1382
- accountEpoch,
1383
- accountStatus,
1384
- ),
1329
+ account: this.parseAccount(userId, accountEpoch, accountStatus),
1385
1330
  session: this.parseSession(
1386
1331
  userId,
1387
1332
  sessionId,
@@ -1392,9 +1337,7 @@ export class AuthStateService {
1392
1337
  ),
1393
1338
  }
1394
1339
  } catch {
1395
- throw new ServiceUnavailableException(
1396
- '共享鉴权状态暂时不可用',
1397
- )
1340
+ throw new ServiceUnavailableException('共享鉴权状态暂时不可用')
1398
1341
  }
1399
1342
  }
1400
1343
 
@@ -1404,10 +1347,7 @@ export class AuthStateService {
1404
1347
  ): Promise<AuthStateSnapshot | null> {
1405
1348
  const numericUserId = Number(userId)
1406
1349
 
1407
- if (
1408
- !Number.isSafeInteger(numericUserId) ||
1409
- numericUserId <= 0
1410
- ) {
1350
+ if (!Number.isSafeInteger(numericUserId) || numericUserId <= 0) {
1411
1351
  return null
1412
1352
  }
1413
1353
 
@@ -1425,10 +1365,7 @@ export class AuthStateService {
1425
1365
  .from(users)
1426
1366
  .innerJoin(
1427
1367
  authSessions,
1428
- and(
1429
- eq(authSessions.userId, users.id),
1430
- eq(authSessions.id, sessionId),
1431
- ),
1368
+ and(eq(authSessions.userId, users.id), eq(authSessions.id, sessionId)),
1432
1369
  )
1433
1370
  .where(eq(users.id, numericUserId))
1434
1371
  .limit(1)
@@ -1452,15 +1389,11 @@ export class AuthStateService {
1452
1389
  },
1453
1390
  }
1454
1391
  } catch {
1455
- throw new ServiceUnavailableException(
1456
- '鉴权事实库暂时不可用',
1457
- )
1392
+ throw new ServiceUnavailableException('鉴权事实库暂时不可用')
1458
1393
  }
1459
1394
  }
1460
1395
 
1461
- private async projectAccount(
1462
- state: AccountAuthState,
1463
- ): Promise<ProjectionWriteResult> {
1396
+ private async projectAccount(state: AccountAuthState): Promise<ProjectionWriteResult> {
1464
1397
  let raw: unknown
1465
1398
 
1466
1399
  try {
@@ -1472,21 +1405,15 @@ export class AuthStateService {
1472
1405
  state.status,
1473
1406
  )
1474
1407
  } catch {
1475
- throw new ServiceUnavailableException(
1476
- '账号鉴权状态暂时不可用',
1477
- )
1408
+ throw new ServiceUnavailableException('账号鉴权状态暂时不可用')
1478
1409
  }
1479
1410
 
1480
1411
  return this.decodeProjectionResult(raw, '账号')
1481
1412
  }
1482
1413
 
1483
- private async projectSession(
1484
- state: SessionAuthState,
1485
- ): Promise<ProjectionWriteResult> {
1414
+ private async projectSession(state: SessionAuthState): Promise<ProjectionWriteResult> {
1486
1415
  const auth = this.config.getOrThrow<AuthConfig>('auth')
1487
- const activeTtl = Math.ceil(
1488
- (state.expiresAtMs - Date.now()) / 1000,
1489
- )
1416
+ const activeTtl = Math.ceil((state.expiresAtMs - Date.now()) / 1000)
1490
1417
  const ttlSeconds =
1491
1418
  state.status === 'revoked'
1492
1419
  ? auth.tombstoneTtlSeconds
@@ -1506,18 +1433,13 @@ export class AuthStateService {
1506
1433
  String(ttlSeconds),
1507
1434
  )
1508
1435
  } catch {
1509
- throw new ServiceUnavailableException(
1510
- 'Session 鉴权状态暂时不可用',
1511
- )
1436
+ throw new ServiceUnavailableException('Session 鉴权状态暂时不可用')
1512
1437
  }
1513
1438
 
1514
1439
  return this.decodeProjectionResult(raw, 'Session')
1515
1440
  }
1516
1441
 
1517
- private decodeProjectionResult(
1518
- raw: unknown,
1519
- aggregateName: string,
1520
- ): ProjectionWriteResult {
1442
+ private decodeProjectionResult(raw: unknown, aggregateName: string): ProjectionWriteResult {
1521
1443
  const code = Number(raw)
1522
1444
 
1523
1445
  if (code === 1) {
@@ -1532,9 +1454,7 @@ export class AuthStateService {
1532
1454
  return 'stale'
1533
1455
  }
1534
1456
 
1535
- throw new ServiceUnavailableException(
1536
- aggregateName + '鉴权状态存在版本冲突',
1537
- )
1457
+ throw new ServiceUnavailableException(aggregateName + '鉴权状态存在版本冲突')
1538
1458
  }
1539
1459
 
1540
1460
  private throwIfSharedIsNewer(
@@ -1605,17 +1525,9 @@ export class AuthStateService {
1605
1525
  }
1606
1526
  }
1607
1527
 
1608
- private assertAllowed(
1609
- snapshot: AuthStateSnapshot,
1610
- claims: AccessTokenClaims,
1611
- ) {
1612
- if (
1613
- snapshot.account.epoch < claims.ae ||
1614
- snapshot.session.version < claims.sv
1615
- ) {
1616
- throw new ServiceUnavailableException(
1617
- '鉴权状态投影落后',
1618
- )
1528
+ private assertAllowed(snapshot: AuthStateSnapshot, claims: AccessTokenClaims) {
1529
+ if (snapshot.account.epoch < claims.ae || snapshot.session.version < claims.sv) {
1530
+ throw new ServiceUnavailableException('鉴权状态投影落后')
1619
1531
  }
1620
1532
 
1621
1533
  const accepted =
@@ -1631,18 +1543,18 @@ export class AuthStateService {
1631
1543
  }
1632
1544
  }
1633
1545
  }
1634
- ~~~
1546
+ ```
1635
1547
 
1636
1548
  两个 Redis key 都带相同的 {userId} hash tag,所以即使以后使用 Redis Cluster,也会落在同一个 slot;READ_AUTH_STATE_LUA 可以一次看到账号和 Session 的同一 Redis 执行时点。
1637
1549
 
1638
1550
  ACCOUNT_PROJECTION_LUA 和 SESSION_PROJECTION_LUA 的返回值含义是:
1639
1551
 
1640
- | 返回值 | 含义 | 处理 |
1641
- | --- | --- | --- |
1642
- | 1 | 新版本已写入 | 继续 |
1643
- | 2 | 相同版本、所有字段完全相同 | 幂等成功 |
1644
- | 0 | Redis 已有更高版本 | 重新 Pull,不能覆盖 |
1645
- | -1 / -2 | 同版本字段冲突、revoked 复活、参数或投影损坏 | 返回 503 并报警 |
1552
+ | 返回值 | 含义 | 处理 |
1553
+ | ------- | -------------------------------------------- | ------------------- |
1554
+ | 1 | 新版本已写入 | 继续 |
1555
+ | 2 | 相同版本、所有字段完全相同 | 幂等成功 |
1556
+ | 0 | Redis 已有更高版本 | 重新 Pull,不能覆盖 |
1557
+ | -1 / -2 | 同版本字段冲突、revoked 复活、参数或投影损坏 | 返回 503 并报警 |
1646
1558
 
1647
1559
  不要把“相同 version、不同 status”当作可覆盖更新。状态变化而版本不变,是写路径违反不变量,应立即暴露。
1648
1560
 
@@ -1650,9 +1562,9 @@ ACCOUNT_PROJECTION_LUA 和 SESSION_PROJECTION_LUA 的返回值含义是:
1650
1562
 
1651
1563
  执行:
1652
1564
 
1653
- ~~~powershell
1565
+ ```powershell
1654
1566
  vp run "server#build"
1655
- ~~~
1567
+ ```
1656
1568
 
1657
1569
  此时 AuthModule 还没有在后续步骤完整接入,所以这里只做类型和编译验收。
1658
1570
 
@@ -1671,7 +1583,7 @@ vp run "server#build"
1671
1583
 
1672
1584
  本步骤完成:
1673
1585
 
1674
- ~~~text
1586
+ ```text
1675
1587
  注册账号
1676
1588
  -> 服务端 Argon2id 哈希密码
1677
1589
  -> 登录校验密码
@@ -1679,7 +1591,7 @@ vp run "server#build"
1679
1591
  -> Redis 写入账号和 Session 状态
1680
1592
  -> 返回 Access JWT
1681
1593
  -> Refresh Token 写入 HttpOnly Cookie
1682
- ~~~
1594
+ ```
1683
1595
 
1684
1596
  教程增加一个公开注册接口只是为了让你能独立完成验收。如果你的产品不允许用户自注册,完成测试后删除 register Controller 路由,把注册能力放到管理员或邀请流程中。
1685
1597
 
@@ -1687,18 +1599,18 @@ vp run "server#build"
1687
1599
 
1688
1600
  新增:
1689
1601
 
1690
- ~~~text
1602
+ ```text
1691
1603
  apps/server/src/common/decorators/public.decorator.ts
1692
- ~~~
1604
+ ```
1693
1605
 
1694
1606
  粘贴:
1695
1607
 
1696
- ~~~ts
1608
+ ```ts
1697
1609
  import { SetMetadata } from '@nestjs/common'
1698
1610
  import { IS_PUBLIC_KEY } from '@/common/constants/auth'
1699
1611
 
1700
1612
  export const Public = () => SetMetadata(IS_PUBLIC_KEY, true)
1701
- ~~~
1613
+ ```
1702
1614
 
1703
1615
  它和现有 Bypass 的职责完全不同:
1704
1616
 
@@ -1711,13 +1623,13 @@ export const Public = () => SetMetadata(IS_PUBLIC_KEY, true)
1711
1623
 
1712
1624
  新增:
1713
1625
 
1714
- ~~~text
1626
+ ```text
1715
1627
  apps/server/src/modules/auth/dto/register.dto.ts
1716
- ~~~
1628
+ ```
1717
1629
 
1718
1630
  粘贴:
1719
1631
 
1720
- ~~~ts
1632
+ ```ts
1721
1633
  import { createZodDto } from 'nestjs-zod'
1722
1634
  import { z } from 'zod'
1723
1635
 
@@ -1728,17 +1640,17 @@ const RegisterSchema = z.object({
1728
1640
  })
1729
1641
 
1730
1642
  export class RegisterDto extends createZodDto(RegisterSchema) {}
1731
- ~~~
1643
+ ```
1732
1644
 
1733
1645
  新增:
1734
1646
 
1735
- ~~~text
1647
+ ```text
1736
1648
  apps/server/src/modules/auth/dto/login.dto.ts
1737
- ~~~
1649
+ ```
1738
1650
 
1739
1651
  粘贴:
1740
1652
 
1741
- ~~~ts
1653
+ ```ts
1742
1654
  import { createZodDto } from 'nestjs-zod'
1743
1655
  import { z } from 'zod'
1744
1656
 
@@ -1748,27 +1660,23 @@ const LoginSchema = z.object({
1748
1660
  })
1749
1661
 
1750
1662
  export class LoginDto extends createZodDto(LoginSchema) {}
1751
- ~~~
1663
+ ```
1752
1664
 
1753
1665
  ## 7.3 新增 Refresh Cookie Helper
1754
1666
 
1755
1667
  新增:
1756
1668
 
1757
- ~~~text
1669
+ ```text
1758
1670
  apps/server/src/modules/auth/http/refresh-cookie.ts
1759
- ~~~
1671
+ ```
1760
1672
 
1761
1673
  粘贴:
1762
1674
 
1763
- ~~~ts
1675
+ ```ts
1764
1676
  import type { FastifyReply } from 'fastify'
1765
1677
  import type { AuthConfig } from '@/config/auth.config'
1766
1678
 
1767
- export function setRefreshCookie(
1768
- reply: FastifyReply,
1769
- token: string,
1770
- config: AuthConfig,
1771
- ) {
1679
+ export function setRefreshCookie(reply: FastifyReply, token: string, config: AuthConfig) {
1772
1680
  reply.setCookie(config.cookieName, token, {
1773
1681
  httpOnly: true,
1774
1682
  secure: config.cookieSecure,
@@ -1779,10 +1687,7 @@ export function setRefreshCookie(
1779
1687
  })
1780
1688
  }
1781
1689
 
1782
- export function clearRefreshCookie(
1783
- reply: FastifyReply,
1784
- config: AuthConfig,
1785
- ) {
1690
+ export function clearRefreshCookie(reply: FastifyReply, config: AuthConfig) {
1786
1691
  reply.clearCookie(config.cookieName, {
1787
1692
  httpOnly: true,
1788
1693
  secure: config.cookieSecure,
@@ -1791,7 +1696,7 @@ export function clearRefreshCookie(
1791
1696
  domain: config.cookieDomain,
1792
1697
  })
1793
1698
  }
1794
- ~~~
1699
+ ```
1795
1700
 
1796
1701
  如果前端和 API 跨站部署,SameSite、Secure、CORS 和 CSRF 需要一起设计,不能只把 sameSite 改成 none 就结束。当前教程按同站部署处理。
1797
1702
 
@@ -1799,22 +1704,17 @@ export function clearRefreshCookie(
1799
1704
 
1800
1705
  新增:
1801
1706
 
1802
- ~~~text
1707
+ ```text
1803
1708
  apps/server/src/modules/auth/auth.repository.ts
1804
- ~~~
1709
+ ```
1805
1710
 
1806
1711
  粘贴:
1807
1712
 
1808
- ~~~ts
1713
+ ```ts
1809
1714
  import { Inject, Injectable } from '@nestjs/common'
1810
1715
  import { eq } from 'drizzle-orm'
1811
1716
  import { DRIZZLE, type DrizzleDB } from '@/database/db.module'
1812
- import {
1813
- authSessions,
1814
- refreshTokenFamilies,
1815
- refreshTokens,
1816
- users,
1817
- } from '@/database/schema'
1717
+ import { authSessions, refreshTokenFamilies, refreshTokens, users } from '@/database/schema'
1818
1718
 
1819
1719
  @Injectable()
1820
1720
  export class AuthRepository {
@@ -1832,11 +1732,7 @@ export class AuthRepository {
1832
1732
  })
1833
1733
  }
1834
1734
 
1835
- async createUser(input: {
1836
- name: string
1837
- email: string
1838
- passwordHash: string
1839
- }) {
1735
+ async createUser(input: { name: string; email: string; passwordHash: string }) {
1840
1736
  const [user] = await this.db
1841
1737
  .insert(users)
1842
1738
  .values({
@@ -1889,24 +1785,20 @@ export class AuthRepository {
1889
1785
  })
1890
1786
  }
1891
1787
  }
1892
- ~~~
1788
+ ```
1893
1789
 
1894
1790
  ## 7.5 新增 AuthService
1895
1791
 
1896
1792
  新增:
1897
1793
 
1898
- ~~~text
1794
+ ```text
1899
1795
  apps/server/src/modules/auth/auth.service.ts
1900
- ~~~
1796
+ ```
1901
1797
 
1902
1798
  粘贴:
1903
1799
 
1904
- ~~~ts
1905
- import {
1906
- ConflictException,
1907
- Injectable,
1908
- UnauthorizedException,
1909
- } from '@nestjs/common'
1800
+ ```ts
1801
+ import { ConflictException, Injectable, UnauthorizedException } from '@nestjs/common'
1910
1802
  import { ConfigService } from '@nestjs/config'
1911
1803
  import type { AuthConfig } from '@/config/auth.config'
1912
1804
  import { AuthRepository } from './auth.repository'
@@ -1926,11 +1818,7 @@ export class AuthService {
1926
1818
  private readonly config: ConfigService,
1927
1819
  ) {}
1928
1820
 
1929
- async register(input: {
1930
- name: string
1931
- email: string
1932
- password: string
1933
- }) {
1821
+ async register(input: { name: string; email: string; password: string }) {
1934
1822
  const email = input.email.trim().toLowerCase()
1935
1823
  const existing = await this.repository.findUserByEmail(email)
1936
1824
 
@@ -1973,17 +1861,9 @@ export class AuthService {
1973
1861
  ): Promise<IssuedTokenPair> {
1974
1862
  const email = input.email.trim().toLowerCase()
1975
1863
  const user = await this.repository.findUserByEmail(email)
1976
- const passwordMatched = await this.passwords.verify(
1977
- user?.passwordHash ?? null,
1978
- input.password,
1979
- )
1864
+ const passwordMatched = await this.passwords.verify(user?.passwordHash ?? null, input.password)
1980
1865
 
1981
- if (
1982
- !user ||
1983
- !passwordMatched ||
1984
- !user.passwordHash ||
1985
- user.status !== 'active'
1986
- ) {
1866
+ if (!user || !passwordMatched || !user.passwordHash || user.status !== 'active') {
1987
1867
  throw new UnauthorizedException('邮箱或密码错误')
1988
1868
  }
1989
1869
 
@@ -2028,7 +1908,7 @@ export class AuthService {
2028
1908
  }
2029
1909
  }
2030
1910
  }
2031
- ~~~
1911
+ ```
2032
1912
 
2033
1913
  登录事务提交后才写 Redis。如果 Redis 此时不可用,接口返回 503,客户端拿不到 Token;数据库里可能留下一个不会被使用的 Session,它会在 expiresAt 后由清理任务删除。步骤 13 的 Outbox 只保障撤销、禁用和版本提升类状态,不恢复登录创建投影。
2034
1914
 
@@ -2036,13 +1916,13 @@ export class AuthService {
2036
1916
 
2037
1917
  新增:
2038
1918
 
2039
- ~~~text
1919
+ ```text
2040
1920
  apps/server/src/modules/auth/auth.controller.ts
2041
- ~~~
1921
+ ```
2042
1922
 
2043
1923
  粘贴:
2044
1924
 
2045
- ~~~ts
1925
+ ```ts
2046
1926
  import { Body, Controller, Post, Req, Res } from '@nestjs/common'
2047
1927
  import { ConfigService } from '@nestjs/config'
2048
1928
  import { ApiTags } from '@nestjs/swagger'
@@ -2089,7 +1969,7 @@ export class AuthController {
2089
1969
  }
2090
1970
  }
2091
1971
  }
2092
- ~~~
1972
+ ```
2093
1973
 
2094
1974
  Refresh Token 不出现在 JSON 响应,只通过 Set-Cookie 返回。
2095
1975
 
@@ -2097,13 +1977,13 @@ Refresh Token 不出现在 JSON 响应,只通过 Set-Cookie 返回。
2097
1977
 
2098
1978
  新增:
2099
1979
 
2100
- ~~~text
1980
+ ```text
2101
1981
  apps/server/src/modules/auth/auth.module.ts
2102
- ~~~
1982
+ ```
2103
1983
 
2104
1984
  粘贴:
2105
1985
 
2106
- ~~~ts
1986
+ ```ts
2107
1987
  import { Module } from '@nestjs/common'
2108
1988
  import { ConfigService } from '@nestjs/config'
2109
1989
  import { JwtModule, type JwtModuleOptions } from '@nestjs/jwt'
@@ -2152,7 +2032,7 @@ import { AccessTokenService, RefreshTokenCodec } from './token.service'
2152
2032
  exports: [AuthService, AuthStateService],
2153
2033
  })
2154
2034
  export class AuthModule {}
2155
- ~~~
2035
+ ```
2156
2036
 
2157
2037
  HS256 只用于当前单体施工。多微服务生产环境不要把同一个对称密钥发给所有服务,最后一章会说明如何改成私钥签发、公钥验签。
2158
2038
 
@@ -2160,26 +2040,20 @@ HS256 只用于当前单体施工。多微服务生产环境不要把同一个
2160
2040
 
2161
2041
  修改:
2162
2042
 
2163
- ~~~text
2043
+ ```text
2164
2044
  apps/server/src/app.module.ts
2165
- ~~~
2045
+ ```
2166
2046
 
2167
2047
  替换为以下完整内容:
2168
2048
 
2169
- ~~~ts
2049
+ ```ts
2170
2050
  import { RedisModule } from '@nestjs-modules/ioredis'
2171
2051
  import { Module } from '@nestjs/common'
2172
2052
  import { ConfigModule, ConfigService } from '@nestjs/config'
2173
2053
  import { APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'
2174
2054
  import { ZodSerializerInterceptor, ZodValidationPipe } from 'nestjs-zod'
2175
2055
  import { ResponseInterceptor } from '@/common/interceptors/transform'
2176
- import {
2177
- appConfig,
2178
- authConfig,
2179
- databaseConfig,
2180
- llmConfig,
2181
- redisConfig,
2182
- } from '@/config'
2056
+ import { appConfig, authConfig, databaseConfig, llmConfig, redisConfig } from '@/config'
2183
2057
  import { DatabaseModule } from './database/db.module'
2184
2058
  import { AppController } from './app.controller'
2185
2059
  import { AppService } from './app.service'
@@ -2193,12 +2067,7 @@ const nodeEnv = process.env.NODE_ENV ?? 'development'
2193
2067
  imports: [
2194
2068
  ConfigModule.forRoot({
2195
2069
  isGlobal: true,
2196
- envFilePath: [
2197
- '.env.' + nodeEnv + '.local',
2198
- '.env.local',
2199
- '.env.' + nodeEnv,
2200
- '.env',
2201
- ],
2070
+ envFilePath: ['.env.' + nodeEnv + '.local', '.env.local', '.env.' + nodeEnv, '.env'],
2202
2071
  load: [appConfig, authConfig, databaseConfig, llmConfig, redisConfig],
2203
2072
  }),
2204
2073
  DatabaseModule,
@@ -2238,22 +2107,22 @@ const nodeEnv = process.env.NODE_ENV ?? 'development'
2238
2107
  ],
2239
2108
  })
2240
2109
  export class AppModule {}
2241
- ~~~
2110
+ ```
2242
2111
 
2243
2112
  ## 7.9 构建并启动
2244
2113
 
2245
2114
  执行:
2246
2115
 
2247
- ~~~powershell
2116
+ ```powershell
2248
2117
  vp run "server#build"
2249
2118
  vp run "server#dev"
2250
- ~~~
2119
+ ```
2251
2120
 
2252
2121
  ## 7.10 手工验收注册
2253
2122
 
2254
2123
  另开 PowerShell:
2255
2124
 
2256
- ~~~powershell
2125
+ ```powershell
2257
2126
  $body = @{
2258
2127
  name = "测试用户"
2259
2128
  email = "auth-test@example.com"
@@ -2261,11 +2130,11 @@ $body = @{
2261
2130
  } | ConvertTo-Json
2262
2131
 
2263
2132
  Invoke-RestMethod -Method Post -Uri "http://localhost:3000/auth/register" -ContentType "application/json" -Body $body
2264
- ~~~
2133
+ ```
2265
2134
 
2266
2135
  由于项目已有 ResponseInterceptor,成功响应不是裸数据,而是:
2267
2136
 
2268
- ~~~json
2137
+ ```json
2269
2138
  {
2270
2139
  "code": 200,
2271
2140
  "data": {
@@ -2275,11 +2144,11 @@ Invoke-RestMethod -Method Post -Uri "http://localhost:3000/auth/register" -Conte
2275
2144
  },
2276
2145
  "message": "success"
2277
2146
  }
2278
- ~~~
2147
+ ```
2279
2148
 
2280
2149
  ## 7.11 手工验收登录和 Cookie
2281
2150
 
2282
- ~~~powershell
2151
+ ```powershell
2283
2152
  $web = New-Object Microsoft.PowerShell.Commands.WebRequestSession
2284
2153
 
2285
2154
  $body = @{
@@ -2292,7 +2161,7 @@ $login = Invoke-RestMethod -Method Post -Uri "http://localhost:3000/auth/login"
2292
2161
  $accessToken = $login.data.accessToken
2293
2162
  $login.data
2294
2163
  $web.Cookies.GetCookies("http://localhost:3000")
2295
- ~~~
2164
+ ```
2296
2165
 
2297
2166
  你应该看到:
2298
2167
 
@@ -2321,18 +2190,18 @@ $web.Cookies.GetCookies("http://localhost:3000")
2321
2190
 
2322
2191
  新增:
2323
2192
 
2324
- ~~~text
2193
+ ```text
2325
2194
  apps/server/src/common/decorators/strong-auth.decorator.ts
2326
- ~~~
2195
+ ```
2327
2196
 
2328
2197
  粘贴:
2329
2198
 
2330
- ~~~ts
2199
+ ```ts
2331
2200
  import { SetMetadata } from '@nestjs/common'
2332
2201
  import { AUTH_CONSISTENCY_KEY } from '@/common/constants/auth'
2333
2202
 
2334
2203
  export const StrongAuth = () => SetMetadata(AUTH_CONSISTENCY_KEY, 'strong')
2335
- ~~~
2204
+ ```
2336
2205
 
2337
2206
  此时所有接口本来就会强校验。这个装饰器先建立语义,等步骤 11 把默认值改成 bounded 后,它才真正负责绕过 L1。
2338
2207
 
@@ -2340,13 +2209,13 @@ export const StrongAuth = () => SetMetadata(AUTH_CONSISTENCY_KEY, 'strong')
2340
2209
 
2341
2210
  新增:
2342
2211
 
2343
- ~~~text
2212
+ ```text
2344
2213
  apps/server/src/common/decorators/current-auth.decorator.ts
2345
- ~~~
2214
+ ```
2346
2215
 
2347
2216
  粘贴:
2348
2217
 
2349
- ~~~ts
2218
+ ```ts
2350
2219
  import { createParamDecorator, type ExecutionContext } from '@nestjs/common'
2351
2220
  import type { FastifyRequest } from 'fastify'
2352
2221
  import type { AuthPrincipal } from '@/modules/auth/auth.types'
@@ -2359,19 +2228,19 @@ export const CurrentAuth = createParamDecorator(
2359
2228
  (_data: unknown, context: ExecutionContext) =>
2360
2229
  context.switchToHttp().getRequest<AuthenticatedRequest>().auth,
2361
2230
  )
2362
- ~~~
2231
+ ```
2363
2232
 
2364
2233
  ## 8.3 新增全局 HybridAuthGuard
2365
2234
 
2366
2235
  新增:
2367
2236
 
2368
- ~~~text
2237
+ ```text
2369
2238
  apps/server/src/modules/auth/guards/hybrid-auth.guard.ts
2370
- ~~~
2239
+ ```
2371
2240
 
2372
2241
  粘贴:
2373
2242
 
2374
- ~~~ts
2243
+ ```ts
2375
2244
  import {
2376
2245
  type CanActivate,
2377
2246
  type ExecutionContext,
@@ -2380,11 +2249,7 @@ import {
2380
2249
  } from '@nestjs/common'
2381
2250
  import { Reflector } from '@nestjs/core'
2382
2251
  import type { FastifyRequest } from 'fastify'
2383
- import {
2384
- AUTH_CONSISTENCY_KEY,
2385
- type AuthConsistency,
2386
- IS_PUBLIC_KEY,
2387
- } from '@/common/constants/auth'
2252
+ import { AUTH_CONSISTENCY_KEY, type AuthConsistency, IS_PUBLIC_KEY } from '@/common/constants/auth'
2388
2253
  import type { AuthPrincipal } from '../auth.types'
2389
2254
  import { AuthStateService } from '../state/auth-state.service'
2390
2255
  import { AccessTokenService } from '../token.service'
@@ -2404,28 +2269,20 @@ export class HybridAuthGuard implements CanActivate {
2404
2269
  async canActivate(context: ExecutionContext) {
2405
2270
  const targets = [context.getHandler(), context.getClass()]
2406
2271
 
2407
- const isPublic = this.reflector.getAllAndOverride<boolean>(
2408
- IS_PUBLIC_KEY,
2409
- targets,
2410
- )
2272
+ const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, targets)
2411
2273
 
2412
2274
  if (isPublic) {
2413
2275
  return true
2414
2276
  }
2415
2277
 
2416
- const request = context
2417
- .switchToHttp()
2418
- .getRequest<AuthenticatedRequest>()
2278
+ const request = context.switchToHttp().getRequest<AuthenticatedRequest>()
2419
2279
 
2420
2280
  const rawToken = this.extractBearer(request.headers.authorization)
2421
2281
  const claims = await this.accessTokens.verify(rawToken)
2422
2282
 
2423
2283
  // 第一阶段故意默认 strong;步骤 11 再改成 bounded
2424
2284
  const consistency =
2425
- this.reflector.getAllAndOverride<AuthConsistency>(
2426
- AUTH_CONSISTENCY_KEY,
2427
- targets,
2428
- ) ?? 'strong'
2285
+ this.reflector.getAllAndOverride<AuthConsistency>(AUTH_CONSISTENCY_KEY, targets) ?? 'strong'
2429
2286
 
2430
2287
  await this.authState.verify(claims, consistency)
2431
2288
 
@@ -2447,42 +2304,38 @@ export class HybridAuthGuard implements CanActivate {
2447
2304
 
2448
2305
  const [scheme, token, extra] = header.trim().split(/\s+/)
2449
2306
 
2450
- if (
2451
- extra ||
2452
- scheme?.toLowerCase() !== 'bearer' ||
2453
- !token
2454
- ) {
2307
+ if (extra || scheme?.toLowerCase() !== 'bearer' || !token) {
2455
2308
  throw new UnauthorizedException('Authorization 格式错误')
2456
2309
  }
2457
2310
 
2458
2311
  return token
2459
2312
  }
2460
2313
  }
2461
- ~~~
2314
+ ```
2462
2315
 
2463
2316
  ## 8.4 注册 APP_GUARD
2464
2317
 
2465
2318
  修改:
2466
2319
 
2467
- ~~~text
2320
+ ```text
2468
2321
  apps/server/src/modules/auth/auth.module.ts
2469
- ~~~
2322
+ ```
2470
2323
 
2471
2324
  在 import 中增加:
2472
2325
 
2473
- ~~~ts
2326
+ ```ts
2474
2327
  import { APP_GUARD } from '@nestjs/core'
2475
2328
  import { HybridAuthGuard } from './guards/hybrid-auth.guard'
2476
- ~~~
2329
+ ```
2477
2330
 
2478
2331
  在 providers 最后增加:
2479
2332
 
2480
- ~~~ts
2333
+ ```ts
2481
2334
  {
2482
2335
  provide: APP_GUARD,
2483
2336
  useClass: HybridAuthGuard,
2484
2337
  },
2485
- ~~~
2338
+ ```
2486
2339
 
2487
2340
  不要在 AppModule 再注册第二次相同 Guard。
2488
2341
 
@@ -2490,38 +2343,38 @@ import { HybridAuthGuard } from './guards/hybrid-auth.guard'
2490
2343
 
2491
2344
  修改:
2492
2345
 
2493
- ~~~text
2346
+ ```text
2494
2347
  apps/server/src/modules/auth/auth.controller.ts
2495
- ~~~
2348
+ ```
2496
2349
 
2497
2350
  把 Nest import 增加 Get:
2498
2351
 
2499
- ~~~ts
2352
+ ```ts
2500
2353
  import { Body, Controller, Get, Post, Req, Res } from '@nestjs/common'
2501
- ~~~
2354
+ ```
2502
2355
 
2503
2356
  把 Swagger import 改成:
2504
2357
 
2505
- ~~~ts
2358
+ ```ts
2506
2359
  import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'
2507
- ~~~
2360
+ ```
2508
2361
 
2509
2362
  增加两个 import:
2510
2363
 
2511
- ~~~ts
2364
+ ```ts
2512
2365
  import { CurrentAuth } from '@/common/decorators/current-auth.decorator'
2513
2366
  import type { AuthPrincipal } from './auth.types'
2514
- ~~~
2367
+ ```
2515
2368
 
2516
2369
  在 Controller 类中增加:
2517
2370
 
2518
- ~~~ts
2371
+ ```ts
2519
2372
  @ApiBearerAuth('access-token')
2520
2373
  @Get('me')
2521
2374
  me(@CurrentAuth() auth: AuthPrincipal) {
2522
2375
  return auth
2523
2376
  }
2524
- ~~~
2377
+ ```
2525
2378
 
2526
2379
  ApiBearerAuth 只控制 Swagger operation 的锁图标和 Authorization 输入。真正校验仍然是 HybridAuthGuard。
2527
2380
 
@@ -2529,27 +2382,27 @@ ApiBearerAuth 只控制 Swagger operation 的锁图标和 Authorization 输入
2529
2382
 
2530
2383
  执行:
2531
2384
 
2532
- ~~~powershell
2385
+ ```powershell
2533
2386
  vp run "server#build"
2534
- ~~~
2387
+ ```
2535
2388
 
2536
2389
  保持 dev server 运行,然后测试无 Token:
2537
2390
 
2538
- ~~~powershell
2391
+ ```powershell
2539
2392
  try {
2540
2393
  Invoke-RestMethod -Uri "http://localhost:3000/auth/me"
2541
2394
  } catch {
2542
2395
  $_.Exception.Response.StatusCode.value__
2543
2396
  }
2544
- ~~~
2397
+ ```
2545
2398
 
2546
2399
  应为 401。
2547
2400
 
2548
2401
  再使用步骤 7 保存的 Access Token:
2549
2402
 
2550
- ~~~powershell
2403
+ ```powershell
2551
2404
  Invoke-RestMethod -Uri "http://localhost:3000/auth/me" -Headers @{ Authorization = "Bearer $accessToken" }
2552
- ~~~
2405
+ ```
2553
2406
 
2554
2407
  应返回当前 userId、sessionId、accountEpoch、sessionVersion 和 tokenId,并经过统一响应包装。
2555
2408
 
@@ -2569,7 +2422,7 @@ Invoke-RestMethod -Uri "http://localhost:3000/auth/me" -Headers @{ Authorization
2569
2422
 
2570
2423
  每次调用 refresh 时:
2571
2424
 
2572
- ~~~text
2425
+ ```text
2573
2426
  旧 Refresh Token active
2574
2427
  -> 数据库事务锁定 Token、Family、Session、User
2575
2428
  -> 旧 Token 改成 consumed
@@ -2577,11 +2430,11 @@ Invoke-RestMethod -Uri "http://localhost:3000/auth/me" -Headers @{ Authorization
2577
2430
  -> 插入新的 Refresh Token hash
2578
2431
  -> 返回新 Access JWT
2579
2432
  -> Cookie 替换成新的 Refresh Token
2580
- ~~~
2433
+ ```
2581
2434
 
2582
2435
  如果再次使用已经 consumed 的旧 Token:
2583
2436
 
2584
- ~~~text
2437
+ ```text
2585
2438
  Family -> compromised
2586
2439
  Session -> revoked
2587
2440
  Session version + 1
@@ -2589,7 +2442,7 @@ Session version + 1
2589
2442
  事务提交
2590
2443
  写 Redis revoked tombstone
2591
2444
  最后才返回 401
2592
- ~~~
2445
+ ```
2593
2446
 
2594
2447
  特别注意:发现重放以后,不能在数据库事务内部直接 throw。直接 throw 会让刚执行的撤销更新一起回滚。
2595
2448
 
@@ -2597,12 +2450,12 @@ Session version + 1
2597
2450
 
2598
2451
  只给 Refresh Token 行加 `FOR UPDATE` 还不够。Refresh 通常从 Token、Family 开始查,logout 或管理员踢人通常从 Session 开始查。如果各条命令直接按自己的查询入口加行锁,就可能形成相反的锁顺序:
2599
2452
 
2600
- ~~~text
2453
+ ```text
2601
2454
  Refresh:已经锁住 Token,等待 Session
2602
2455
  logout: 已经锁住 Session,等待 Token
2603
2456
 
2604
2457
  结果:死锁;或者依赖数据库偶然的加锁顺序,无法清楚定义谁先发生
2605
- ~~~
2458
+ ```
2606
2459
 
2607
2460
  所以 advisory lock 的作用不是代替数据库行锁,也不是解决“最后提交覆盖”,而是让所有改变同一个 Session 的事务先经过同一个入口,统一锁顺序并明确事务先后。
2608
2461
 
@@ -2610,16 +2463,16 @@ logout: 已经锁住 Session,等待 Token
2610
2463
 
2611
2464
  第一次通过 Token hash 查询只能用来定位 sid。下面两条映射从创建到过期必须保持不可修改:
2612
2465
 
2613
- ~~~text
2466
+ ```text
2614
2467
  refresh_tokens.family_id
2615
2468
  refresh_token_families.session_id
2616
- ~~~
2469
+ ```
2617
2470
 
2618
2471
  不要通过 UPDATE 把已有 Token 或 Family 迁移到另一个 Session。确实需要重新绑定时,创建新的 Session、Family 和 Token,再撤销旧记录。否则第一次定位出来的 sid 可能在等待锁期间变化,事务就会锁错对象。
2619
2472
 
2620
2473
  统一协议如下:
2621
2474
 
2622
- ~~~text
2475
+ ```text
2623
2476
  锁键:auth-session:<sid>
2624
2477
 
2625
2478
  Refresh:
@@ -2633,7 +2486,7 @@ logout / 管理员踢人:
2633
2486
  -> advisory lock(sid)
2634
2487
  -> 重新读取 Session,并 FOR UPDATE
2635
2488
  -> 撤销 Session + Family + active Refresh Token
2636
- ~~~
2489
+ ```
2637
2490
 
2638
2491
  这里使用 `auth-session:` 前缀隔离 advisory-lock 命名空间。锁会随 PostgreSQL 事务提交或回滚自动释放,不要手动 unlock。
2639
2492
 
@@ -2645,22 +2498,17 @@ advisory lock 只协调所有**遵守这套协议**的代码,所以后面的 `
2645
2498
 
2646
2499
  将:
2647
2500
 
2648
- ~~~text
2501
+ ```text
2649
2502
  apps/server/src/modules/auth/auth.repository.ts
2650
- ~~~
2503
+ ```
2651
2504
 
2652
2505
  替换成下面的完整版本。这个版本同时提前加入后续退出和改密需要的方法,避免连续几步反复重写 Repository。
2653
2506
 
2654
- ~~~ts
2507
+ ```ts
2655
2508
  import { Inject, Injectable } from '@nestjs/common'
2656
2509
  import { and, eq, inArray, sql } from 'drizzle-orm'
2657
2510
  import { DRIZZLE, type DrizzleDB } from '@/database/db.module'
2658
- import {
2659
- authSessions,
2660
- refreshTokenFamilies,
2661
- refreshTokens,
2662
- users,
2663
- } from '@/database/schema'
2511
+ import { authSessions, refreshTokenFamilies, refreshTokens, users } from '@/database/schema'
2664
2512
 
2665
2513
  @Injectable()
2666
2514
  export class AuthRepository {
@@ -2678,11 +2526,7 @@ export class AuthRepository {
2678
2526
  })
2679
2527
  }
2680
2528
 
2681
- async createUser(input: {
2682
- name: string
2683
- email: string
2684
- passwordHash: string
2685
- }) {
2529
+ async createUser(input: { name: string; email: string; passwordHash: string }) {
2686
2530
  const [user] = await this.db
2687
2531
  .insert(users)
2688
2532
  .values({
@@ -2735,11 +2579,7 @@ export class AuthRepository {
2735
2579
  })
2736
2580
  }
2737
2581
 
2738
- rotateRefreshToken(input: {
2739
- currentTokenHash: string
2740
- nextTokenHash: string
2741
- now: Date
2742
- }) {
2582
+ rotateRefreshToken(input: { currentTokenHash: string; nextTokenHash: string; now: Date }) {
2743
2583
  return this.db.transaction(async (tx) => {
2744
2584
  // 第一次查询只把不可变的 Token -> Family -> sid 映射找出来。
2745
2585
  // 这里读到的 status、version 等状态一律不能用于后续判断。
@@ -2748,10 +2588,7 @@ export class AuthRepository {
2748
2588
  sessionId: refreshTokenFamilies.sessionId,
2749
2589
  })
2750
2590
  .from(refreshTokens)
2751
- .innerJoin(
2752
- refreshTokenFamilies,
2753
- eq(refreshTokenFamilies.id, refreshTokens.familyId),
2754
- )
2591
+ .innerJoin(refreshTokenFamilies, eq(refreshTokenFamilies.id, refreshTokens.familyId))
2755
2592
  .where(eq(refreshTokens.tokenHash, input.currentTokenHash))
2756
2593
  .limit(1)
2757
2594
 
@@ -2790,14 +2627,8 @@ export class AuthRepository {
2790
2627
  accountEpoch: users.authEpoch,
2791
2628
  })
2792
2629
  .from(refreshTokens)
2793
- .innerJoin(
2794
- refreshTokenFamilies,
2795
- eq(refreshTokenFamilies.id, refreshTokens.familyId),
2796
- )
2797
- .innerJoin(
2798
- authSessions,
2799
- eq(authSessions.id, refreshTokenFamilies.sessionId),
2800
- )
2630
+ .innerJoin(refreshTokenFamilies, eq(refreshTokenFamilies.id, refreshTokens.familyId))
2631
+ .innerJoin(authSessions, eq(authSessions.id, refreshTokenFamilies.sessionId))
2801
2632
  .innerJoin(users, eq(users.id, authSessions.userId))
2802
2633
  .where(
2803
2634
  and(
@@ -2827,12 +2658,7 @@ export class AuthRepository {
2827
2658
  .set({
2828
2659
  status: 'revoked',
2829
2660
  })
2830
- .where(
2831
- and(
2832
- eq(refreshTokens.familyId, row.familyId),
2833
- eq(refreshTokens.status, 'active'),
2834
- ),
2835
- )
2661
+ .where(and(eq(refreshTokens.familyId, row.familyId), eq(refreshTokens.status, 'active')))
2836
2662
 
2837
2663
  const [updatedSession] = await tx
2838
2664
  .update(authSessions)
@@ -2885,12 +2711,7 @@ export class AuthRepository {
2885
2711
  status: 'consumed',
2886
2712
  consumedAt: input.now,
2887
2713
  })
2888
- .where(
2889
- and(
2890
- eq(refreshTokens.id, row.tokenId),
2891
- eq(refreshTokens.status, 'active'),
2892
- ),
2893
- )
2714
+ .where(and(eq(refreshTokens.id, row.tokenId), eq(refreshTokens.status, 'active')))
2894
2715
  .returning()
2895
2716
 
2896
2717
  if (!consumed) {
@@ -2909,10 +2730,7 @@ export class AuthRepository {
2909
2730
  and(
2910
2731
  eq(refreshTokenFamilies.id, row.familyId),
2911
2732
  eq(refreshTokenFamilies.status, 'active'),
2912
- eq(
2913
- refreshTokenFamilies.currentGeneration,
2914
- row.tokenGeneration,
2915
- ),
2733
+ eq(refreshTokenFamilies.currentGeneration, row.tokenGeneration),
2916
2734
  ),
2917
2735
  )
2918
2736
  .returning()
@@ -2946,11 +2764,7 @@ export class AuthRepository {
2946
2764
  })
2947
2765
  }
2948
2766
 
2949
- revokeSession(input: {
2950
- sessionId: string
2951
- userId?: number
2952
- reason: string
2953
- }) {
2767
+ revokeSession(input: { sessionId: string; userId?: number; reason: string }) {
2954
2768
  return this.db.transaction(async (tx) => {
2955
2769
  const now = new Date()
2956
2770
 
@@ -2962,17 +2776,10 @@ export class AuthRepository {
2962
2776
  `)
2963
2777
 
2964
2778
  const condition = input.userId
2965
- ? and(
2966
- eq(authSessions.id, input.sessionId),
2967
- eq(authSessions.userId, input.userId),
2968
- )
2779
+ ? and(eq(authSessions.id, input.sessionId), eq(authSessions.userId, input.userId))
2969
2780
  : eq(authSessions.id, input.sessionId)
2970
2781
 
2971
- const [session] = await tx
2972
- .select()
2973
- .from(authSessions)
2974
- .where(condition)
2975
- .for('update')
2782
+ const [session] = await tx.select().from(authSessions).where(condition).for('update')
2976
2783
 
2977
2784
  if (!session) {
2978
2785
  return null
@@ -3029,10 +2836,7 @@ export class AuthRepository {
3029
2836
  .update(refreshTokens)
3030
2837
  .set({ status: 'revoked' })
3031
2838
  .where(
3032
- and(
3033
- inArray(refreshTokens.familyId, familyIds),
3034
- eq(refreshTokens.status, 'active'),
3035
- ),
2839
+ and(inArray(refreshTokens.familyId, familyIds), eq(refreshTokens.status, 'active')),
3036
2840
  )
3037
2841
  }
3038
2842
 
@@ -3048,11 +2852,7 @@ export class AuthRepository {
3048
2852
 
3049
2853
  bumpAccountEpoch(userId: number) {
3050
2854
  return this.db.transaction(async (tx) => {
3051
- const [account] = await tx
3052
- .select()
3053
- .from(users)
3054
- .where(eq(users.id, userId))
3055
- .for('update')
2855
+ const [account] = await tx.select().from(users).where(eq(users.id, userId)).for('update')
3056
2856
 
3057
2857
  if (!account) {
3058
2858
  return null
@@ -3106,19 +2906,19 @@ export class AuthRepository {
3106
2906
  })
3107
2907
  }
3108
2908
  }
3109
- ~~~
2909
+ ```
3110
2910
 
3111
2911
  ## 9.2 在 AuthService 增加 refresh
3112
2912
 
3113
2913
  修改:
3114
2914
 
3115
- ~~~text
2915
+ ```text
3116
2916
  apps/server/src/modules/auth/auth.service.ts
3117
- ~~~
2917
+ ```
3118
2918
 
3119
2919
  把 UnauthorizedException 保留在 import 中,然后在 AuthService 类内增加:
3120
2920
 
3121
- ~~~ts
2921
+ ```ts
3122
2922
  async refresh(rawToken: string | undefined): Promise<IssuedTokenPair> {
3123
2923
  if (!rawToken) {
3124
2924
  throw new UnauthorizedException('缺少 Refresh Token')
@@ -3158,19 +2958,19 @@ async refresh(rawToken: string | undefined): Promise<IssuedTokenPair> {
3158
2958
  refreshToken: nextToken.raw,
3159
2959
  }
3160
2960
  }
3161
- ~~~
2961
+ ```
3162
2962
 
3163
2963
  ## 9.3 在 Controller 增加 refresh 接口
3164
2964
 
3165
2965
  修改:
3166
2966
 
3167
- ~~~text
2967
+ ```text
3168
2968
  apps/server/src/modules/auth/auth.controller.ts
3169
- ~~~
2969
+ ```
3170
2970
 
3171
2971
  在 Controller 类内增加:
3172
2972
 
3173
- ~~~ts
2973
+ ```ts
3174
2974
  @Public()
3175
2975
  @Post('refresh')
3176
2976
  async refresh(
@@ -3188,21 +2988,21 @@ async refresh(
3188
2988
  expiresIn: result.accessExpiresIn,
3189
2989
  }
3190
2990
  }
3191
- ~~~
2991
+ ```
3192
2992
 
3193
2993
  request.cookies 的类型由 @fastify/cookie 增强。如果 TypeScript 提示 cookies 不存在,先确认 main.ts 已注册插件并且依赖已正确安装。
3194
2994
 
3195
2995
  ## 9.4 构建
3196
2996
 
3197
- ~~~powershell
2997
+ ```powershell
3198
2998
  vp run "server#build"
3199
- ~~~
2999
+ ```
3200
3000
 
3201
3001
  ## 9.5 验收正常轮换
3202
3002
 
3203
3003
  重新登录并保存旧 Cookie:
3204
3004
 
3205
- ~~~powershell
3005
+ ```powershell
3206
3006
  $web = New-Object Microsoft.PowerShell.Commands.WebRequestSession
3207
3007
  $body = @{ email = "auth-test@example.com"; password = "Correct-Horse-Battery-123!" } | ConvertTo-Json
3208
3008
  $login = Invoke-RestMethod -Method Post -Uri "http://localhost:3000/auth/login" -ContentType "application/json" -Body $body -WebSession $web
@@ -3213,7 +3013,7 @@ $newAccessToken = $refresh.data.accessToken
3213
3013
  $newCookie = $web.Cookies.GetCookies("http://localhost:3000")["refresh_token"].Value
3214
3014
 
3215
3015
  $oldCookie -eq $newCookie
3216
- ~~~
3016
+ ```
3217
3017
 
3218
3018
  最后一行必须是 False。
3219
3019
 
@@ -3228,7 +3028,7 @@ $oldCookie -eq $newCookie
3228
3028
 
3229
3029
  创建一个只携带旧 Cookie 的新 WebSession:
3230
3030
 
3231
- ~~~powershell
3031
+ ```powershell
3232
3032
  $replay = New-Object Microsoft.PowerShell.Commands.WebRequestSession
3233
3033
  $cookie = New-Object System.Net.Cookie("refresh_token", $oldCookie, "/auth", "localhost")
3234
3034
  $replay.Cookies.Add($cookie)
@@ -3238,7 +3038,7 @@ try {
3238
3038
  } catch {
3239
3039
  $_.Exception.Response.StatusCode.value__
3240
3040
  }
3241
- ~~~
3041
+ ```
3242
3042
 
3243
3043
  应返回 401。
3244
3044
 
@@ -3283,14 +3083,14 @@ try {
3283
3083
 
3284
3084
  实现两个不同的失效维度:
3285
3085
 
3286
- ~~~text
3086
+ ```text
3287
3087
  单设备失效
3288
3088
  Session status -> revoked
3289
3089
  sessionVersion + 1
3290
3090
 
3291
3091
  全部设备失效
3292
3092
  accountAuthEpoch + 1
3293
- ~~~
3093
+ ```
3294
3094
 
3295
3095
  退出全部设备和改密不扫描每一个 Session,也不向每个微服务广播每一个 Session。只更新一次账号 epoch,并发送一个账号级事件。
3296
3096
 
@@ -3298,13 +3098,13 @@ try {
3298
3098
 
3299
3099
  新增:
3300
3100
 
3301
- ~~~text
3101
+ ```text
3302
3102
  apps/server/src/modules/auth/dto/change-password.dto.ts
3303
- ~~~
3103
+ ```
3304
3104
 
3305
3105
  粘贴:
3306
3106
 
3307
- ~~~ts
3107
+ ```ts
3308
3108
  import { createZodDto } from 'nestjs-zod'
3309
3109
  import { z } from 'zod'
3310
3110
 
@@ -3318,34 +3118,28 @@ const ChangePasswordSchema = z
3318
3118
  path: ['newPassword'],
3319
3119
  })
3320
3120
 
3321
- export class ChangePasswordDto extends createZodDto(
3322
- ChangePasswordSchema,
3323
- ) {}
3324
- ~~~
3121
+ export class ChangePasswordDto extends createZodDto(ChangePasswordSchema) {}
3122
+ ```
3325
3123
 
3326
3124
  ## 10.2 扩展 AuthService
3327
3125
 
3328
3126
  打开:
3329
3127
 
3330
- ~~~text
3128
+ ```text
3331
3129
  apps/server/src/modules/auth/auth.service.ts
3332
- ~~~
3130
+ ```
3333
3131
 
3334
3132
  在 Nest import 中加入 ConflictException:
3335
3133
 
3336
- ~~~ts
3337
- import {
3338
- ConflictException,
3339
- Injectable,
3340
- UnauthorizedException,
3341
- } from '@nestjs/common'
3342
- ~~~
3134
+ ```ts
3135
+ import { ConflictException, Injectable, UnauthorizedException } from '@nestjs/common'
3136
+ ```
3343
3137
 
3344
3138
  如果之前已经有 ConflictException,不要重复导入。
3345
3139
 
3346
3140
  在 AuthService 类内增加:
3347
3141
 
3348
- ~~~ts
3142
+ ```ts
3349
3143
  async logoutCurrent(input: {
3350
3144
  userId: string
3351
3145
  sessionId: string
@@ -3426,7 +3220,7 @@ async changePassword(input: {
3426
3220
  epoch: updated.authEpoch,
3427
3221
  })
3428
3222
  }
3429
- ~~~
3223
+ ```
3430
3224
 
3431
3225
  revokeSessionByAdmin 只是领域方法。当前项目没有 RBAC,所以不要直接创建一个“任何登录用户都能踢任意 sid”的 Controller。等你有管理员权限 Guard 后,再把这个方法接到管理员接口,并同时使用 StrongAuth。
3432
3226
 
@@ -3434,28 +3228,25 @@ revokeSessionByAdmin 只是领域方法。当前项目没有 RBAC,所以不要
3434
3228
 
3435
3229
  打开:
3436
3230
 
3437
- ~~~text
3231
+ ```text
3438
3232
  apps/server/src/modules/auth/auth.controller.ts
3439
- ~~~
3233
+ ```
3440
3234
 
3441
3235
  增加 import:
3442
3236
 
3443
- ~~~ts
3237
+ ```ts
3444
3238
  import { CurrentAuth } from '@/common/decorators/current-auth.decorator'
3445
3239
  import { StrongAuth } from '@/common/decorators/strong-auth.decorator'
3446
3240
  import type { AuthPrincipal } from './auth.types'
3447
3241
  import { ChangePasswordDto } from './dto/change-password.dto'
3448
- import {
3449
- clearRefreshCookie,
3450
- setRefreshCookie,
3451
- } from './http/refresh-cookie'
3452
- ~~~
3242
+ import { clearRefreshCookie, setRefreshCookie } from './http/refresh-cookie'
3243
+ ```
3453
3244
 
3454
3245
  如果已有 CurrentAuth、AuthPrincipal 或 setRefreshCookie import,请合并,不要重复。
3455
3246
 
3456
3247
  在 Controller 类内增加:
3457
3248
 
3458
- ~~~ts
3249
+ ```ts
3459
3250
  @StrongAuth()
3460
3251
  @ApiBearerAuth('access-token')
3461
3252
  @Post('logout')
@@ -3505,7 +3296,7 @@ async changePassword(
3505
3296
 
3506
3297
  return null
3507
3298
  }
3508
- ~~~
3299
+ ```
3509
3300
 
3510
3301
  改密后当前设备也会失效,因为 accountAuthEpoch 已增加。前端收到成功响应后应清空内存 Access Token,并跳转登录页。
3511
3302
 
@@ -3513,15 +3304,15 @@ async changePassword(
3513
3304
 
3514
3305
  ## 10.4 构建
3515
3306
 
3516
- ~~~powershell
3307
+ ```powershell
3517
3308
  vp run "server#build"
3518
- ~~~
3309
+ ```
3519
3310
 
3520
3311
  ## 10.5 验收单设备退出
3521
3312
 
3522
3313
  分别创建两个 WebSession 并登录同一个账号:
3523
3314
 
3524
- ~~~powershell
3315
+ ```powershell
3525
3316
  $web1 = New-Object Microsoft.PowerShell.Commands.WebRequestSession
3526
3317
  $web2 = New-Object Microsoft.PowerShell.Commands.WebRequestSession
3527
3318
  $body = @{ email = "auth-test@example.com"; password = "Correct-Horse-Battery-123!" } | ConvertTo-Json
@@ -3531,17 +3322,17 @@ $login2 = Invoke-RestMethod -Method Post -Uri "http://localhost:3000/auth/login"
3531
3322
 
3532
3323
  $token1 = $login1.data.accessToken
3533
3324
  $token2 = $login2.data.accessToken
3534
- ~~~
3325
+ ```
3535
3326
 
3536
3327
  退出第一个 Session:
3537
3328
 
3538
- ~~~powershell
3329
+ ```powershell
3539
3330
  Invoke-RestMethod -Method Post -Uri "http://localhost:3000/auth/logout" -Headers @{ Authorization = "Bearer $token1" } -WebSession $web1
3540
- ~~~
3331
+ ```
3541
3332
 
3542
3333
  然后分别调用 me:
3543
3334
 
3544
- ~~~powershell
3335
+ ```powershell
3545
3336
  try {
3546
3337
  Invoke-RestMethod -Uri "http://localhost:3000/auth/me" -Headers @{ Authorization = "Bearer $token1" }
3547
3338
  } catch {
@@ -3549,7 +3340,7 @@ try {
3549
3340
  }
3550
3341
 
3551
3342
  Invoke-RestMethod -Uri "http://localhost:3000/auth/me" -Headers @{ Authorization = "Bearer $token2" }
3552
- ~~~
3343
+ ```
3553
3344
 
3554
3345
  预期:
3555
3346
 
@@ -3562,9 +3353,9 @@ Invoke-RestMethod -Uri "http://localhost:3000/auth/me" -Headers @{ Authorization
3562
3353
 
3563
3354
  使用仍有效的 token2:
3564
3355
 
3565
- ~~~powershell
3356
+ ```powershell
3566
3357
  Invoke-RestMethod -Method Post -Uri "http://localhost:3000/auth/logout-all" -Headers @{ Authorization = "Bearer $token2" } -WebSession $web2
3567
- ~~~
3358
+ ```
3568
3359
 
3569
3360
  预期:
3570
3361
 
@@ -3579,7 +3370,7 @@ Invoke-RestMethod -Method Post -Uri "http://localhost:3000/auth/logout-all" -Hea
3579
3370
 
3580
3371
  先重新登录并明确取得一枚新的 Access Token。不要继续复用步骤 9 或步骤 10.6 中已经被撤销的变量:
3581
3372
 
3582
- ~~~powershell
3373
+ ```powershell
3583
3374
  $webChange = New-Object Microsoft.PowerShell.Commands.WebRequestSession
3584
3375
  $loginBody = @{
3585
3376
  email = "auth-test@example.com"
@@ -3595,7 +3386,7 @@ $changeBody = @{
3595
3386
  } | ConvertTo-Json
3596
3387
 
3597
3388
  Invoke-RestMethod -Method Post -Uri "http://localhost:3000/auth/change-password" -ContentType "application/json" -Body $changeBody -Headers @{ Authorization = "Bearer $accessTokenForChange" } -WebSession $webChange
3598
- ~~~
3389
+ ```
3599
3390
 
3600
3391
  预期:
3601
3392
 
@@ -3661,9 +3452,9 @@ Invoke-RestMethod -Method Post -Uri "http://localhost:3000/auth/change-password"
3661
3452
 
3662
3453
  执行:
3663
3454
 
3664
- ~~~powershell
3455
+ ```powershell
3665
3456
  vp add lru-cache --filter server --save-catalog
3666
- ~~~
3457
+ ```
3667
3458
 
3668
3459
  不要使用无限增长的 Map 作为用户缓存。
3669
3460
 
@@ -3671,13 +3462,13 @@ vp add lru-cache --filter server --save-catalog
3671
3462
 
3672
3463
  把:
3673
3464
 
3674
- ~~~text
3465
+ ```text
3675
3466
  apps/server/src/modules/auth/state/auth-state.service.ts
3676
- ~~~
3467
+ ```
3677
3468
 
3678
3469
  替换成下面完整代码:
3679
3470
 
3680
- ~~~ts
3471
+ ```ts
3681
3472
  import { InjectRedis } from '@nestjs-modules/ioredis'
3682
3473
  import {
3683
3474
  Inject,
@@ -3855,30 +3646,16 @@ function sessionCacheKey(userId: string, sessionId: string) {
3855
3646
  }
3856
3647
 
3857
3648
  function singleFlightKey(claims: AccessTokenClaims) {
3858
- return [
3859
- claims.sub,
3860
- claims.sid,
3861
- String(claims.ae),
3862
- String(claims.sv),
3863
- ].join(':')
3649
+ return [claims.sub, claims.sid, String(claims.ae), String(claims.sv)].join(':')
3864
3650
  }
3865
3651
 
3866
3652
  @Injectable()
3867
3653
  export class AuthStateService {
3868
- private readonly accountCache: LRUCache<
3869
- string,
3870
- CacheEntry<AccountAuthState>
3871
- >
3654
+ private readonly accountCache: LRUCache<string, CacheEntry<AccountAuthState>>
3872
3655
 
3873
- private readonly sessionCache: LRUCache<
3874
- string,
3875
- CacheEntry<SessionAuthState>
3876
- >
3656
+ private readonly sessionCache: LRUCache<string, CacheEntry<SessionAuthState>>
3877
3657
 
3878
- private readonly inFlight = new Map<
3879
- string,
3880
- Promise<AuthStateSnapshot>
3881
- >()
3658
+ private readonly inFlight = new Map<string, Promise<AuthStateSnapshot>>()
3882
3659
 
3883
3660
  private readonly accountPullFences = new Map<string, PullFence>()
3884
3661
  private readonly sessionPullFences = new Map<string, PullFence>()
@@ -3903,10 +3680,7 @@ export class AuthStateService {
3903
3680
  })
3904
3681
  }
3905
3682
 
3906
- async verify(
3907
- claims: AccessTokenClaims,
3908
- consistency: AuthConsistency,
3909
- ) {
3683
+ async verify(claims: AccessTokenClaims, consistency: AuthConsistency) {
3910
3684
  const snapshot =
3911
3685
  consistency === 'strong'
3912
3686
  ? await this.pullSnapshot(claims)
@@ -3920,17 +3694,13 @@ export class AuthStateService {
3920
3694
  const result = await this.projectAccount(state)
3921
3695
 
3922
3696
  if (result === 'stale') {
3923
- throw new ServiceUnavailableException(
3924
- '账号鉴权状态已经存在更高版本',
3925
- )
3697
+ throw new ServiceUnavailableException('账号鉴权状态已经存在更高版本')
3926
3698
  }
3927
3699
 
3928
3700
  const entry = this.mergeAccountCache(state)
3929
3701
 
3930
3702
  if (entry.versionFloor > state.epoch) {
3931
- throw new ServiceUnavailableException(
3932
- '账号鉴权状态正在发生并发变化',
3933
- )
3703
+ throw new ServiceUnavailableException('账号鉴权状态正在发生并发变化')
3934
3704
  }
3935
3705
  } catch (error) {
3936
3706
  this.accountCache.delete(state.userId)
@@ -3945,17 +3715,13 @@ export class AuthStateService {
3945
3715
  const result = await this.projectSession(state)
3946
3716
 
3947
3717
  if (result === 'stale') {
3948
- throw new ServiceUnavailableException(
3949
- 'Session 鉴权状态已经存在更高版本',
3950
- )
3718
+ throw new ServiceUnavailableException('Session 鉴权状态已经存在更高版本')
3951
3719
  }
3952
3720
 
3953
3721
  const entry = this.mergeSessionCache(state)
3954
3722
 
3955
3723
  if (entry.versionFloor > state.version) {
3956
- throw new ServiceUnavailableException(
3957
- 'Session 鉴权状态正在发生并发变化',
3958
- )
3724
+ throw new ServiceUnavailableException('Session 鉴权状态正在发生并发变化')
3959
3725
  }
3960
3726
  } catch (error) {
3961
3727
  this.sessionCache.delete(key)
@@ -3980,11 +3746,7 @@ export class AuthStateService {
3980
3746
  entry.dirty = entry.versionFloor > entry.value.epoch
3981
3747
  }
3982
3748
 
3983
- markSessionDirty(
3984
- userId: string,
3985
- sessionId: string,
3986
- version: number,
3987
- ) {
3749
+ markSessionDirty(userId: string, sessionId: string, version: number) {
3988
3750
  const key = sessionCacheKey(userId, sessionId)
3989
3751
  const fence = this.sessionPullFences.get(key)
3990
3752
 
@@ -4002,27 +3764,15 @@ export class AuthStateService {
4002
3764
  entry.dirty = entry.versionFloor > entry.value.version
4003
3765
  }
4004
3766
 
4005
- private async getBoundedSnapshot(
4006
- claims: AccessTokenClaims,
4007
- ): Promise<AuthStateSnapshot> {
3767
+ private async getBoundedSnapshot(claims: AccessTokenClaims): Promise<AuthStateSnapshot> {
4008
3768
  const account = this.accountCache.get(claims.sub)
4009
- const session = this.sessionCache.get(
4010
- sessionCacheKey(claims.sub, claims.sid),
4011
- )
3769
+ const session = this.sessionCache.get(sessionCacheKey(claims.sub, claims.sid))
4012
3770
 
4013
- if (
4014
- account &&
4015
- (account.value.epoch > claims.ae ||
4016
- account.versionFloor > claims.ae)
4017
- ) {
3771
+ if (account && (account.value.epoch > claims.ae || account.versionFloor > claims.ae)) {
4018
3772
  throw new UnauthorizedException('登录状态已失效')
4019
3773
  }
4020
3774
 
4021
- if (
4022
- session &&
4023
- (session.value.version > claims.sv ||
4024
- session.versionFloor > claims.sv)
4025
- ) {
3775
+ if (session && (session.value.version > claims.sv || session.versionFloor > claims.sv)) {
4026
3776
  throw new UnauthorizedException('登录状态已失效')
4027
3777
  }
4028
3778
 
@@ -4043,9 +3793,7 @@ export class AuthStateService {
4043
3793
  return this.pullSnapshotSingleFlight(claims)
4044
3794
  }
4045
3795
 
4046
- private pullSnapshotSingleFlight(
4047
- claims: AccessTokenClaims,
4048
- ): Promise<AuthStateSnapshot> {
3796
+ private pullSnapshotSingleFlight(claims: AccessTokenClaims): Promise<AuthStateSnapshot> {
4049
3797
  const key = singleFlightKey(claims)
4050
3798
  const existing = this.inFlight.get(key)
4051
3799
 
@@ -4061,13 +3809,8 @@ export class AuthStateService {
4061
3809
  return task
4062
3810
  }
4063
3811
 
4064
- private async pullSnapshot(
4065
- claims: AccessTokenClaims,
4066
- ): Promise<AuthStateSnapshot> {
4067
- const sessionCacheId = sessionCacheKey(
4068
- claims.sub,
4069
- claims.sid,
4070
- )
3812
+ private async pullSnapshot(claims: AccessTokenClaims): Promise<AuthStateSnapshot> {
3813
+ const sessionCacheId = sessionCacheKey(claims.sub, claims.sid)
4071
3814
 
4072
3815
  const accountFence = this.acquireFence(
4073
3816
  this.accountPullFences,
@@ -4083,10 +3826,7 @@ export class AuthStateService {
4083
3826
 
4084
3827
  try {
4085
3828
  for (let attempt = 0; attempt < 2; attempt += 1) {
4086
- const shared = await this.readShared(
4087
- claims.sub,
4088
- claims.sid,
4089
- )
3829
+ const shared = await this.readShared(claims.sub, claims.sid)
4090
3830
 
4091
3831
  const accountEntry = shared.account
4092
3832
  ? this.mergeAccountCache(shared.account)
@@ -4125,43 +3865,31 @@ export class AuthStateService {
4125
3865
  }
4126
3866
  }
4127
3867
 
4128
- const database = await this.loadDatabaseSnapshot(
4129
- claims.sub,
4130
- claims.sid,
4131
- )
3868
+ const database = await this.loadDatabaseSnapshot(claims.sub, claims.sid)
4132
3869
 
4133
3870
  if (!database) {
4134
3871
  throw new UnauthorizedException('登录状态不存在')
4135
3872
  }
4136
3873
 
4137
- if (
4138
- database.account.epoch < claims.ae ||
4139
- database.session.version < claims.sv
4140
- ) {
4141
- throw new ServiceUnavailableException(
4142
- '鉴权事实库版本落后于 JWT',
4143
- )
3874
+ if (database.account.epoch < claims.ae || database.session.version < claims.sv) {
3875
+ throw new ServiceUnavailableException('鉴权事实库版本落后于 JWT')
4144
3876
  }
4145
3877
 
4146
3878
  let accountProjection: ProjectionWriteResult
4147
3879
  let sessionProjection: ProjectionWriteResult
4148
3880
 
4149
3881
  try {
4150
- ;[accountProjection, sessionProjection] =
4151
- await Promise.all([
4152
- this.projectAccount(database.account),
4153
- this.projectSession(database.session),
4154
- ])
3882
+ ;[accountProjection, sessionProjection] = await Promise.all([
3883
+ this.projectAccount(database.account),
3884
+ this.projectSession(database.session),
3885
+ ])
4155
3886
  } catch (error) {
4156
3887
  this.accountCache.delete(claims.sub)
4157
3888
  this.sessionCache.delete(sessionCacheId)
4158
3889
  throw error
4159
3890
  }
4160
3891
 
4161
- if (
4162
- accountProjection === 'stale' ||
4163
- sessionProjection === 'stale'
4164
- ) {
3892
+ if (accountProjection === 'stale' || sessionProjection === 'stale') {
4165
3893
  continue
4166
3894
  }
4167
3895
 
@@ -4189,21 +3917,11 @@ export class AuthStateService {
4189
3917
  }
4190
3918
  }
4191
3919
 
4192
- throw new ServiceUnavailableException(
4193
- '鉴权状态正在变化,请稍后重试',
4194
- )
3920
+ throw new ServiceUnavailableException('鉴权状态正在变化,请稍后重试')
4195
3921
  } finally {
4196
- this.releaseFence(
4197
- this.accountPullFences,
4198
- claims.sub,
4199
- accountFence,
4200
- )
3922
+ this.releaseFence(this.accountPullFences, claims.sub, accountFence)
4201
3923
 
4202
- this.releaseFence(
4203
- this.sessionPullFences,
4204
- sessionCacheId,
4205
- sessionFence,
4206
- )
3924
+ this.releaseFence(this.sessionPullFences, sessionCacheId, sessionFence)
4207
3925
  }
4208
3926
  }
4209
3927
 
@@ -4236,11 +3954,7 @@ export class AuthStateService {
4236
3954
  ] = raw.map((value) => String(value ?? ''))
4237
3955
 
4238
3956
  return {
4239
- account: this.parseAccount(
4240
- userId,
4241
- accountEpoch,
4242
- accountStatus,
4243
- ),
3957
+ account: this.parseAccount(userId, accountEpoch, accountStatus),
4244
3958
  session: this.parseSession(
4245
3959
  userId,
4246
3960
  sessionId,
@@ -4251,9 +3965,7 @@ export class AuthStateService {
4251
3965
  ),
4252
3966
  }
4253
3967
  } catch {
4254
- throw new ServiceUnavailableException(
4255
- '共享鉴权状态暂时不可用',
4256
- )
3968
+ throw new ServiceUnavailableException('共享鉴权状态暂时不可用')
4257
3969
  }
4258
3970
  }
4259
3971
 
@@ -4263,10 +3975,7 @@ export class AuthStateService {
4263
3975
  ): Promise<AuthStateSnapshot | null> {
4264
3976
  const numericUserId = Number(userId)
4265
3977
 
4266
- if (
4267
- !Number.isSafeInteger(numericUserId) ||
4268
- numericUserId <= 0
4269
- ) {
3978
+ if (!Number.isSafeInteger(numericUserId) || numericUserId <= 0) {
4270
3979
  return null
4271
3980
  }
4272
3981
 
@@ -4284,10 +3993,7 @@ export class AuthStateService {
4284
3993
  .from(users)
4285
3994
  .innerJoin(
4286
3995
  authSessions,
4287
- and(
4288
- eq(authSessions.userId, users.id),
4289
- eq(authSessions.id, sessionId),
4290
- ),
3996
+ and(eq(authSessions.userId, users.id), eq(authSessions.id, sessionId)),
4291
3997
  )
4292
3998
  .where(eq(users.id, numericUserId))
4293
3999
  .limit(1)
@@ -4311,15 +4017,11 @@ export class AuthStateService {
4311
4017
  },
4312
4018
  }
4313
4019
  } catch {
4314
- throw new ServiceUnavailableException(
4315
- '鉴权事实库暂时不可用',
4316
- )
4020
+ throw new ServiceUnavailableException('鉴权事实库暂时不可用')
4317
4021
  }
4318
4022
  }
4319
4023
 
4320
- private async projectAccount(
4321
- state: AccountAuthState,
4322
- ): Promise<ProjectionWriteResult> {
4024
+ private async projectAccount(state: AccountAuthState): Promise<ProjectionWriteResult> {
4323
4025
  let raw: unknown
4324
4026
 
4325
4027
  try {
@@ -4331,29 +4033,20 @@ export class AuthStateService {
4331
4033
  state.status,
4332
4034
  )
4333
4035
  } catch {
4334
- throw new ServiceUnavailableException(
4335
- '账号鉴权状态暂时不可用',
4336
- )
4036
+ throw new ServiceUnavailableException('账号鉴权状态暂时不可用')
4337
4037
  }
4338
4038
 
4339
4039
  return this.decodeProjectionResult(raw, '账号')
4340
4040
  }
4341
4041
 
4342
- private async projectSession(
4343
- state: SessionAuthState,
4344
- ): Promise<ProjectionWriteResult> {
4042
+ private async projectSession(state: SessionAuthState): Promise<ProjectionWriteResult> {
4345
4043
  const auth = this.config.getOrThrow<AuthConfig>('auth')
4346
- const activeTtl = Math.ceil(
4347
- (state.expiresAtMs - Date.now()) / 1000,
4348
- )
4044
+ const activeTtl = Math.ceil((state.expiresAtMs - Date.now()) / 1000)
4349
4045
 
4350
4046
  const ttlSeconds =
4351
4047
  state.status === 'revoked'
4352
4048
  ? auth.tombstoneTtlSeconds
4353
- : Math.max(
4354
- activeTtl,
4355
- auth.tombstoneTtlSeconds,
4356
- )
4049
+ : Math.max(activeTtl, auth.tombstoneTtlSeconds)
4357
4050
 
4358
4051
  let raw: unknown
4359
4052
 
@@ -4369,18 +4062,13 @@ export class AuthStateService {
4369
4062
  String(ttlSeconds),
4370
4063
  )
4371
4064
  } catch {
4372
- throw new ServiceUnavailableException(
4373
- 'Session 鉴权状态暂时不可用',
4374
- )
4065
+ throw new ServiceUnavailableException('Session 鉴权状态暂时不可用')
4375
4066
  }
4376
4067
 
4377
4068
  return this.decodeProjectionResult(raw, 'Session')
4378
4069
  }
4379
4070
 
4380
- private decodeProjectionResult(
4381
- raw: unknown,
4382
- aggregateName: string,
4383
- ): ProjectionWriteResult {
4071
+ private decodeProjectionResult(raw: unknown, aggregateName: string): ProjectionWriteResult {
4384
4072
  const code = Number(raw)
4385
4073
 
4386
4074
  if (code === 1) {
@@ -4395,9 +4083,7 @@ export class AuthStateService {
4395
4083
  return 'stale'
4396
4084
  }
4397
4085
 
4398
- throw new ServiceUnavailableException(
4399
- aggregateName + '鉴权状态存在版本冲突',
4400
- )
4086
+ throw new ServiceUnavailableException(aggregateName + '鉴权状态存在版本冲突')
4401
4087
  }
4402
4088
 
4403
4089
  private mergeSnapshot(snapshot: AuthStateSnapshot) {
@@ -4407,21 +4093,13 @@ export class AuthStateService {
4407
4093
  }
4408
4094
  }
4409
4095
 
4410
- private mergeAccountCache(
4411
- state: AccountAuthState,
4412
- ): CacheEntry<AccountAuthState> {
4096
+ private mergeAccountCache(state: AccountAuthState): CacheEntry<AccountAuthState> {
4413
4097
  const previous = this.accountCache.peek(state.userId)
4414
- const fenceFloor =
4415
- this.accountPullFences.get(state.userId)?.versionFloor ?? 0
4098
+ const fenceFloor = this.accountPullFences.get(state.userId)?.versionFloor ?? 0
4416
4099
 
4417
4100
  if (previous && previous.value.epoch > state.epoch) {
4418
- previous.versionFloor = Math.max(
4419
- previous.versionFloor,
4420
- previous.value.epoch,
4421
- fenceFloor,
4422
- )
4423
- previous.dirty =
4424
- previous.versionFloor > previous.value.epoch
4101
+ previous.versionFloor = Math.max(previous.versionFloor, previous.value.epoch, fenceFloor)
4102
+ previous.dirty = previous.versionFloor > previous.value.epoch
4425
4103
 
4426
4104
  return previous
4427
4105
  }
@@ -4429,21 +4107,14 @@ export class AuthStateService {
4429
4107
  if (
4430
4108
  previous &&
4431
4109
  previous.value.epoch === state.epoch &&
4432
- (previous.value.userId !== state.userId ||
4433
- previous.value.status !== state.status)
4110
+ (previous.value.userId !== state.userId || previous.value.status !== state.status)
4434
4111
  ) {
4435
4112
  this.accountCache.delete(state.userId)
4436
4113
 
4437
- throw new ServiceUnavailableException(
4438
- 'L1 账号状态出现同版本冲突',
4439
- )
4114
+ throw new ServiceUnavailableException('L1 账号状态出现同版本冲突')
4440
4115
  }
4441
4116
 
4442
- const versionFloor = Math.max(
4443
- previous?.versionFloor ?? 0,
4444
- fenceFloor,
4445
- state.epoch,
4446
- )
4117
+ const versionFloor = Math.max(previous?.versionFloor ?? 0, fenceFloor, state.epoch)
4447
4118
 
4448
4119
  const entry: CacheEntry<AccountAuthState> = {
4449
4120
  value: state,
@@ -4455,26 +4126,15 @@ export class AuthStateService {
4455
4126
  return entry
4456
4127
  }
4457
4128
 
4458
- private mergeSessionCache(
4459
- state: SessionAuthState,
4460
- ): CacheEntry<SessionAuthState> {
4461
- const key = sessionCacheKey(
4462
- state.userId,
4463
- state.sessionId,
4464
- )
4129
+ private mergeSessionCache(state: SessionAuthState): CacheEntry<SessionAuthState> {
4130
+ const key = sessionCacheKey(state.userId, state.sessionId)
4465
4131
 
4466
4132
  const previous = this.sessionCache.peek(key)
4467
- const fenceFloor =
4468
- this.sessionPullFences.get(key)?.versionFloor ?? 0
4133
+ const fenceFloor = this.sessionPullFences.get(key)?.versionFloor ?? 0
4469
4134
 
4470
4135
  if (previous && previous.value.version > state.version) {
4471
- previous.versionFloor = Math.max(
4472
- previous.versionFloor,
4473
- previous.value.version,
4474
- fenceFloor,
4475
- )
4476
- previous.dirty =
4477
- previous.versionFloor > previous.value.version
4136
+ previous.versionFloor = Math.max(previous.versionFloor, previous.value.version, fenceFloor)
4137
+ previous.dirty = previous.versionFloor > previous.value.version
4478
4138
 
4479
4139
  return previous
4480
4140
  }
@@ -4489,27 +4149,16 @@ export class AuthStateService {
4489
4149
  ) {
4490
4150
  this.sessionCache.delete(key)
4491
4151
 
4492
- throw new ServiceUnavailableException(
4493
- 'L1 Session 状态出现同版本冲突',
4494
- )
4152
+ throw new ServiceUnavailableException('L1 Session 状态出现同版本冲突')
4495
4153
  }
4496
4154
 
4497
- if (
4498
- previous?.value.status === 'revoked' &&
4499
- state.status === 'active'
4500
- ) {
4155
+ if (previous?.value.status === 'revoked' && state.status === 'active') {
4501
4156
  this.sessionCache.delete(key)
4502
4157
 
4503
- throw new ServiceUnavailableException(
4504
- '禁止恢复已经撤销的 Session',
4505
- )
4158
+ throw new ServiceUnavailableException('禁止恢复已经撤销的 Session')
4506
4159
  }
4507
4160
 
4508
- const versionFloor = Math.max(
4509
- previous?.versionFloor ?? 0,
4510
- fenceFloor,
4511
- state.version,
4512
- )
4161
+ const versionFloor = Math.max(previous?.versionFloor ?? 0, fenceFloor, state.version)
4513
4162
 
4514
4163
  const entry: CacheEntry<SessionAuthState> = {
4515
4164
  value: state,
@@ -4528,20 +4177,11 @@ export class AuthStateService {
4528
4177
  accountFence: PullFence,
4529
4178
  sessionFence: PullFence,
4530
4179
  ) {
4531
- const accountFloor = Math.max(
4532
- account?.versionFloor ?? 0,
4533
- accountFence.versionFloor,
4534
- )
4180
+ const accountFloor = Math.max(account?.versionFloor ?? 0, accountFence.versionFloor)
4535
4181
 
4536
- const sessionFloor = Math.max(
4537
- session?.versionFloor ?? 0,
4538
- sessionFence.versionFloor,
4539
- )
4182
+ const sessionFloor = Math.max(session?.versionFloor ?? 0, sessionFence.versionFloor)
4540
4183
 
4541
- if (
4542
- accountFloor > claims.ae ||
4543
- sessionFloor > claims.sv
4544
- ) {
4184
+ if (accountFloor > claims.ae || sessionFloor > claims.sv) {
4545
4185
  throw new UnauthorizedException('登录状态已失效')
4546
4186
  }
4547
4187
  }
@@ -4554,15 +4194,9 @@ export class AuthStateService {
4554
4194
  accountFence: PullFence,
4555
4195
  sessionFence: PullFence,
4556
4196
  ) {
4557
- const accountFloor = Math.max(
4558
- account?.versionFloor ?? 0,
4559
- accountFence.versionFloor,
4560
- )
4197
+ const accountFloor = Math.max(account?.versionFloor ?? 0, accountFence.versionFloor)
4561
4198
 
4562
- const sessionFloor = Math.max(
4563
- session?.versionFloor ?? 0,
4564
- sessionFence.versionFloor,
4565
- )
4199
+ const sessionFloor = Math.max(session?.versionFloor ?? 0, sessionFence.versionFloor)
4566
4200
 
4567
4201
  return (
4568
4202
  snapshot.account.epoch >= claims.ae &&
@@ -4572,19 +4206,12 @@ export class AuthStateService {
4572
4206
  )
4573
4207
  }
4574
4208
 
4575
- private acquireFence(
4576
- map: Map<string, PullFence>,
4577
- key: string,
4578
- initialFloor: number,
4579
- ) {
4209
+ private acquireFence(map: Map<string, PullFence>, key: string, initialFloor: number) {
4580
4210
  const existing = map.get(key)
4581
4211
 
4582
4212
  if (existing) {
4583
4213
  existing.references += 1
4584
- existing.versionFloor = Math.max(
4585
- existing.versionFloor,
4586
- initialFloor,
4587
- )
4214
+ existing.versionFloor = Math.max(existing.versionFloor, initialFloor)
4588
4215
 
4589
4216
  return existing
4590
4217
  }
@@ -4598,11 +4225,7 @@ export class AuthStateService {
4598
4225
  return fence
4599
4226
  }
4600
4227
 
4601
- private releaseFence(
4602
- map: Map<string, PullFence>,
4603
- key: string,
4604
- fence: PullFence,
4605
- ) {
4228
+ private releaseFence(map: Map<string, PullFence>, key: string, fence: PullFence) {
4606
4229
  const current = map.get(key)
4607
4230
 
4608
4231
  if (current !== fence) {
@@ -4669,17 +4292,9 @@ export class AuthStateService {
4669
4292
  }
4670
4293
  }
4671
4294
 
4672
- private assertAllowed(
4673
- snapshot: AuthStateSnapshot,
4674
- claims: AccessTokenClaims,
4675
- ) {
4676
- if (
4677
- snapshot.account.epoch < claims.ae ||
4678
- snapshot.session.version < claims.sv
4679
- ) {
4680
- throw new ServiceUnavailableException(
4681
- '鉴权状态投影落后',
4682
- )
4295
+ private assertAllowed(snapshot: AuthStateSnapshot, claims: AccessTokenClaims) {
4296
+ if (snapshot.account.epoch < claims.ae || snapshot.session.version < claims.sv) {
4297
+ throw new ServiceUnavailableException('鉴权状态投影落后')
4683
4298
  }
4684
4299
 
4685
4300
  const accepted =
@@ -4695,7 +4310,7 @@ export class AuthStateService {
4695
4310
  }
4696
4311
  }
4697
4312
  }
4698
- ~~~
4313
+ ```
4699
4314
 
4700
4315
  这版缓存代码必须保留以下行为:
4701
4316
 
@@ -4721,21 +4336,21 @@ export class AuthStateService {
4721
4336
 
4722
4337
  修改:
4723
4338
 
4724
- ~~~text
4339
+ ```text
4725
4340
  apps/server/src/modules/auth/guards/hybrid-auth.guard.ts
4726
- ~~~
4341
+ ```
4727
4342
 
4728
4343
  找到:
4729
4344
 
4730
- ~~~ts
4345
+ ```ts
4731
4346
  ) ?? 'strong'
4732
- ~~~
4347
+ ```
4733
4348
 
4734
4349
  改成:
4735
4350
 
4736
- ~~~ts
4351
+ ```ts
4737
4352
  ) ?? 'bounded'
4738
- ~~~
4353
+ ```
4739
4354
 
4740
4355
  现在规则变成:
4741
4356
 
@@ -4745,22 +4360,22 @@ apps/server/src/modules/auth/guards/hybrid-auth.guard.ts
4745
4360
 
4746
4361
  建议:
4747
4362
 
4748
- | 接口 | 模式 |
4749
- | --- | --- |
4750
- | 普通列表、详情、搜索 | bounded |
4751
- | 修改密码 | strong |
4752
- | 退出全部设备 | strong |
4753
- | 管理员踢人 | strong |
4363
+ | 接口 | 模式 |
4364
+ | -------------------- | -------------------------- |
4365
+ | 普通列表、详情、搜索 | bounded |
4366
+ | 修改密码 | strong |
4367
+ | 退出全部设备 | strong |
4368
+ | 管理员踢人 | strong |
4754
4369
  | 付款、提现、绑定密钥 | strong,并考虑 Recent Auth |
4755
- | 登录、注册、刷新 | Public |
4370
+ | 登录、注册、刷新 | Public |
4756
4371
 
4757
4372
  写接口不一定全部 strong,但只要操作会扩大权限、转移资产或修改认证状态,就应该 strong。
4758
4373
 
4759
4374
  ## 11.4 构建
4760
4375
 
4761
- ~~~powershell
4376
+ ```powershell
4762
4377
  vp run "server#build"
4763
- ~~~
4378
+ ```
4764
4379
 
4765
4380
  ## 11.5 验收 bounded 延迟
4766
4381
 
@@ -4768,21 +4383,21 @@ vp run "server#build"
4768
4383
 
4769
4384
  终端 A:
4770
4385
 
4771
- ~~~powershell
4386
+ ```powershell
4772
4387
  $env:PORT = "3000"
4773
4388
  vp run "server#dev"
4774
- ~~~
4389
+ ```
4775
4390
 
4776
4391
  终端 B:
4777
4392
 
4778
- ~~~powershell
4393
+ ```powershell
4779
4394
  $env:PORT = "3001"
4780
4395
  vp run "server#dev"
4781
- ~~~
4396
+ ```
4782
4397
 
4783
4398
  另开第三个 PowerShell,按下面的命令创建 Session,并先调用 A 的普通 me,让 A 写入 L1:
4784
4399
 
4785
- ~~~powershell
4400
+ ```powershell
4786
4401
  $boundedWeb = New-Object Microsoft.PowerShell.Commands.WebRequestSession
4787
4402
  $boundedBody = @{
4788
4403
  email = "auth-test@example.com"
@@ -4793,27 +4408,27 @@ $boundedLogin = Invoke-RestMethod -Method Post -Uri "http://localhost:3000/auth/
4793
4408
  $boundedToken = $boundedLogin.data.accessToken
4794
4409
 
4795
4410
  Invoke-RestMethod -Uri "http://localhost:3000/auth/me" -Headers @{ Authorization = "Bearer $boundedToken" }
4796
- ~~~
4411
+ ```
4797
4412
 
4798
4413
  然后让实例 B 使用同一枚 JWT 撤销这个 Session:
4799
4414
 
4800
- ~~~powershell
4415
+ ```powershell
4801
4416
  Invoke-RestMethod -Method Post -Uri "http://localhost:3001/auth/logout" -Headers @{ Authorization = "Bearer $boundedToken" } -WebSession $boundedWeb
4802
- ~~~
4417
+ ```
4803
4418
 
4804
4419
  因为此时还没有 Pub/Sub,立刻请求 A 时可能仍然成功:
4805
4420
 
4806
- ~~~powershell
4421
+ ```powershell
4807
4422
  try {
4808
4423
  Invoke-RestMethod -Uri "http://localhost:3000/auth/me" -Headers @{ Authorization = "Bearer $boundedToken" }
4809
4424
  } catch {
4810
4425
  $_.Exception.Response.StatusCode.value__
4811
4426
  }
4812
- ~~~
4427
+ ```
4813
4428
 
4814
4429
  等待一个完整 TTL 加少量余量后再次请求:
4815
4430
 
4816
- ~~~powershell
4431
+ ```powershell
4817
4432
  Start-Sleep -Seconds 32
4818
4433
 
4819
4434
  try {
@@ -4821,7 +4436,7 @@ try {
4821
4436
  } catch {
4822
4437
  $_.Exception.Response.StatusCode.value__
4823
4438
  }
4824
- ~~~
4439
+ ```
4825
4440
 
4826
4441
  第二次必须得到 401。如果 Redis 写入失败、Outbox 尚在恢复或本机调度发生明显暂停,收敛时间还会叠加对应延迟,因此生产监控应记录实际撤销传播时间,不能只观察配置里的 30_000。
4827
4442
 
@@ -4855,7 +4470,7 @@ try {
4855
4470
 
4856
4471
  流程是:
4857
4472
 
4858
- ~~~text
4473
+ ```text
4859
4474
  状态修改服务
4860
4475
  -> PostgreSQL 提交
4861
4476
  -> 同步写 Redis Auth State
@@ -4866,7 +4481,7 @@ try {
4866
4481
  -> 如果本地根本没缓存该用户,忽略
4867
4482
  -> 如果本地有旧缓存,标 dirty 和 versionFloor
4868
4483
  -> 下一个请求再 Pull
4869
- ~~~
4484
+ ```
4870
4485
 
4871
4486
  这就是推拉结合。Push 只传失效提示,Pull 才拿真实状态。
4872
4487
 
@@ -4874,13 +4489,13 @@ try {
4874
4489
 
4875
4490
  新增:
4876
4491
 
4877
- ~~~text
4492
+ ```text
4878
4493
  apps/server/src/modules/auth/state/auth-state.events.ts
4879
- ~~~
4494
+ ```
4880
4495
 
4881
4496
  粘贴:
4882
4497
 
4883
- ~~~ts
4498
+ ```ts
4884
4499
  export type AuthStateInvalidationEvent =
4885
4500
  | {
4886
4501
  type: 'account.changed'
@@ -4893,7 +4508,7 @@ export type AuthStateInvalidationEvent =
4893
4508
  sessionId: string
4894
4509
  version: number
4895
4510
  }
4896
- ~~~
4511
+ ```
4897
4512
 
4898
4513
  事件不携带密码、Token、完整用户资料或权限列表。
4899
4514
 
@@ -4901,22 +4516,19 @@ export type AuthStateInvalidationEvent =
4901
4516
 
4902
4517
  新增:
4903
4518
 
4904
- ~~~text
4519
+ ```text
4905
4520
  apps/server/src/modules/auth/state/auth-state.publisher.ts
4906
- ~~~
4521
+ ```
4907
4522
 
4908
4523
  粘贴:
4909
4524
 
4910
- ~~~ts
4525
+ ```ts
4911
4526
  import { InjectRedis } from '@nestjs-modules/ioredis'
4912
4527
  import { Injectable, Logger } from '@nestjs/common'
4913
4528
  import { ConfigService } from '@nestjs/config'
4914
4529
  import type Redis from 'ioredis'
4915
4530
  import type { AuthConfig } from '@/config/auth.config'
4916
- import type {
4917
- AccountAuthState,
4918
- SessionAuthState,
4919
- } from '../auth.types'
4531
+ import type { AccountAuthState, SessionAuthState } from '../auth.types'
4920
4532
  import type { AuthStateInvalidationEvent } from './auth-state.events'
4921
4533
 
4922
4534
  @Injectable()
@@ -4949,39 +4561,29 @@ export class AuthStatePublisher {
4949
4561
  const auth = this.config.getOrThrow<AuthConfig>('auth')
4950
4562
 
4951
4563
  try {
4952
- await this.redis.publish(
4953
- auth.eventChannel,
4954
- JSON.stringify(event),
4955
- )
4564
+ await this.redis.publish(auth.eventChannel, JSON.stringify(event))
4956
4565
  } catch (error) {
4957
4566
  // Redis Auth State 已经同步写入,所以 Pub/Sub 失败不回滚业务。
4958
4567
  // 步骤 13 的 Outbox Worker 会负责重试发布。
4959
- this.logger.warn(
4960
- 'Auth State 失效事件发布失败:' + String(error),
4961
- )
4568
+ this.logger.warn('Auth State 失效事件发布失败:' + String(error))
4962
4569
  }
4963
4570
  }
4964
4571
  }
4965
- ~~~
4572
+ ```
4966
4573
 
4967
4574
  ## 12.3 新增 Subscriber
4968
4575
 
4969
4576
  新增:
4970
4577
 
4971
- ~~~text
4578
+ ```text
4972
4579
  apps/server/src/modules/auth/state/auth-state.subscriber.ts
4973
- ~~~
4580
+ ```
4974
4581
 
4975
4582
  粘贴:
4976
4583
 
4977
- ~~~ts
4584
+ ```ts
4978
4585
  import { InjectRedis } from '@nestjs-modules/ioredis'
4979
- import {
4980
- Injectable,
4981
- Logger,
4982
- type OnModuleDestroy,
4983
- type OnModuleInit,
4984
- } from '@nestjs/common'
4586
+ import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common'
4985
4587
  import { ConfigService } from '@nestjs/config'
4986
4588
  import type Redis from 'ioredis'
4987
4589
  import type { AuthConfig } from '@/config/auth.config'
@@ -4989,9 +4591,7 @@ import type { AuthStateInvalidationEvent } from './auth-state.events'
4989
4591
  import { AuthStateService } from './auth-state.service'
4990
4592
 
4991
4593
  @Injectable()
4992
- export class AuthStateSubscriber
4993
- implements OnModuleInit, OnModuleDestroy
4994
- {
4594
+ export class AuthStateSubscriber implements OnModuleInit, OnModuleDestroy {
4995
4595
  private readonly logger = new Logger(AuthStateSubscriber.name)
4996
4596
  private readonly subscriber: Redis
4997
4597
 
@@ -5031,10 +4631,7 @@ export class AuthStateSubscriber
5031
4631
  typeof event.userId === 'string' &&
5032
4632
  Number.isSafeInteger(event.version)
5033
4633
  ) {
5034
- this.authState.markAccountDirty(
5035
- event.userId,
5036
- event.version,
5037
- )
4634
+ this.authState.markAccountDirty(event.userId, event.version)
5038
4635
  return
5039
4636
  }
5040
4637
 
@@ -5044,18 +4641,14 @@ export class AuthStateSubscriber
5044
4641
  typeof event.sessionId === 'string' &&
5045
4642
  Number.isSafeInteger(event.version)
5046
4643
  ) {
5047
- this.authState.markSessionDirty(
5048
- event.userId,
5049
- event.sessionId,
5050
- event.version,
5051
- )
4644
+ this.authState.markSessionDirty(event.userId, event.sessionId, event.version)
5052
4645
  }
5053
4646
  } catch (error) {
5054
4647
  this.logger.warn('忽略非法 Auth State 事件:' + String(error))
5055
4648
  }
5056
4649
  }
5057
4650
  }
5058
- ~~~
4651
+ ```
5059
4652
 
5060
4653
  Subscriber 必须使用 redis.duplicate。不能把普通命令连接直接拿去 subscribe。
5061
4654
 
@@ -5063,97 +4656,97 @@ Subscriber 必须使用 redis.duplicate。不能把普通命令连接直接拿
5063
4656
 
5064
4657
  修改:
5065
4658
 
5066
- ~~~text
4659
+ ```text
5067
4660
  apps/server/src/modules/auth/auth.module.ts
5068
- ~~~
4661
+ ```
5069
4662
 
5070
4663
  增加 import:
5071
4664
 
5072
- ~~~ts
4665
+ ```ts
5073
4666
  import { AuthStatePublisher } from './state/auth-state.publisher'
5074
4667
  import { AuthStateSubscriber } from './state/auth-state.subscriber'
5075
- ~~~
4668
+ ```
5076
4669
 
5077
4670
  在 providers 中增加:
5078
4671
 
5079
- ~~~ts
4672
+ ```ts
5080
4673
  AuthStatePublisher,
5081
4674
  AuthStateSubscriber,
5082
- ~~~
4675
+ ```
5083
4676
 
5084
4677
  ## 12.5 让状态修改后发布事件
5085
4678
 
5086
4679
  修改:
5087
4680
 
5088
- ~~~text
4681
+ ```text
5089
4682
  apps/server/src/modules/auth/auth.service.ts
5090
- ~~~
4683
+ ```
5091
4684
 
5092
4685
  增加 import:
5093
4686
 
5094
- ~~~ts
4687
+ ```ts
5095
4688
  import { AuthStatePublisher } from './state/auth-state.publisher'
5096
- ~~~
4689
+ ```
5097
4690
 
5098
4691
  在 constructor 增加依赖:
5099
4692
 
5100
- ~~~ts
4693
+ ```ts
5101
4694
  private readonly authEvents: AuthStatePublisher,
5102
- ~~~
4695
+ ```
5103
4696
 
5104
4697
  然后完成以下机械修改。
5105
4698
 
5106
4699
  Refresh 重放分支中,在 writeThroughSession 后增加:
5107
4700
 
5108
- ~~~ts
4701
+ ```ts
5109
4702
  await this.authEvents.sessionChanged(outcome.session)
5110
- ~~~
4703
+ ```
5111
4704
 
5112
4705
  logoutCurrent 中改为:
5113
4706
 
5114
- ~~~ts
4707
+ ```ts
5115
4708
  if (state) {
5116
4709
  await this.authState.writeThroughSession(state)
5117
4710
  await this.authEvents.sessionChanged(state)
5118
4711
  }
5119
- ~~~
4712
+ ```
5120
4713
 
5121
4714
  revokeSessionByAdmin 中改为:
5122
4715
 
5123
- ~~~ts
4716
+ ```ts
5124
4717
  if (state) {
5125
4718
  await this.authState.writeThroughSession(state)
5126
4719
  await this.authEvents.sessionChanged(state)
5127
4720
  }
5128
- ~~~
4721
+ ```
5129
4722
 
5130
4723
  logoutAll 在 writeThroughAccount 后增加:
5131
4724
 
5132
- ~~~ts
4725
+ ```ts
5133
4726
  await this.authEvents.accountChanged({
5134
4727
  userId,
5135
4728
  status: account.status,
5136
4729
  epoch: account.authEpoch,
5137
4730
  })
5138
- ~~~
4731
+ ```
5139
4732
 
5140
4733
  changePassword 在 writeThroughAccount 后增加:
5141
4734
 
5142
- ~~~ts
4735
+ ```ts
5143
4736
  await this.authEvents.accountChanged({
5144
4737
  userId: input.userId,
5145
4738
  status: updated.status,
5146
4739
  epoch: updated.authEpoch,
5147
4740
  })
5148
- ~~~
4741
+ ```
5149
4742
 
5150
4743
  登录创建新 Session 时可以不发布,因为其他实例通常没有这个新 sid 的缓存。即使发布,没有缓存的实例也会忽略。
5151
4744
 
5152
4745
  ## 12.6 构建
5153
4746
 
5154
- ~~~powershell
4747
+ ```powershell
5155
4748
  vp run "server#build"
5156
- ~~~
4749
+ ```
5157
4750
 
5158
4751
  ## 12.7 两实例验收 Push/Pull
5159
4752
 
@@ -5198,11 +4791,11 @@ vp run "server#build"
5198
4791
 
5199
4792
  步骤 12 的快速路径是:
5200
4793
 
5201
- ~~~text
4794
+ ```text
5202
4795
  PostgreSQL COMMIT
5203
4796
  -> 写 Redis
5204
4797
  -> publish
5205
- ~~~
4798
+ ```
5206
4799
 
5207
4800
  如果进程恰好在 COMMIT 后、写 Redis 前崩溃,数据库已经撤销 Session,但 Redis 仍可能是旧状态。
5208
4801
 
@@ -5212,19 +4805,19 @@ Outbox 保证最终恢复,不代表魔法般的跨 PostgreSQL 和 Redis 单事
5212
4805
 
5213
4806
  安全命令仍遵循:
5214
4807
 
5215
- ~~~text
4808
+ ```text
5216
4809
  1. PostgreSQL 事务更新状态并写 Outbox
5217
4810
  2. 提交后同步投影 Redis
5218
4811
  3. Redis 确认后,安全命令才向调用方报告成功
5219
4812
  4. Pub/Sub 可以 best effort
5220
4813
  5. Outbox Worker 负责崩溃恢复和重试
5221
- ~~~
4814
+ ```
5222
4815
 
5223
4816
  ## 13.1 安装调度依赖
5224
4817
 
5225
- ~~~powershell
4818
+ ```powershell
5226
4819
  vp add @nestjs/schedule --filter server --save-catalog
5227
- ~~~
4820
+ ```
5228
4821
 
5229
4822
  auth_outbox 表已经在步骤 4 创建,不需要再新增表。
5230
4823
 
@@ -5236,13 +4829,13 @@ Outbox 不能只保存 aggregateId。否则数据库行以后被物理删除,W
5236
4829
 
5237
4830
  修改:
5238
4831
 
5239
- ~~~text
4832
+ ```text
5240
4833
  apps/server/src/modules/auth/auth.types.ts
5241
- ~~~
4834
+ ```
5242
4835
 
5243
4836
  在文件末尾增加:
5244
4837
 
5245
- ~~~ts
4838
+ ```ts
5246
4839
  export interface AccountChangedOutboxPayload {
5247
4840
  schemaVersion: 1
5248
4841
  state: AccountAuthState
@@ -5252,7 +4845,7 @@ export interface SessionChangedOutboxPayload {
5252
4845
  schemaVersion: 1
5253
4846
  state: SessionAuthState
5254
4847
  }
5255
- ~~~
4848
+ ```
5256
4849
 
5257
4850
  payload 字段在 schema 中仍然是 unknown。上面的接口用于写入端检查;Worker 读取 JSONB 时仍必须做运行时校验。
5258
4851
 
@@ -5260,13 +4853,13 @@ payload 字段在 schema 中仍然是 unknown。上面的接口用于写入端
5260
4853
 
5261
4854
  修改:
5262
4855
 
5263
- ~~~text
4856
+ ```text
5264
4857
  apps/server/src/modules/auth/auth.repository.ts
5265
- ~~~
4858
+ ```
5266
4859
 
5267
4860
  在 schema import 中加入 authOutbox:
5268
4861
 
5269
- ~~~ts
4862
+ ```ts
5270
4863
  import {
5271
4864
  authOutbox,
5272
4865
  authSessions,
@@ -5274,24 +4867,24 @@ import {
5274
4867
  refreshTokens,
5275
4868
  users,
5276
4869
  } from '@/database/schema'
5277
- ~~~
4870
+ ```
5278
4871
 
5279
4872
  再增加类型 import:
5280
4873
 
5281
- ~~~ts
4874
+ ```ts
5282
4875
  import type {
5283
4876
  AccountAuthState,
5284
4877
  AccountChangedOutboxPayload,
5285
4878
  SessionAuthState,
5286
4879
  SessionChangedOutboxPayload,
5287
4880
  } from './auth.types'
5288
- ~~~
4881
+ ```
5289
4882
 
5290
4883
  ### 13.2.3 Refresh 重放事务写 Session tombstone
5291
4884
 
5292
4885
  在 rotateRefreshToken 的 consumed 分支中,updatedSession 更新成功后、return reuse 之前增加:
5293
4886
 
5294
- ~~~ts
4887
+ ```ts
5295
4888
  if (updatedSession) {
5296
4889
  const state: SessionAuthState = {
5297
4890
  sessionId: updatedSession.id,
@@ -5312,7 +4905,7 @@ if (updatedSession) {
5312
4905
  } satisfies SessionChangedOutboxPayload,
5313
4906
  })
5314
4907
  }
5315
- ~~~
4908
+ ```
5316
4909
 
5317
4910
  只有本事务真的完成 active -> revoked 时才写 Outbox。Session 之前已经 revoked 时,不增加 version,也不制造重复状态事件。
5318
4911
 
@@ -5320,7 +4913,7 @@ if (updatedSession) {
5320
4913
 
5321
4914
  在 revokeSession 的 session.status === active 分支中,得到 updatedSession 并把 version 赋值后增加:
5322
4915
 
5323
- ~~~ts
4916
+ ```ts
5324
4917
  const state: SessionAuthState = {
5325
4918
  sessionId: updatedSession.id,
5326
4919
  userId: String(updatedSession.userId),
@@ -5339,7 +4932,7 @@ await tx.insert(authOutbox).values({
5339
4932
  state,
5340
4933
  } satisfies SessionChangedOutboxPayload,
5341
4934
  })
5342
- ~~~
4935
+ ```
5343
4936
 
5344
4937
  这段 insert 和 Session 撤销必须在同一个 tx 回调内。
5345
4938
 
@@ -5347,7 +4940,7 @@ await tx.insert(authOutbox).values({
5347
4940
 
5348
4941
  在 bumpAccountEpoch 中得到 updated 后、return updated 前增加:
5349
4942
 
5350
- ~~~ts
4943
+ ```ts
5351
4944
  const state: AccountAuthState = {
5352
4945
  userId: String(updated.id),
5353
4946
  status: updated.status,
@@ -5364,13 +4957,13 @@ await tx.insert(authOutbox).values({
5364
4957
  state,
5365
4958
  } satisfies AccountChangedOutboxPayload,
5366
4959
  })
5367
- ~~~
4960
+ ```
5368
4961
 
5369
4962
  ### 13.2.6 改密写 Account 快照
5370
4963
 
5371
4964
  在 changePassword 中得到 updated 后、return updated 前增加完全相同的账号事件:
5372
4965
 
5373
- ~~~ts
4966
+ ```ts
5374
4967
  const state: AccountAuthState = {
5375
4968
  userId: String(updated.id),
5376
4969
  status: updated.status,
@@ -5387,7 +4980,7 @@ await tx.insert(authOutbox).values({
5387
4980
  state,
5388
4981
  } satisfies AccountChangedOutboxPayload,
5389
4982
  })
5390
- ~~~
4983
+ ```
5391
4984
 
5392
4985
  正常 Refresh 只增加 refreshGeneration,不让 Access JWT 失效,所以不写 Auth State Outbox。
5393
4986
 
@@ -5397,29 +4990,19 @@ await tx.insert(authOutbox).values({
5397
4990
 
5398
4991
  修改:
5399
4992
 
5400
- ~~~text
4993
+ ```text
5401
4994
  apps/server/src/modules/auth/auth.repository.ts
5402
- ~~~
4995
+ ```
5403
4996
 
5404
4997
  把 drizzle-orm import 扩展为下面这个完整列表。不要漏掉步骤 9 advisory lock 使用的 sql:
5405
4998
 
5406
- ~~~ts
5407
- import {
5408
- and,
5409
- asc,
5410
- eq,
5411
- inArray,
5412
- isNull,
5413
- lt,
5414
- lte,
5415
- or,
5416
- sql,
5417
- } from 'drizzle-orm'
5418
- ~~~
4999
+ ```ts
5000
+ import { and, asc, eq, inArray, isNull, lt, lte, or, sql } from 'drizzle-orm'
5001
+ ```
5419
5002
 
5420
5003
  在 AuthRepository 类中增加:
5421
5004
 
5422
- ~~~ts
5005
+ ```ts
5423
5006
  findSessionById(sessionId: string) {
5424
5007
  return this.db.query.authSessions.findFirst({
5425
5008
  where: eq(authSessions.id, sessionId),
@@ -5541,7 +5124,7 @@ async releaseOutboxForRetry(input: {
5541
5124
  )
5542
5125
  }
5543
5126
  }
5544
- ~~~
5127
+ ```
5545
5128
 
5546
5129
  这里的并发协议是:
5547
5130
 
@@ -5558,13 +5141,13 @@ Outbox 仍按“至少一次”处理。投影和 publish 都必须幂等,不
5558
5141
 
5559
5142
  修改:
5560
5143
 
5561
- ~~~text
5144
+ ```text
5562
5145
  apps/server/src/modules/auth/state/auth-state.publisher.ts
5563
- ~~~
5146
+ ```
5564
5147
 
5565
5148
  把 accountChanged、sessionChanged 和 publish 三个方法替换成:
5566
5149
 
5567
- ~~~ts
5150
+ ```ts
5568
5151
  accountChanged(
5569
5152
  state: AccountAuthState,
5570
5153
  bestEffort = true,
@@ -5615,7 +5198,7 @@ private async publish(
5615
5198
  }
5616
5199
  }
5617
5200
  }
5618
- ~~~
5201
+ ```
5619
5202
 
5620
5203
  AuthService 仍使用默认 bestEffort。Outbox Worker 会传 false,只有 publish 成功后才标记 published。
5621
5204
 
@@ -5623,116 +5206,67 @@ AuthService 仍使用默认 bestEffort。Outbox Worker 会传 false,只有 pub
5623
5206
 
5624
5207
  新增:
5625
5208
 
5626
- ~~~text
5209
+ ```text
5627
5210
  apps/server/src/modules/auth/state/auth-outbox.worker.ts
5628
- ~~~
5211
+ ```
5629
5212
 
5630
5213
  粘贴完整代码:
5631
5214
 
5632
- ~~~ts
5215
+ ```ts
5633
5216
  import { randomUUID } from 'node:crypto'
5634
5217
  import { Injectable, Logger } from '@nestjs/common'
5635
5218
  import { Interval } from '@nestjs/schedule'
5636
5219
  import { AuthRepository } from '../auth.repository'
5637
- import type {
5638
- AccountAuthState,
5639
- SessionAuthState,
5640
- } from '../auth.types'
5220
+ import type { AccountAuthState, SessionAuthState } from '../auth.types'
5641
5221
  import { AuthStatePublisher } from './auth-state.publisher'
5642
5222
  import { AuthStateService } from './auth-state.service'
5643
5223
 
5644
- type ClaimedOutboxRow =
5645
- Awaited<
5646
- ReturnType<AuthRepository['claimAuthOutbox']>
5647
- >[number]
5648
-
5649
- function invalid(
5650
- row: ClaimedOutboxRow,
5651
- message: string,
5652
- ): never {
5653
- throw new Error(
5654
- 'Auth Outbox ' + row.id + ' 非法:' + message,
5655
- )
5224
+ type ClaimedOutboxRow = Awaited<ReturnType<AuthRepository['claimAuthOutbox']>>[number]
5225
+
5226
+ function invalid(row: ClaimedOutboxRow, message: string): never {
5227
+ throw new Error('Auth Outbox ' + row.id + ' 非法:' + message)
5656
5228
  }
5657
5229
 
5658
- function readRecord(
5659
- row: ClaimedOutboxRow,
5660
- value: unknown,
5661
- field: string,
5662
- ): Record<string, unknown> {
5663
- if (
5664
- typeof value !== 'object' ||
5665
- value === null ||
5666
- Array.isArray(value)
5667
- ) {
5230
+ function readRecord(row: ClaimedOutboxRow, value: unknown, field: string): Record<string, unknown> {
5231
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
5668
5232
  invalid(row, field + ' 必须是对象')
5669
5233
  }
5670
5234
 
5671
5235
  return value as Record<string, unknown>
5672
5236
  }
5673
5237
 
5674
- function readString(
5675
- row: ClaimedOutboxRow,
5676
- value: Record<string, unknown>,
5677
- field: string,
5678
- ) {
5238
+ function readString(row: ClaimedOutboxRow, value: Record<string, unknown>, field: string) {
5679
5239
  const result = value[field]
5680
5240
 
5681
- if (
5682
- typeof result !== 'string' ||
5683
- result.length === 0 ||
5684
- result.trim() !== result
5685
- ) {
5686
- invalid(
5687
- row,
5688
- field + ' 必须是非空且无首尾空格的字符串',
5689
- )
5241
+ if (typeof result !== 'string' || result.length === 0 || result.trim() !== result) {
5242
+ invalid(row, field + ' 必须是非空且无首尾空格的字符串')
5690
5243
  }
5691
5244
 
5692
5245
  return result
5693
5246
  }
5694
5247
 
5695
- function readPositiveInteger(
5696
- row: ClaimedOutboxRow,
5697
- value: Record<string, unknown>,
5698
- field: string,
5699
- ) {
5248
+ function readPositiveInteger(row: ClaimedOutboxRow, value: Record<string, unknown>, field: string) {
5700
5249
  const result = value[field]
5701
5250
 
5702
- if (
5703
- typeof result !== 'number' ||
5704
- !Number.isSafeInteger(result) ||
5705
- result < 1
5706
- ) {
5251
+ if (typeof result !== 'number' || !Number.isSafeInteger(result) || result < 1) {
5707
5252
  invalid(row, field + ' 必须是正安全整数')
5708
5253
  }
5709
5254
 
5710
5255
  return result
5711
5256
  }
5712
5257
 
5713
- function assertUserId(
5714
- row: ClaimedOutboxRow,
5715
- userId: string,
5716
- ) {
5258
+ function assertUserId(row: ClaimedOutboxRow, userId: string) {
5717
5259
  const numericId = Number(userId)
5718
5260
 
5719
- if (
5720
- !/^[1-9][0-9]*$/.test(userId) ||
5721
- !Number.isSafeInteger(numericId)
5722
- ) {
5261
+ if (!/^[1-9][0-9]*$/.test(userId) || !Number.isSafeInteger(numericId)) {
5723
5262
  invalid(row, 'userId 格式非法')
5724
5263
  }
5725
5264
 
5726
5265
  return numericId
5727
5266
  }
5728
5267
 
5729
- function parseAccountPayload(
5730
- row: ClaimedOutboxRow,
5731
- ): AccountAuthState {
5732
- if (
5733
- row.aggregateType !== 'account' ||
5734
- row.eventType !== 'account.changed'
5735
- ) {
5268
+ function parseAccountPayload(row: ClaimedOutboxRow): AccountAuthState {
5269
+ if (row.aggregateType !== 'account' || row.eventType !== 'account.changed') {
5736
5270
  invalid(row, '账号事件类型与聚合类型不匹配')
5737
5271
  }
5738
5272
 
@@ -5742,22 +5276,14 @@ function parseAccountPayload(
5742
5276
  invalid(row, '不支持的账号 payload schemaVersion')
5743
5277
  }
5744
5278
 
5745
- const source = readRecord(
5746
- row,
5747
- payload.state,
5748
- 'payload.state',
5749
- )
5279
+ const source = readRecord(row, payload.state, 'payload.state')
5750
5280
  const userId = readString(row, source, 'userId')
5751
5281
  const status = readString(row, source, 'status')
5752
5282
  const epoch = readPositiveInteger(row, source, 'epoch')
5753
5283
 
5754
5284
  assertUserId(row, userId)
5755
5285
 
5756
- if (
5757
- status !== 'active' &&
5758
- status !== 'locked' &&
5759
- status !== 'disabled'
5760
- ) {
5286
+ if (status !== 'active' && status !== 'locked' && status !== 'disabled') {
5761
5287
  invalid(row, '账号 status 非法')
5762
5288
  }
5763
5289
 
@@ -5772,13 +5298,8 @@ function parseAccountPayload(
5772
5298
  return { userId, status, epoch }
5773
5299
  }
5774
5300
 
5775
- function parseSessionPayload(
5776
- row: ClaimedOutboxRow,
5777
- ): SessionAuthState {
5778
- if (
5779
- row.aggregateType !== 'session' ||
5780
- row.eventType !== 'session.changed'
5781
- ) {
5301
+ function parseSessionPayload(row: ClaimedOutboxRow): SessionAuthState {
5302
+ if (row.aggregateType !== 'session' || row.eventType !== 'session.changed') {
5782
5303
  invalid(row, 'Session 事件类型与聚合类型不匹配')
5783
5304
  }
5784
5305
 
@@ -5788,24 +5309,12 @@ function parseSessionPayload(
5788
5309
  invalid(row, '不支持的 Session payload schemaVersion')
5789
5310
  }
5790
5311
 
5791
- const source = readRecord(
5792
- row,
5793
- payload.state,
5794
- 'payload.state',
5795
- )
5312
+ const source = readRecord(row, payload.state, 'payload.state')
5796
5313
  const sessionId = readString(row, source, 'sessionId')
5797
5314
  const userId = readString(row, source, 'userId')
5798
5315
  const status = readString(row, source, 'status')
5799
- const version = readPositiveInteger(
5800
- row,
5801
- source,
5802
- 'version',
5803
- )
5804
- const expiresAtMs = readPositiveInteger(
5805
- row,
5806
- source,
5807
- 'expiresAtMs',
5808
- )
5316
+ const version = readPositiveInteger(row, source, 'version')
5317
+ const expiresAtMs = readPositiveInteger(row, source, 'expiresAtMs')
5809
5318
 
5810
5319
  assertUserId(row, userId)
5811
5320
 
@@ -5855,20 +5364,13 @@ export class AuthOutboxWorker {
5855
5364
  this.running = true
5856
5365
 
5857
5366
  try {
5858
- const rows = await this.repository.claimAuthOutbox(
5859
- this.workerId,
5860
- 20,
5861
- 60_000,
5862
- )
5367
+ const rows = await this.repository.claimAuthOutbox(this.workerId, 20, 60_000)
5863
5368
 
5864
5369
  for (const row of rows) {
5865
5370
  try {
5866
5371
  await this.process(row)
5867
5372
 
5868
- await this.repository.markOutboxPublished(
5869
- row.id,
5870
- this.workerId,
5871
- )
5373
+ await this.repository.markOutboxPublished(row.id, this.workerId)
5872
5374
  } catch (error) {
5873
5375
  try {
5874
5376
  await this.repository.releaseOutboxForRetry({
@@ -5878,12 +5380,7 @@ export class AuthOutboxWorker {
5878
5380
  error,
5879
5381
  })
5880
5382
  } catch (releaseError) {
5881
- this.logger.error(
5882
- 'Auth Outbox ' +
5883
- row.id +
5884
- ' 释放失败:' +
5885
- String(releaseError),
5886
- )
5383
+ this.logger.error('Auth Outbox ' + row.id + ' 释放失败:' + String(releaseError))
5887
5384
  }
5888
5385
 
5889
5386
  if (row.attempts + 1 >= 10) {
@@ -5896,12 +5393,7 @@ export class AuthOutboxWorker {
5896
5393
  String(error),
5897
5394
  )
5898
5395
  } else {
5899
- this.logger.warn(
5900
- 'Auth Outbox ' +
5901
- row.id +
5902
- ' 处理失败:' +
5903
- String(error),
5904
- )
5396
+ this.logger.warn('Auth Outbox ' + row.id + ' 处理失败:' + String(error))
5905
5397
  }
5906
5398
  }
5907
5399
  }
@@ -5917,18 +5409,13 @@ export class AuthOutboxWorker {
5917
5409
  case 'session':
5918
5410
  return this.processSession(row)
5919
5411
  default:
5920
- invalid(
5921
- row,
5922
- '未知 aggregateType:' + row.aggregateType,
5923
- )
5412
+ invalid(row, '未知 aggregateType:' + row.aggregateType)
5924
5413
  }
5925
5414
  }
5926
5415
 
5927
5416
  private async processAccount(row: ClaimedOutboxRow) {
5928
5417
  const eventState = parseAccountPayload(row)
5929
- const account = await this.repository.findUserById(
5930
- assertUserId(row, eventState.userId),
5931
- )
5418
+ const account = await this.repository.findUserById(assertUserId(row, eventState.userId))
5932
5419
 
5933
5420
  let state: AccountAuthState
5934
5421
 
@@ -5945,17 +5432,13 @@ export class AuthOutboxWorker {
5945
5432
 
5946
5433
  if (
5947
5434
  state.epoch === eventState.epoch &&
5948
- (state.userId !== eventState.userId ||
5949
- state.status !== eventState.status)
5435
+ (state.userId !== eventState.userId || state.status !== eventState.status)
5950
5436
  ) {
5951
5437
  invalid(row, '账号同版本状态不一致')
5952
5438
  }
5953
5439
  } else {
5954
5440
  if (eventState.status !== 'disabled') {
5955
- invalid(
5956
- row,
5957
- '账号行缺失时只允许 disabled tombstone',
5958
- )
5441
+ invalid(row, '账号行缺失时只允许 disabled tombstone')
5959
5442
  }
5960
5443
 
5961
5444
  state = eventState
@@ -5967,9 +5450,7 @@ export class AuthOutboxWorker {
5967
5450
 
5968
5451
  private async processSession(row: ClaimedOutboxRow) {
5969
5452
  const eventState = parseSessionPayload(row)
5970
- const session = await this.repository.findSessionById(
5971
- eventState.sessionId,
5972
- )
5453
+ const session = await this.repository.findSessionById(eventState.sessionId)
5973
5454
 
5974
5455
  let state: SessionAuthState
5975
5456
 
@@ -5996,18 +5477,12 @@ export class AuthOutboxWorker {
5996
5477
  invalid(row, 'Session 同版本状态不一致')
5997
5478
  }
5998
5479
 
5999
- if (
6000
- eventState.status === 'revoked' &&
6001
- state.status === 'active'
6002
- ) {
5480
+ if (eventState.status === 'revoked' && state.status === 'active') {
6003
5481
  invalid(row, '已撤销 Session 不允许恢复为 active')
6004
5482
  }
6005
5483
  } else {
6006
5484
  if (eventState.status !== 'revoked') {
6007
- invalid(
6008
- row,
6009
- 'Session 行缺失时只允许 revoked tombstone',
6010
- )
5485
+ invalid(row, 'Session 行缺失时只允许 revoked tombstone')
6011
5486
  }
6012
5487
 
6013
5488
  state = eventState
@@ -6017,11 +5492,11 @@ export class AuthOutboxWorker {
6017
5492
  await this.publisher.sessionChanged(state, false)
6018
5493
  }
6019
5494
  }
6020
- ~~~
5495
+ ```
6021
5496
 
6022
5497
  处理顺序必须是:
6023
5498
 
6024
- ~~~text
5499
+ ```text
6025
5500
  领取租约
6026
5501
  -> 校验 eventType、aggregateType、aggregateVersion 和完整 payload
6027
5502
  -> 有数据库行时读取当前更高版本
@@ -6029,7 +5504,7 @@ export class AuthOutboxWorker {
6029
5504
  -> 单调投影 Redis
6030
5505
  -> 严格 publish
6031
5506
  -> 最后标记 published
6032
- ~~~
5507
+ ```
6033
5508
 
6034
5509
  以下情况必须抛错、释放租约并保持 publishedAt 为 null:
6035
5510
 
@@ -6050,11 +5525,11 @@ export class AuthOutboxWorker {
6050
5525
 
6051
5526
  至少保留到对应 Refresh Family 的绝对 `expiresAt` 之后,再加上允许的时钟偏差和运维缓冲。可以把最早删除时间理解为:
6052
5527
 
6053
- ~~~text
5528
+ ```text
6054
5529
  deleteAfter = family.expiresAt
6055
5530
  + clockToleranceSeconds
6056
5531
  + 运维缓冲时间
6057
- ~~~
5532
+ ```
6058
5533
 
6059
5534
  运维缓冲建议至少 24 小时;有审计要求时可以更久。`active`、`consumed`、`revoked` 三种 Token 在保留期内都不能因为“节省表空间”而提前物理删除。
6060
5535
 
@@ -6074,11 +5549,11 @@ deleteAfter = family.expiresAt
6074
5549
 
6075
5550
  真正执行物理删除前,先运行:
6076
5551
 
6077
- ~~~sql
5552
+ ```sql
6078
5553
  SELECT COUNT(*) AS unpublished_count
6079
5554
  FROM auth_outbox
6080
5555
  WHERE published_at IS NULL;
6081
- ~~~
5556
+ ```
6082
5557
 
6083
5558
  只要待删除聚合仍有未发布事件,就必须停止清理。普通清理任务永远不能删除 `published_at IS NULL` 的 Outbox;已发布 Outbox 建议继续保留 7~30 天用于审计和排障。
6084
5559
 
@@ -6090,34 +5565,34 @@ WHERE published_at IS NULL;
6090
5565
 
6091
5566
  修改:
6092
5567
 
6093
- ~~~text
5568
+ ```text
6094
5569
  apps/server/src/modules/auth/auth.module.ts
6095
- ~~~
5570
+ ```
6096
5571
 
6097
5572
  增加 import:
6098
5573
 
6099
- ~~~ts
5574
+ ```ts
6100
5575
  import { ScheduleModule } from '@nestjs/schedule'
6101
5576
  import { AuthOutboxWorker } from './state/auth-outbox.worker'
6102
- ~~~
5577
+ ```
6103
5578
 
6104
5579
  在 imports 数组中增加:
6105
5580
 
6106
- ~~~ts
5581
+ ```ts
6107
5582
  ScheduleModule.forRoot(),
6108
- ~~~
5583
+ ```
6109
5584
 
6110
5585
  在 providers 中增加:
6111
5586
 
6112
- ~~~ts
5587
+ ```ts
6113
5588
  AuthOutboxWorker,
6114
- ~~~
5589
+ ```
6115
5590
 
6116
5591
  ## 13.8 构建和验收
6117
5592
 
6118
- ~~~powershell
5593
+ ```powershell
6119
5594
  vp run "server#build"
6120
- ~~~
5595
+ ```
6121
5596
 
6122
5597
  验收场景:
6123
5598
 
@@ -6171,64 +5646,54 @@ Outbox 仍不能让 PostgreSQL 和 Redis 成为一个 ACID 事务。
6171
5646
 
6172
5647
  修改:
6173
5648
 
6174
- ~~~text
5649
+ ```text
6175
5650
  apps/server/package.json
6176
- ~~~
5651
+ ```
6177
5652
 
6178
5653
  在 scripts 中增加:
6179
5654
 
6180
- ~~~json
5655
+ ```json
6181
5656
  {
6182
5657
  "test": "vp test"
6183
5658
  }
6184
- ~~~
5659
+ ```
6185
5660
 
6186
5661
  Vite+ 的测试 import 使用:
6187
5662
 
6188
- ~~~ts
6189
- import {
6190
- describe,
6191
- expect,
6192
- it,
6193
- vi,
6194
- } from 'vite-plus/test'
6195
- ~~~
5663
+ ```ts
5664
+ import { describe, expect, it, vi } from 'vite-plus/test'
5665
+ ```
6196
5666
 
6197
5667
  不要再单独安装 Vitest,也不要从 vitest import。
6198
5668
 
6199
5669
  ## 14.2 后续必须落地的自动测试清单
6200
5670
 
6201
- 下面是验收矩阵,不代表测试已经实现。只有创建对应的 *.spec.ts 文件并实际通过后,才能声明自动测试完成。如果仓库里还没有任何测试文件,不能因为 vp run "server#test" 没报错就认为鉴权已经验证完成。
5671
+ 下面是验收矩阵,不代表测试已经实现。只有创建对应的 \*.spec.ts 文件并实际通过后,才能声明自动测试完成。如果仓库里还没有任何测试文件,不能因为 vp run "server#test" 没报错就认为鉴权已经验证完成。
6202
5672
 
6203
5673
  先落地一个不依赖 PostgreSQL 和 Redis 的最小测试,证明 Vite+ 测试入口、JWT 配置和 Refresh HMAC 原语能真正运行。
6204
5674
 
6205
5675
  新增:
6206
5676
 
6207
- ~~~text
5677
+ ```text
6208
5678
  apps/server/src/modules/auth/token.service.spec.ts
6209
- ~~~
5679
+ ```
6210
5680
 
6211
5681
  粘贴:
6212
5682
 
6213
- ~~~ts
5683
+ ```ts
6214
5684
  import { ConfigService } from '@nestjs/config'
6215
5685
  import { JwtService } from '@nestjs/jwt'
6216
5686
  import { describe, expect, it } from 'vite-plus/test'
6217
5687
  import type { AuthConfig } from '@/config/auth.config'
6218
- import {
6219
- AccessTokenService,
6220
- RefreshTokenCodec,
6221
- } from './token.service'
5688
+ import { AccessTokenService, RefreshTokenCodec } from './token.service'
6222
5689
 
6223
5690
  const auth: AuthConfig = {
6224
- accessSecret:
6225
- 'test-only-access-secret-please-never-use-this-value-in-production',
5691
+ accessSecret: 'test-only-access-secret-please-never-use-this-value-in-production',
6226
5692
  issuer: 'bubbles-auth-test',
6227
5693
  audience: 'bubbles-api-test',
6228
5694
  accessTtlSeconds: 600,
6229
5695
  refreshTtlSeconds: 30 * 24 * 60 * 60,
6230
- refreshPepper:
6231
- 'test-only-refresh-pepper-please-never-use-this-value-in-production',
5696
+ refreshPepper: 'test-only-refresh-pepper-please-never-use-this-value-in-production',
6232
5697
  clockToleranceSeconds: 30,
6233
5698
  localCacheTtlMs: 30_000,
6234
5699
  tombstoneTtlSeconds: 900,
@@ -6270,10 +5735,7 @@ function createJwtService() {
6270
5735
 
6271
5736
  describe('AccessTokenService', () => {
6272
5737
  it('签发的 JWT 可以恢复身份和两个版本号', async () => {
6273
- const service = new AccessTokenService(
6274
- createJwtService(),
6275
- createConfigService(),
6276
- )
5738
+ const service = new AccessTokenService(createJwtService(), createConfigService())
6277
5739
 
6278
5740
  const issued = await service.sign({
6279
5741
  userId: 42,
@@ -6298,10 +5760,7 @@ describe('AccessTokenService', () => {
6298
5760
  })
6299
5761
 
6300
5762
  it('拒绝签名被篡改的 JWT', async () => {
6301
- const service = new AccessTokenService(
6302
- createJwtService(),
6303
- createConfigService(),
6304
- )
5763
+ const service = new AccessTokenService(createJwtService(), createConfigService())
6305
5764
 
6306
5765
  const issued = await service.sign({
6307
5766
  userId: 42,
@@ -6313,13 +5772,9 @@ describe('AccessTokenService', () => {
6313
5772
  const parts = issued.accessToken.split('.')
6314
5773
  const signature = parts[2]
6315
5774
 
6316
- parts[2] =
6317
- (signature.startsWith('a') ? 'b' : 'a') +
6318
- signature.slice(1)
5775
+ parts[2] = (signature.startsWith('a') ? 'b' : 'a') + signature.slice(1)
6319
5776
 
6320
- await expect(
6321
- service.verify(parts.join('.')),
6322
- ).rejects.toThrow('访问令牌无效或已过期')
5777
+ await expect(service.verify(parts.join('.'))).rejects.toThrow('访问令牌无效或已过期')
6323
5778
  })
6324
5779
 
6325
5780
  it('Refresh Token 只保存可重复计算的 HMAC 摘要', () => {
@@ -6332,13 +5787,13 @@ describe('AccessTokenService', () => {
6332
5787
  expect(codec.hash(token.raw)).toBe(token.hash)
6333
5788
  })
6334
5789
  })
6335
- ~~~
5790
+ ```
6336
5791
 
6337
5792
  立即执行:
6338
5793
 
6339
- ~~~powershell
5794
+ ```powershell
6340
5795
  vp run "server#test"
6341
- ~~~
5796
+ ```
6342
5797
 
6343
5798
  必须看到 3 个测试通过。这个最小测试通过只代表测试入口和 Token 原语正确,不能代替下面依赖 PostgreSQL、Redis 和 HTTP 的集成验收。
6344
5799
 
@@ -6414,12 +5869,12 @@ vp run "server#test"
6414
5869
 
6415
5870
  先保证 PostgreSQL 和 Redis 测试实例可用,然后执行:
6416
5871
 
6417
- ~~~powershell
5872
+ ```powershell
6418
5873
  vp check
6419
5874
  vp run "server#test"
6420
5875
  vp run "server#build"
6421
5876
  vp run ready
6422
- ~~~
5877
+ ```
6423
5878
 
6424
5879
  vp check 会执行格式、Lint 和类型检查。不要只看 nest build。
6425
5880
 
@@ -6453,33 +5908,33 @@ vp check 会执行格式、Lint 和类型检查。不要只看 nest build。
6453
5908
 
6454
5909
  先查询:
6455
5910
 
6456
- ~~~sql
5911
+ ```sql
6457
5912
  SELECT COUNT(*)
6458
5913
  FROM users
6459
5914
  WHERE password_hash IS NULL;
6460
- ~~~
5915
+ ```
6461
5916
 
6462
5917
  结果必须为 0。
6463
5918
 
6464
5919
  然后把 schema 中:
6465
5920
 
6466
- ~~~ts
5921
+ ```ts
6467
5922
  passwordHash: varchar('password_hash', { length: 255 }),
6468
- ~~~
5923
+ ```
6469
5924
 
6470
5925
  改成:
6471
5926
 
6472
- ~~~ts
5927
+ ```ts
6473
5928
  passwordHash: varchar('password_hash', { length: 255 }).notNull(),
6474
- ~~~
5929
+ ```
6475
5930
 
6476
5931
  生成并审查第二次迁移:
6477
5932
 
6478
- ~~~powershell
5933
+ ```powershell
6479
5934
  vp run "server#db:generate"
6480
5935
  vp run "server#db:migrate"
6481
5936
  vp run "server#build"
6482
- ~~~
5937
+ ```
6483
5938
 
6484
5939
  不要给旧账号填一个所有人共用的假密码。旧账号应通过安全的密码重置、管理员邀请或导入流程生成各自的真实 hash。
6485
5940
 
@@ -6491,7 +5946,7 @@ vp run "server#build"
6491
5946
 
6492
5947
  真正拆成多个微服务以后:
6493
5948
 
6494
- ~~~text
5949
+ ```text
6495
5950
  认证服务
6496
5951
  持有私钥
6497
5952
  负责签发 JWT
@@ -6499,7 +5954,7 @@ vp run "server#build"
6499
5954
  业务微服务
6500
5955
  只持有公钥或读取 JWKS
6501
5956
  只能验签,不能伪造 JWT
6502
- ~~~
5957
+ ```
6503
5958
 
6504
5959
  推荐步骤:
6505
5960
 
@@ -6550,7 +6005,7 @@ vp run "server#build"
6550
6005
 
6551
6006
  这套设计不是“JWT 完全无状态”,而是把工作分层:
6552
6007
 
6553
- ~~~text
6008
+ ```text
6554
6009
  JWT
6555
6010
  证明这个身份曾被认证服务合法签发
6556
6011
 
@@ -6571,17 +6026,17 @@ Pub/Sub Push
6571
6026
 
6572
6027
  Outbox
6573
6028
  修复数据库提交后进程崩溃导致的传播缺口
6574
- ~~~
6029
+ ```
6575
6030
 
6576
6031
  你最开始担心的“多个用户同时改密会让所有微服务疯狂查询用户中心”,在这里被拆成:
6577
6032
 
6578
- ~~~text
6033
+ ```text
6579
6034
  每个改密用户只产生一个 account epoch 事件
6580
6035
  -> 微服务没有该用户缓存就忽略
6581
6036
  -> 有缓存只标脏
6582
6037
  -> 该用户真正再次访问时才 Pull
6583
6038
  -> 同一用户并发 Pull 由 single-flight 合并
6584
- ~~~
6039
+ ```
6585
6040
 
6586
6041
  因此它保留了 JWT 的本地验签优势,又提供了可控的踢人实时性。
6587
6042