├── .cursor └── rules │ └── base.mdc ├── .dockerignore ├── .editorconfig ├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── ai.md ├── docker-compose.yml ├── ecosystem.config.js ├── index.js ├── jest.json ├── logs ├── error.log └── output.log ├── nodemon.json ├── package.json ├── pnpm-lock.yaml ├── prisma ├── migrations │ ├── 20250302022905_initial │ │ └── migration.sql │ ├── 20250302051014_add_email_verification │ │ └── migration.sql │ ├── 20250302134713_add_aspect_radio │ │ └── migration.sql │ ├── 20250305162747_add_svg_modify_list │ │ └── migration.sql │ ├── 20250311142946_init │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── src ├── modules │ ├── app.module.ts │ ├── auth │ │ ├── auth.module.ts │ │ ├── controller │ │ │ ├── auth.controller.ts │ │ │ └── index.ts │ │ ├── decorator │ │ │ └── public.decorator.ts │ │ ├── flow │ │ │ ├── index.ts │ │ │ ├── login.pipe.ts │ │ │ ├── register.pipe.ts │ │ │ ├── send-verification-code.pipe.ts │ │ │ └── verify-email.pipe.ts │ │ ├── model │ │ │ ├── auth-response.dto.ts │ │ │ ├── index.ts │ │ │ ├── login.dto.ts │ │ │ ├── mail-config.ts │ │ │ ├── register.dto.ts │ │ │ ├── send-verification-code.dto.ts │ │ │ └── verify-email.dto.ts │ │ └── service │ │ │ ├── auth.service.ts │ │ │ ├── index.ts │ │ │ ├── jwt.guard.ts │ │ │ ├── jwt.strategy.ts │ │ │ ├── mail-template.service.ts │ │ │ ├── mail.service.ts │ │ │ └── token-blacklist.service.ts │ ├── common │ │ ├── common.module.ts │ │ ├── controller │ │ │ ├── health.controller.ts │ │ │ └── index.ts │ │ ├── flow │ │ │ ├── index.ts │ │ │ ├── joi-validation.pipe.ts │ │ │ └── log.interceptor.ts │ │ ├── index.ts │ │ ├── model │ │ │ ├── config.ts │ │ │ └── index.ts │ │ ├── provider │ │ │ ├── config.provider.ts │ │ │ ├── index.ts │ │ │ ├── logger.service.ts │ │ │ └── prisma.provider.ts │ │ ├── security │ │ │ ├── guest.guard.ts │ │ │ ├── health.guard.ts │ │ │ ├── index.ts │ │ │ ├── restricted.guard.ts │ │ │ └── security-utils.ts │ │ └── spec │ │ │ └── basic-unit-test.spec.ts │ ├── svg-generator │ │ ├── controller │ │ │ ├── index.ts │ │ │ ├── svg-generation.controller.ts │ │ │ └── user.controller.ts │ │ ├── flow │ │ │ ├── index.ts │ │ │ ├── svg-generation.pipe.ts │ │ │ ├── update-public-status.pipe.ts │ │ │ └── user.pipe.ts │ │ ├── model │ │ │ ├── index.ts │ │ │ ├── paginated-response.data.ts │ │ │ ├── svg-generation-with-version.data.ts │ │ │ ├── svg-generation.data.ts │ │ │ ├── svg-generation.input.ts │ │ │ ├── svg-version-update.dto.ts │ │ │ ├── svg-version.data.ts │ │ │ ├── update-public-status.dto.ts │ │ │ ├── user.data.ts │ │ │ └── user.input.ts │ │ ├── service │ │ │ ├── index.ts │ │ │ ├── svg-generation.service.ts │ │ │ └── user.service.ts │ │ ├── spec │ │ │ └── passenger.spec.ts │ │ ├── svg-generator.module.ts │ │ └── validation │ │ │ └── user.schema.ts │ └── tokens.ts ├── scripts │ └── send-apology-emails.ts ├── server.ts └── tokens.ts ├── tsconfig.eslint.json └── tsconfig.json /.cursor/rules/base.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | 7 | 你是资深软件开发工程师,要求: 8 | 1. 查看 [package.json](mdc:package.json) 和 [server.ts](mdc:src/server.ts) 了解项目基础情况接 9 | 2. 如果是简单需求可以直接开发,复杂需求需要在开发之前一步一步把你要做的工作理清楚,然后产出一份完整详尽的实施文档来跟我确认你接下来的工作,然后确认完工作文档后,才能进行开发 10 | 3. 代码保证组件化,模块化,解耦,避免硬编码,应该保留可扩展性 11 | 4. 遵循最小改动原则,非必要不改动已有的代码 -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Versioning and metadata 2 | .git 3 | .gitignore 4 | .dockerignore 5 | 6 | # Build dependencies 7 | dist 8 | node_modules 9 | coverage 10 | 11 | # Environment (contains sensitive data) 12 | .env 13 | 14 | # Files not required for production 15 | .editorconfig 16 | Dockerfile 17 | README.md 18 | .eslintrc.js 19 | nodemon.json 20 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.{js,ts}] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 4 8 | 9 | [*.json] 10 | end_of_line = lf 11 | insert_final_newline = true 12 | indent_style = space 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # API configuration 2 | API_PORT=3000 3 | API_PREFIX="/api/v1" 4 | API_CORS=1 5 | 6 | # Swagger API documentation 7 | SWAGGER_ENABLE=1 8 | 9 | # Database ORM configuration 10 | # 数据库连接URL格式:postgresql://用户名:密码@主机:端口/数据库名?schema=public 11 | # 在这个例子中,用户名是postgres,密码是postgres 12 | # 实际使用时,请替换为您自己的数据库凭据 13 | DATABASE_URL="postgresql://postgres:postgres@localhost:5432/svg?schema=public" 14 | DATABASE_URL_DEV="postgresql://postgres:postgres@localhost:5432/svg?schema=public" 15 | # JWT configuration 16 | JWT_SECRET="your-secret-key" 17 | JWT_ISSUER="DEFAULT_ISSUER" 18 | 19 | # Access to the health route 20 | HEALTH_TOKEN=ThisMustBeChanged 21 | 22 | # Domain-related configuration 23 | PASSENGERS_ALLOWED=yes 24 | 25 | # Mail Configuration 26 | MAIL_HOST=smtp.example.com 27 | MAIL_PORT=465 28 | MAIL_SECURE=true 29 | MAIL_USER=noreply@example.com 30 | MAIL_PASS=your-password-or-app-key 31 | MAIL_SENDER_NAME=SVG生成器 32 | 33 | # Anthropic API Configuration 34 | ANTHROPIC_API_KEY=xx 35 | ANTHROPIC_API_URL=xxx 36 | 37 | # Anthropic API Configuration 2 38 | ANTHROPIC_API_KEY_2=xx 39 | ANTHROPIC_API_URL_2=xxx 40 | 41 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # 忽略PM2配置文件 2 | ecosystem.config.js 3 | 4 | # 忽略构建输出目录 5 | dist/ 6 | 7 | # 忽略依赖 8 | node_modules/ 9 | 10 | # 忽略日志 11 | logs/ -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: false, 4 | es6: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 10 | ], 11 | parser: "@typescript-eslint/parser", 12 | parserOptions: { 13 | project: "tsconfig.eslint.json", 14 | sourceType: "module", 15 | }, 16 | plugins: [ 17 | "eslint-plugin-prefer-arrow", 18 | "eslint-plugin-import", 19 | "@typescript-eslint", 20 | ], 21 | root: true, 22 | rules: { 23 | "@typescript-eslint/adjacent-overload-signatures": "error", 24 | "@typescript-eslint/array-type": "off", 25 | "@typescript-eslint/await-thenable": "error", 26 | "@typescript-eslint/ban-types": [ 27 | "error", 28 | { 29 | types: { 30 | Object: { 31 | message: 32 | "Avoid using the `Object` type. Did you mean `object`?", 33 | }, 34 | Function: { 35 | message: 36 | "Avoid using the `Function` type. Prefer a specific function type, like `() => void`.", 37 | }, 38 | Boolean: { 39 | message: 40 | "Avoid using the `Boolean` type. Did you mean `boolean`?", 41 | }, 42 | Number: { 43 | message: 44 | "Avoid using the `Number` type. Did you mean `number`?", 45 | }, 46 | String: { 47 | message: 48 | "Avoid using the `String` type. Did you mean `string`?", 49 | }, 50 | Symbol: { 51 | message: 52 | "Avoid using the `Symbol` type. Did you mean `symbol`?", 53 | }, 54 | }, 55 | }, 56 | ], 57 | "@typescript-eslint/consistent-type-assertions": "error", 58 | "@typescript-eslint/dot-notation": "error", 59 | "@typescript-eslint/explicit-function-return-type": "off", 60 | "@typescript-eslint/explicit-member-accessibility": [ 61 | "error", 62 | { 63 | accessibility: "explicit", 64 | }, 65 | ], 66 | "@typescript-eslint/explicit-module-boundary-types": "off", 67 | "@typescript-eslint/member-delimiter-style": [ 68 | "error", 69 | { 70 | multiline: { 71 | delimiter: "semi", 72 | requireLast: true, 73 | }, 74 | singleline: { 75 | delimiter: "semi", 76 | requireLast: false, 77 | }, 78 | }, 79 | ], 80 | "@typescript-eslint/member-ordering": "error", 81 | "@typescript-eslint/naming-convention": [ 82 | "error", 83 | { 84 | selector: "variable", 85 | format: ["camelCase", "UPPER_CASE"], 86 | leadingUnderscore: "forbid", 87 | trailingUnderscore: "forbid", 88 | }, 89 | ], 90 | "@typescript-eslint/no-dynamic-delete": "error", 91 | "@typescript-eslint/no-empty-function": "error", 92 | "@typescript-eslint/no-empty-interface": "error", 93 | "@typescript-eslint/no-explicit-any": "warn", 94 | "@typescript-eslint/no-floating-promises": "error", 95 | "@typescript-eslint/no-misused-new": "error", 96 | "@typescript-eslint/no-namespace": "off", 97 | "@typescript-eslint/no-non-null-assertion": "error", 98 | "@typescript-eslint/no-parameter-properties": "off", 99 | "@typescript-eslint/no-shadow": [ 100 | "error", 101 | { 102 | hoist: "all", 103 | }, 104 | ], 105 | "@typescript-eslint/no-unused-expressions": "error", 106 | "@typescript-eslint/no-use-before-define": "off", 107 | "@typescript-eslint/no-var-requires": "error", 108 | "@typescript-eslint/prefer-for-of": "error", 109 | "@typescript-eslint/prefer-function-type": "error", 110 | "@typescript-eslint/prefer-namespace-keyword": "error", 111 | "@typescript-eslint/prefer-readonly": "error", 112 | "@typescript-eslint/promise-function-async": "error", 113 | // "@typescript-eslint/quotes": ["error", "double"], 114 | "@typescript-eslint/restrict-plus-operands": "error", 115 | "@typescript-eslint/semi": ["error", "always"], 116 | "@typescript-eslint/triple-slash-reference": [ 117 | "error", 118 | { 119 | path: "always", 120 | types: "prefer-import", 121 | lib: "always", 122 | }, 123 | ], 124 | "@typescript-eslint/typedef": "off", 125 | "@typescript-eslint/unified-signatures": "error", 126 | "arrow-body-style": "error", 127 | "arrow-parens": ["off", "always"], 128 | "brace-style": ["off", "off"], 129 | "comma-dangle": "off", 130 | complexity: "off", 131 | "constructor-super": "error", 132 | curly: ["error", "multi-line"], 133 | "dot-notation": "off", 134 | "eol-last": "off", 135 | eqeqeq: ["error", "always"], 136 | "guard-for-in": "error", 137 | "id-denylist": "error", 138 | "id-match": "error", 139 | "import/no-default-export": "error", 140 | "import/no-extraneous-dependencies": [ 141 | "error", 142 | { 143 | devDependencies: false, 144 | }, 145 | ], 146 | "import/no-unassigned-import": "error", 147 | "import/order": [ 148 | "error", 149 | { 150 | alphabetize: { 151 | caseInsensitive: true, 152 | order: "asc", 153 | }, 154 | "newlines-between": "ignore", 155 | groups: [ 156 | [ 157 | "builtin", 158 | "external", 159 | "internal", 160 | "unknown", 161 | "object", 162 | "type", 163 | ], 164 | "parent", 165 | ["sibling", "index"], 166 | ], 167 | distinctGroup: false, 168 | pathGroupsExcludedImportTypes: [], 169 | pathGroups: [ 170 | { 171 | pattern: "./", 172 | patternOptions: { 173 | nocomment: true, 174 | dot: true, 175 | }, 176 | group: "sibling", 177 | position: "before", 178 | }, 179 | { 180 | pattern: ".", 181 | patternOptions: { 182 | nocomment: true, 183 | dot: true, 184 | }, 185 | group: "sibling", 186 | position: "before", 187 | }, 188 | { 189 | pattern: "..", 190 | patternOptions: { 191 | nocomment: true, 192 | dot: true, 193 | }, 194 | group: "parent", 195 | position: "before", 196 | }, 197 | { 198 | pattern: "../", 199 | patternOptions: { 200 | nocomment: true, 201 | dot: true, 202 | }, 203 | group: "parent", 204 | position: "before", 205 | }, 206 | ], 207 | }, 208 | ], 209 | "max-classes-per-file": ["error", 1], 210 | "max-len": [ 211 | "error", 212 | { 213 | code: 180, 214 | }, 215 | ], 216 | "new-parens": "error", 217 | "no-bitwise": "error", 218 | "no-caller": "error", 219 | "no-cond-assign": "error", 220 | "no-console": "warn", 221 | "no-debugger": "error", 222 | "no-empty": "error", 223 | "no-empty-function": "off", 224 | "no-eval": "error", 225 | "no-fallthrough": "off", 226 | "no-invalid-this": "error", 227 | "no-multiple-empty-lines": "error", 228 | "no-new-wrappers": "error", 229 | "no-param-reassign": "error", 230 | "no-return-await": "error", 231 | "no-sequences": "error", 232 | "no-shadow": "off", 233 | "no-throw-literal": "error", 234 | "no-trailing-spaces": "error", 235 | "no-undef-init": "error", 236 | "no-underscore-dangle": "error", 237 | "no-unsafe-finally": "error", 238 | "no-unused-expressions": "off", 239 | "no-unused-labels": "error", 240 | "no-use-before-define": "off", 241 | "no-var": "error", 242 | "object-shorthand": "error", 243 | "one-var": ["off", "never"], 244 | "prefer-arrow/prefer-arrow-functions": ["off", {}], 245 | "prefer-const": "error", 246 | "prefer-template": "error", 247 | quotes: "off", 248 | radix: "error", 249 | semi: "off", 250 | "spaced-comment": [ 251 | "error", 252 | "always", 253 | { 254 | markers: ["/"], 255 | }, 256 | ], 257 | "use-isnan": "error", 258 | "valid-typeof": "off", 259 | }, 260 | }; 261 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # IDE 5 | /.idea 6 | /.awcache 7 | /.vscode 8 | 9 | # misc 10 | npm-debug.log 11 | 12 | # example 13 | /quick-start 14 | 15 | # tests 16 | /test 17 | /coverage 18 | /.nyc_output 19 | 20 | # dist 21 | /dist 22 | 23 | # environment 24 | .env 25 | backup.sql 26 | backup.dump -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # PRODUCTION DOCKERFILE 2 | # --------------------- 3 | # This Dockerfile allows to build a Docker image of the NestJS application 4 | # and based on a NodeJS 20 image. The multi-stage mechanism allows to build 5 | # the application in a "builder" stage and then create a lightweight production 6 | # image containing the required dependencies and the JS build files. 7 | # 8 | # Dockerfile best practices 9 | # https://docs.docker.com/develop/develop-images/dockerfile_best-practices/ 10 | # Dockerized NodeJS best practices 11 | # https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md 12 | # https://www.bretfisher.com/node-docker-good-defaults/ 13 | # http://goldbergyoni.com/checklist-best-practice-of-node-js-in-production/ 14 | 15 | FROM node:20-alpine as builder 16 | 17 | ENV NODE_ENV build 18 | 19 | USER node 20 | WORKDIR /home/node 21 | 22 | COPY package*.json ./ 23 | RUN npm ci 24 | 25 | COPY --chown=node:node . . 26 | RUN npx prisma generate \ 27 | && npm run build \ 28 | && npm prune --omit=dev 29 | 30 | # --- 31 | 32 | FROM node:20-alpine 33 | 34 | ENV NODE_ENV production 35 | 36 | USER node 37 | WORKDIR /home/node 38 | 39 | COPY --from=builder --chown=node:node /home/node/package*.json ./ 40 | COPY --from=builder --chown=node:node /home/node/node_modules/ ./node_modules/ 41 | COPY --from=builder --chown=node:node /home/node/dist/ ./dist/ 42 | 43 | CMD ["node", "dist/server.js"] 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Saluki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NestJS 10 API 项目模板 2 | 3 | 使用此模板快速搭建您的下一个 [NestJS 10](https://nestjs.com/) API 项目 4 | 5 | - 专为 Docker 环境打造(支持 Dockerfile 和环境变量) 6 | - 支持 [Prisma](https://www.prisma.io/) 的 REST API 7 | - Swagger 文档、[Joi](https://github.com/hapijs/joi) 验证、Winston 日志记录等 8 | - 文件夹结构、代码示例和最佳实践 9 | - 使用 [Fastify](https://fastify.dev/) 的快速 HTTP 服务器 10 | 11 | ## 1. 入门指南 12 | 13 | ### 1.1 要求 14 | 15 | 在开始之前,请确保您的工作站上至少安装了以下组件: 16 | 17 | - 最新版本的 [NodeJS](https://nodejs.org/),例如 20.x 和 NPM 18 | - 数据库,例如 PostgreSQL。您可以使用提供的 `docker-compose.yml` 文件。 19 | 20 | [Docker](https://www.docker.com/) 对于高级测试和镜像构建也可能有用,尽管它不是开发所必需的。 21 | 22 | ### 1.2 项目配置 23 | 24 | 首先在您的工作站上克隆此项目,或在 Github 上点击 ["使用此模板"](https://github.com/new?template_name=nestjs-template&template_owner=Saluki)。 25 | 26 | ```sh 27 | git clone https://github.com/saluki/nestjs-template my-project 28 | ``` 29 | 30 | 接下来是安装项目的所有依赖项。 31 | 32 | ```sh 33 | cd ./my-project 34 | npm install 35 | ``` 36 | 37 | 安装完依赖项后,您现在可以通过创建一个新的 `.env` 文件来配置您的项目,该文件包含用于开发的环境变量。 38 | 39 | ```sh 40 | cp .env.example .env 41 | vi .env 42 | ``` 43 | 44 | 对于标准的开发配置,您可以保留 `Api configuration` 部分下的 `API_PORT`、`API_PREFIX` 和 `API_CORS` 的默认值。`SWAGGER_ENABLE` 规则允许您控制 NestJS 的 Swagger 文档模块。在开始此示例时,请将其保留为 `1`。 45 | 46 | 接下来是 Prisma 配置:根据您自己的数据库设置更改 `DATABASE_URL`。 47 | 48 | 最后但同样重要的是,定义一个 `JWT_SECRET` 来签署 JWT 令牌,或在开发环境中保留默认值。将 `JWT_ISSUER` 更新为 JWT 中设置的正确值。 49 | 50 | ### 1.3 启动和探索 51 | 52 | 您现在可以使用以下命令启动 NestJS 应用程序。 53 | 54 | ```sh 55 | # 仅在开发环境中使用,执行 Prisma 迁移 56 | npx prisma migrate dev 57 | 58 | # 使用 TSNode 启动开发服务器 59 | npm run dev 60 | ``` 61 | 62 | 您现在可以访问 `http://localhost:3000/docs` 查看您的 API Swagger 文档。示例乘客 API 位于 `http://localhost:3000/api/v1/passengers` 端点。 63 | 64 | 对于受限路由,您可以使用以下 JWT 进行测试 65 | 66 | ``` 67 | eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJERUZBVUxUX0lTU1VFUiIsImlhdCI6MTYzMTEwNDMzNCwicm9sZSI6InJlc3RyaWN0ZWQifQ.o2HcQBBpx-EJMcUFiqmAiD_jZ5J92gRDOyhybT9FakE 68 | ``` 69 | 70 | > 上面的示例 JWT 没有过期时间,请记住在生产环境中使用有效的 JWT 并强制执行所需的声明 71 | 72 | ## 2. 项目结构 73 | 74 | 此模板采用明确定义的目录结构。 75 | 76 | ```sh 77 | src/ 78 | ├── modules 79 | │   ├── app.module.ts 80 | │   ├── common/ # 公共模块包含在整个应用程序中使用的管道、守卫、服务和提供者 81 | │   ├── passenger/ # 管理“乘客”资源的模块示例 82 | │   │   ├── controller/ 83 | │   │   │   └── passenger.controller.ts 84 | │   │   ├── flow/ # “flow”目录包含管道、拦截器以及可能更改请求或响应流的所有内容 85 | │   │   │   └── passenger.pipe.ts 86 | │   │   ├── model/ 87 | │   │   │   ├── passenger.data.ts # 将在响应中返回的模型 88 | │   │   │   └── passenger.input.ts # 在请求中使用的模型 89 | │   │   ├── passenger.module.ts 90 | │   │   ├── service/ 91 | │   │   │   └── passenger.service.ts 92 | │   │   └── spec/ 93 | │   └── tokens.ts 94 | └── server.ts 95 | ``` 96 | 97 | ## 3. 默认 NPM 命令 98 | 99 | 以下 NPM 命令已包含在此模板中,可用于快速运行、构建和测试您的项目。 100 | 101 | ```sh 102 | # 使用转译后的 NodeJS 启动应用程序 103 | npm run start 104 | 105 | # 使用 "ts-node" 运行应用程序 106 | npm run dev 107 | 108 | # 转译 TypeScript 文件 109 | npm run build 110 | 111 | # 运行项目的功能测试 112 | npm run test 113 | 114 | # 使用 TSLint 对项目文件进行 lint 检查 115 | npm run lint 116 | ``` 117 | 118 | ## 5. 健康检查支持 119 | 120 | 健康检查 API 是一个 REST 端点,可用于验证服务及其依赖项的状态。健康检查 API 端点内部触发服务的整体健康检查。这可以包括数据库连接检查、系统属性、磁盘可用性和内存可用性。 121 | 122 | 可以使用 `HEALTH_TOKEN` 环境变量中的令牌请求示例健康检查端点。 123 | 124 | ```sh 125 | curl -H 'Authorization: Bearer ThisMustBeChanged' http://localhost:3000/api/v1/health 126 | ``` 127 | 128 | ## 6. 项目目标 129 | 130 | 该项目的目标是提供一个干净且最新的“入门包”,用于使用 NestJS 构建的 REST API 项目。 131 | 132 | ## 7. 贡献 133 | 134 | 欢迎提出改进建议、报告错误或提出问题:[https://github.com/saluki/nestjs-template/issues](https://github.com/saluki/nestjs-template/issues) -------------------------------------------------------------------------------- /ai.md: -------------------------------------------------------------------------------- 1 | 我们已经成功实现了一个完整的用户注册登录模块,主要功能包括: 2 | 用户注册流程: 3 | 发送邮箱验证码 4 | 验证码校验 5 | 用户信息注册(用户名、邮箱、密码) 6 | 密码加密存储 7 | 生成 JWT 令牌 8 | 用户登录流程: 9 | 支持用户名或邮箱登录 10 | 密码验证 11 | 生成 JWT 令牌 12 | 记录登录时间 13 | 安全特性: 14 | JWT 身份验证 15 | 密码 bcrypt 加密 16 | 验证码有效期和使用状态管理 17 | API 输入验证 18 | 接口说明: 19 | /auth/verification-code:发送邮箱验证码 20 | /auth/verify-email:验证邮箱验证码 21 | /auth/register:用户注册 22 | /auth/login:用户登录 23 | 该模块现在可以与现有的 SVG 生成器模块集成,为系统提供用户认证能力。在实际应用中,您可能需要: 24 | 集成真实的邮件发送服务(如 nodemailer) 25 | 设置更强大的密码策略 26 | 添加密码重置功能 27 | 实现用户信息管理功能 28 | 所有这些功能都可以在现有架构的基础上进行扩展。 29 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # THIS DOCKERFILE IS INTENDED FOR DEVELOPMENT ENVIRONMENTS 2 | # DO NOT USE THIS FOR PRODUCTION USAGES. 3 | 4 | version: '3' 5 | 6 | services: 7 | postgresql: 8 | image: docker.io/bitnami/postgresql:16 9 | ports: 10 | - '5432:5432' 11 | volumes: 12 | - 'postgresql_data:/bitnami/postgresql' 13 | environment: 14 | - ALLOW_EMPTY_PASSWORD=yes 15 | - POSTGRESQL_DATABASE=nestjs 16 | - POSTGRESQL_USERNAME=nestjs 17 | - POSTGRESQL_PASSWORD=password 18 | 19 | volumes: 20 | postgresql_data: 21 | driver: local 22 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: "svg-backend", // 应用程序名称,用于PM2识别和管理 5 | script: "dist/server.js", // 启动脚本路径,指向编译后的服务器入口文件 6 | instances: "max", // 启动的实例数量,'max'表示根据CPU核心数自动创建实例数 7 | exec_mode: "cluster", // 执行模式,'cluster'模式允许多个实例共享同一端口 8 | watch: false, // 是否监视文件变化并自动重启,生产环境建议设为false 9 | ignore_watch: ["node_modules", "logs"], // 忽略监视的文件夹 10 | max_memory_restart: "1G", // 当内存超过1G时自动重启应用 11 | env: { 12 | NODE_ENV: "production", // 设置环境变量为生产环境 13 | API_PORT: 3001, // 设置应用程序端口为3001 14 | }, 15 | env_development: { 16 | NODE_ENV: "development", // 开发环境配置 17 | API_PORT: 3001, // 开发环境也使用3001端口 18 | }, 19 | log_date_format: "YYYY-MM-DD HH:mm:ss", // 日志日期格式 20 | error_file: "logs/error.log", // 错误日志文件路径 21 | out_file: "logs/output.log", // 标准输出日志文件路径 22 | merge_logs: true, // 合并集群模式下的日志 23 | log_type: "json", // 日志类型,使用JSON格式便于解析 24 | max_restarts: 10, // 最大重启次数 25 | restart_delay: 3000, // 重启延迟时间(毫秒) 26 | autorestart: true, // 应用崩溃时自动重启 27 | node_args: "--max-old-space-size=1024", // Node.js参数,限制内存使用 28 | time: true, // 在日志中添加时间戳 29 | }, 30 | ], 31 | 32 | // 部署配置,如果需要自动部署可以取消注释并配置 33 | /* 34 | deploy: { 35 | production: { 36 | user: 'username', // 服务器用户名 37 | host: 'server-ip', // 服务器IP地址 38 | ref: 'origin/main', // Git分支 39 | repo: 'git@github.com:username/repo.git', // Git仓库地址 40 | path: '/var/www/production', // 服务器上的部署路径 41 | 'post-deploy': 'npm install && npm run build && pm2 reload ecosystem.config.js --env production', // 部署后执行的命令 42 | }, 43 | }, 44 | */ 45 | }; 46 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | require('ts-node/register'); 2 | require('./src/server'); 3 | -------------------------------------------------------------------------------- /jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": [ 3 | "ts", 4 | "tsx", 5 | "js", 6 | "json" 7 | ], 8 | "transform": { 9 | "^.+\\.tsx?$": "ts-jest" 10 | }, 11 | "testRegex": "/src/.*\\.(test|spec).(ts|tsx|js)$", 12 | "collectCoverageFrom": [ 13 | "src/**/*.{js,jsx,tsx,ts}", 14 | "!**/node_modules/**", 15 | "!**/vendor/**" 16 | ], 17 | "coverageReporters": [ 18 | "json", 19 | "lcov" 20 | ], 21 | "testEnvironmentOptions": { 22 | "url": "http://localhost" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /logs/error.log: -------------------------------------------------------------------------------- 1 | {"message":"\u001b[31m[Nest] 26463 - \u001b[39m03/11/2025, 11:42:58 PM \u001b[31m ERROR\u001b[39m \u001b[38;5;3m[NestApplication] \u001b[39m\u001b[31mError: bind EADDRINUSE 0.0.0.0:3001\u001b[39m\u001b[38;5;3m +4ms\u001b[39m\n","timestamp":"2025-03-11T23:42:58","type":"err","process_id":5,"app_name":"svg-backend"} 2 | {"message":"\u001b[31m[Nest] 26442 - \u001b[39m03/11/2025, 11:42:58 PM \u001b[31m ERROR\u001b[39m \u001b[38;5;3m[NestApplication] \u001b[39m\u001b[31mError: bind EADDRINUSE 0.0.0.0:3001\u001b[39m\u001b[38;5;3m +5ms\u001b[39m\n","timestamp":"2025-03-11T23:42:58","type":"err","process_id":1,"app_name":"svg-backend"} 3 | {"message":"\u001b[31m[Nest] 26441 - \u001b[39m03/11/2025, 11:42:58 PM \u001b[31m ERROR\u001b[39m \u001b[38;5;3m[NestApplication] \u001b[39m\u001b[31mError: bind EADDRINUSE 0.0.0.0:3001\u001b[39m\u001b[38;5;3m +5ms\u001b[39m\n","timestamp":"2025-03-11T23:42:58","type":"err","process_id":0,"app_name":"svg-backend"} 4 | {"message":"\u001b[31m[Nest] 26482 - \u001b[39m03/11/2025, 11:42:58 PM \u001b[31m ERROR\u001b[39m \u001b[38;5;3m[NestApplication] \u001b[39m\u001b[31mError: bind EADDRINUSE 0.0.0.0:3001\u001b[39m\u001b[38;5;3m +3ms\u001b[39m\n","timestamp":"2025-03-11T23:42:58","type":"err","process_id":9,"app_name":"svg-backend"} 5 | {"message":"\u001b[31m[Nest] 26458 - \u001b[39m03/11/2025, 11:42:58 PM \u001b[31m ERROR\u001b[39m \u001b[38;5;3m[NestApplication] \u001b[39m\u001b[31mError: bind EADDRINUSE 0.0.0.0:3001\u001b[39m\u001b[38;5;3m +4ms\u001b[39m\n","timestamp":"2025-03-11T23:42:58","type":"err","process_id":4,"app_name":"svg-backend"} 6 | {"message":"\u001b[31m[Nest] 26487 - \u001b[39m03/11/2025, 11:42:58 PM \u001b[31m ERROR\u001b[39m \u001b[38;5;3m[NestApplication] \u001b[39m\u001b[31mError: bind EADDRINUSE 0.0.0.0:3001\u001b[39m\u001b[38;5;3m +4ms\u001b[39m\n","timestamp":"2025-03-11T23:42:58","type":"err","process_id":10,"app_name":"svg-backend"} 7 | {"message":"\u001b[31m[Nest] 26492 - \u001b[39m03/11/2025, 11:42:58 PM \u001b[31m ERROR\u001b[39m \u001b[38;5;3m[NestApplication] \u001b[39m\u001b[31mError: bind EADDRINUSE 0.0.0.0:3001\u001b[39m\u001b[38;5;3m +4ms\u001b[39m\n","timestamp":"2025-03-11T23:42:58","type":"err","process_id":11,"app_name":"svg-backend"} 8 | {"message":"\u001b[31m[Nest] 26453 - \u001b[39m03/11/2025, 11:42:58 PM \u001b[31m ERROR\u001b[39m \u001b[38;5;3m[NestApplication] \u001b[39m\u001b[31mError: bind EADDRINUSE 0.0.0.0:3001\u001b[39m\u001b[38;5;3m +4ms\u001b[39m\n","timestamp":"2025-03-11T23:42:58","type":"err","process_id":3,"app_name":"svg-backend"} 9 | {"message":"\u001b[31m[Nest] 26474 - \u001b[39m03/11/2025, 11:42:58 PM \u001b[31m ERROR\u001b[39m \u001b[38;5;3m[NestApplication] \u001b[39m\u001b[31mError: bind EADDRINUSE 0.0.0.0:3001\u001b[39m\u001b[38;5;3m +3ms\u001b[39m\n","timestamp":"2025-03-11T23:42:58","type":"err","process_id":7,"app_name":"svg-backend"} 10 | {"message":"\u001b[31m[Nest] 26479 - \u001b[39m03/11/2025, 11:42:58 PM \u001b[31m ERROR\u001b[39m \u001b[38;5;3m[NestApplication] \u001b[39m\u001b[31mError: bind EADDRINUSE 0.0.0.0:3001\u001b[39m\u001b[38;5;3m +4ms\u001b[39m\n","timestamp":"2025-03-11T23:42:58","type":"err","process_id":8,"app_name":"svg-backend"} 11 | {"message":"\u001b[31m[Nest] 26445 - \u001b[39m03/11/2025, 11:42:58 PM \u001b[31m ERROR\u001b[39m \u001b[38;5;3m[NestApplication] \u001b[39m\u001b[31mError: bind EADDRINUSE 0.0.0.0:3001\u001b[39m\u001b[38;5;3m +4ms\u001b[39m\n","timestamp":"2025-03-11T23:42:58","type":"err","process_id":2,"app_name":"svg-backend"} 12 | {"message":"\u001b[31m[Nest] 26469 - \u001b[39m03/11/2025, 11:42:58 PM \u001b[31m ERROR\u001b[39m \u001b[38;5;3m[NestApplication] \u001b[39m\u001b[31mError: bind EADDRINUSE 0.0.0.0:3001\u001b[39m\u001b[38;5;3m +8ms\u001b[39m\n","timestamp":"2025-03-11T23:42:58","type":"err","process_id":6,"app_name":"svg-backend"} 13 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "src" 4 | ], 5 | "ext": "ts", 6 | "ignore": [ 7 | "src/**/*.spec.ts" 8 | ], 9 | "exec": "node ./index" 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nest-template", 3 | "version": "0.1.0", 4 | "description": "NestJS API project template crafted for Docker environments", 5 | "auuidthor": "Saluki", 6 | "license": "MIT", 7 | "private": true, 8 | "engines": { 9 | "node": ">=20.0.0" 10 | }, 11 | "scripts": { 12 | "clean": "rimraf ./dist", 13 | "start": "node dist/server.js", 14 | "dev": "env-cmd -f .env nodemon", 15 | "build": "npm run clean && tsc", 16 | "test": "env-cmd -f .env jest --config=jest.json", 17 | "lint": "eslint -c .eslintrc.js --ext .ts 'src/**/*.ts'", 18 | "pm2:start": "pm2 start ecosystem.config.js --env production", 19 | "pm2:dev": "pm2 start ecosystem.config.js --env development", 20 | "pm2:stop": "pm2 stop ecosystem.config.js", 21 | "pm2:restart": "pm2 restart ecosystem.config.js", 22 | "pm2:delete": "pm2 delete ecosystem.config.js", 23 | "pm2:logs": "pm2 logs svg-backend", 24 | "send-apology": "env-cmd -f .env ts-node src/scripts/send-apology-emails.ts" 25 | }, 26 | "dependencies": { 27 | "@ai-sdk/anthropic": "^1.1.15", 28 | "@ai-sdk/openai": "^1.2.1", 29 | "@fastify/helmet": "^11.1.1", 30 | "@fastify/static": "^6.12.0", 31 | "@nestjs/common": "^10.3.0", 32 | "@nestjs/core": "^10.3.0", 33 | "@nestjs/platform-fastify": "^10.3.0", 34 | "@nestjs/swagger": "^7.1.17", 35 | "@nestjs/terminus": "^10.2.0", 36 | "@nestjs/testing": "^10.3.0", 37 | "@prisma/client": "^6.4.1", 38 | "ai": "^4.1.54", 39 | "bcrypt": "^5.1.1", 40 | "fastify": "^4.25.2", 41 | "joi": "^17.11.0", 42 | "jsonwebtoken": "^9.0.2", 43 | "nodemailer": "^6.10.0", 44 | "reflect-metadata": "^0.1.12", 45 | "rxjs": "^7.8.1", 46 | "uuid": "^11.1.0", 47 | "winston": "^3.11.0" 48 | }, 49 | "devDependencies": { 50 | "@types/bcrypt": "^5.0.2", 51 | "@types/jest": "^29.5.11", 52 | "@types/jsonwebtoken": "^9.0.5", 53 | "@types/node": "^20.0.0", 54 | "@types/nodemailer": "^6.4.17", 55 | "@types/supertest": "^6.0.2", 56 | "@typescript-eslint/eslint-plugin": "^6.18.1", 57 | "@typescript-eslint/parser": "^6.18.1", 58 | "env-cmd": "^10.1.0", 59 | "eslint": "^8.56.0", 60 | "eslint-plugin-import": "^2.29.1", 61 | "eslint-plugin-prefer-arrow": "^1.2.3", 62 | "husky": "^8.0.3", 63 | "jest": "^29.7.0", 64 | "nodemon": "^3.0.2", 65 | "prisma": "^6.4.1", 66 | "rimraf": "^5.0.5", 67 | "supertest": "^6.3.3", 68 | "ts-jest": "^29.1.1", 69 | "ts-node": "^10.9.2", 70 | "typescript": "^5.3.3" 71 | }, 72 | "husky": { 73 | "hooks": { 74 | "pre-commit": "npm run lint" 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /prisma/migrations/20250302022905_initial/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'USER', 'VIP', 'GUEST'); 3 | 4 | -- CreateTable 5 | CREATE TABLE "users" ( 6 | "id" SERIAL NOT NULL, 7 | "email" TEXT, 8 | "username" TEXT NOT NULL, 9 | "password" TEXT, 10 | "role" "UserRole" NOT NULL DEFAULT 'USER', 11 | "wechat_open_id" TEXT, 12 | "miniapp_open_id" TEXT, 13 | "remaining_credits" INTEGER NOT NULL DEFAULT 2, 14 | "is_invited" BOOLEAN NOT NULL DEFAULT false, 15 | "invited_by" INTEGER, 16 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 17 | "updated_at" TIMESTAMP(3) NOT NULL, 18 | "is_active" BOOLEAN NOT NULL DEFAULT true, 19 | "is_deleted" BOOLEAN NOT NULL DEFAULT false, 20 | "last_login_at" TIMESTAMP(3), 21 | 22 | CONSTRAINT "users_pkey" PRIMARY KEY ("id") 23 | ); 24 | 25 | -- CreateTable 26 | CREATE TABLE "svg_generations" ( 27 | "id" SERIAL NOT NULL, 28 | "user_id" INTEGER NOT NULL, 29 | "input_content" TEXT NOT NULL, 30 | "style" TEXT, 31 | "configuration" JSONB, 32 | "model_names" TEXT[], 33 | "title" TEXT, 34 | "is_public" BOOLEAN NOT NULL DEFAULT false, 35 | "share_token" TEXT, 36 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 37 | "updated_at" TIMESTAMP(3) NOT NULL, 38 | 39 | CONSTRAINT "svg_generations_pkey" PRIMARY KEY ("id") 40 | ); 41 | 42 | -- CreateTable 43 | CREATE TABLE "svg_versions" ( 44 | "id" SERIAL NOT NULL, 45 | "generation_id" INTEGER NOT NULL, 46 | "svg_content" TEXT NOT NULL, 47 | "version_number" INTEGER NOT NULL, 48 | "is_ai_generated" BOOLEAN NOT NULL DEFAULT true, 49 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 50 | 51 | CONSTRAINT "svg_versions_pkey" PRIMARY KEY ("id") 52 | ); 53 | 54 | -- CreateIndex 55 | CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); 56 | 57 | -- CreateIndex 58 | CREATE UNIQUE INDEX "users_wechat_open_id_key" ON "users"("wechat_open_id"); 59 | 60 | -- CreateIndex 61 | CREATE UNIQUE INDEX "users_miniapp_open_id_key" ON "users"("miniapp_open_id"); 62 | 63 | -- CreateIndex 64 | CREATE UNIQUE INDEX "svg_generations_share_token_key" ON "svg_generations"("share_token"); 65 | 66 | -- CreateIndex 67 | CREATE UNIQUE INDEX "svg_versions_generation_id_version_number_key" ON "svg_versions"("generation_id", "version_number"); 68 | 69 | -- AddForeignKey 70 | ALTER TABLE "users" ADD CONSTRAINT "users_invited_by_fkey" FOREIGN KEY ("invited_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; 71 | 72 | -- AddForeignKey 73 | ALTER TABLE "svg_generations" ADD CONSTRAINT "svg_generations_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 74 | 75 | -- AddForeignKey 76 | ALTER TABLE "svg_versions" ADD CONSTRAINT "svg_versions_generation_id_fkey" FOREIGN KEY ("generation_id") REFERENCES "svg_generations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 77 | -------------------------------------------------------------------------------- /prisma/migrations/20250302051014_add_email_verification/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "email_verifications" ( 3 | "id" SERIAL NOT NULL, 4 | "email" TEXT NOT NULL, 5 | "code" TEXT NOT NULL, 6 | "is_used" BOOLEAN NOT NULL DEFAULT false, 7 | "expires_at" TIMESTAMP(3) NOT NULL, 8 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 | 10 | CONSTRAINT "email_verifications_pkey" PRIMARY KEY ("id") 11 | ); 12 | -------------------------------------------------------------------------------- /prisma/migrations/20250302134713_add_aspect_radio/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "svg_generations" ADD COLUMN "aspect_ratio" TEXT; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20250305162747_add_svg_modify_list/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "svg_versions" ADD COLUMN "last_edited_at" TIMESTAMP(3), 3 | ADD COLUMN "last_edited_by" INTEGER, 4 | ADD COLUMN "svg_modify_list" JSONB; 5 | -------------------------------------------------------------------------------- /prisma/migrations/20250311142946_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- This is an empty migration. -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (e.g., Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "postgresql" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model User { 14 | id Int @id @default(autoincrement()) 15 | email String? @unique 16 | username String 17 | password String? 18 | role UserRole @default(USER) 19 | wechatOpenId String? @unique @map("wechat_open_id") 20 | miniappOpenId String? @unique @map("miniapp_open_id") 21 | remainingCredits Int @default(2) @map("remaining_credits") 22 | isInvited Boolean @default(false) @map("is_invited") 23 | invitedBy Int? @map("invited_by") 24 | inviter User? @relation("UserInvitations", fields: [invitedBy], references: [id]) 25 | invitees User[] @relation("UserInvitations") 26 | createdAt DateTime @default(now()) @map("created_at") 27 | updatedAt DateTime @updatedAt @map("updated_at") 28 | isActive Boolean @default(true) @map("is_active") 29 | isDeleted Boolean @default(false) @map("is_deleted") 30 | lastLoginAt DateTime? @map("last_login_at") 31 | svgGenerations SvgGeneration[] 32 | 33 | @@map("users") 34 | } 35 | 36 | model SvgGeneration { 37 | id Int @id @default(autoincrement()) 38 | userId Int @map("user_id") 39 | user User @relation(fields: [userId], references: [id]) 40 | inputContent String @map("input_content") 41 | style String? 42 | aspectRatio String? @map("aspect_ratio") 43 | configuration Json? // Stores additional configuration like size ratio, type, etc. 44 | svgVersions SvgVersion[] // Relationship to SVG versions 45 | modelNames String[] @map("model_names") // Array of model names used for generation 46 | title String? 47 | isPublic Boolean @default(false) @map("is_public") 48 | shareToken String? @unique @map("share_token") 49 | createdAt DateTime @default(now()) @map("created_at") 50 | updatedAt DateTime @updatedAt @map("updated_at") 51 | 52 | @@map("svg_generations") 53 | } 54 | 55 | model SvgVersion { 56 | id Int @id @default(autoincrement()) 57 | generationId Int @map("generation_id") 58 | generation SvgGeneration @relation(fields: [generationId], references: [id]) 59 | svgContent String @map("svg_content") 60 | svgModifyList Json? @map("svg_modify_list") // 存储用户修改历史的列表 61 | versionNumber Int @map("version_number") 62 | isAiGenerated Boolean @default(true) @map("is_ai_generated") 63 | createdAt DateTime @default(now()) @map("created_at") 64 | lastEditedAt DateTime? @map("last_edited_at") 65 | lastEditedBy Int? @map("last_edited_by") 66 | 67 | @@unique([generationId, versionNumber]) 68 | @@map("svg_versions") 69 | } 70 | 71 | model EmailVerification { 72 | id Int @id @default(autoincrement()) 73 | email String 74 | code String 75 | isUsed Boolean @default(false) @map("is_used") 76 | expiresAt DateTime @map("expires_at") 77 | createdAt DateTime @default(now()) @map("created_at") 78 | 79 | @@map("email_verifications") 80 | } 81 | 82 | enum UserRole { 83 | ADMIN 84 | USER 85 | VIP 86 | GUEST 87 | } 88 | -------------------------------------------------------------------------------- /src/modules/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | 3 | import { AuthModule } from "./auth/auth.module"; 4 | import { CommonModule } from "./common"; 5 | import { SvgGeneratorModule } from "./svg-generator/svg-generator.module"; 6 | 7 | @Module({ 8 | imports: [CommonModule, SvgGeneratorModule, AuthModule], 9 | }) 10 | export class ApplicationModule {} 11 | -------------------------------------------------------------------------------- /src/modules/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { Service } from "../../tokens"; 3 | import { CommonModule } from "../common"; 4 | import { AuthController } from "./controller"; 5 | import { AuthService, JwtGuard, JwtStrategy, MailService } from "./service"; 6 | import { MailTemplateService } from "./service/mail-template.service"; 7 | import { TokenBlacklistService } from "./service/token-blacklist.service"; 8 | 9 | @Module({ 10 | imports: [CommonModule], 11 | controllers: [AuthController], 12 | providers: [ 13 | AuthService, 14 | MailService, 15 | MailTemplateService, 16 | JwtStrategy, 17 | JwtGuard, 18 | TokenBlacklistService, 19 | { 20 | provide: Service.CONFIG, 21 | useValue: { 22 | /* 配置值 */ 23 | }, // 或者 useFactory/useClass 24 | }, 25 | ], 26 | exports: [AuthService, JwtGuard], 27 | }) 28 | export class AuthModule {} 29 | -------------------------------------------------------------------------------- /src/modules/auth/controller/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | HttpCode, 6 | HttpStatus, 7 | Post, 8 | Req, 9 | UseGuards, 10 | } from "@nestjs/common"; 11 | import { 12 | ApiBearerAuth, 13 | ApiOperation, 14 | ApiResponse, 15 | ApiTags, 16 | } from "@nestjs/swagger"; 17 | 18 | import { FastifyRequest } from "fastify"; 19 | import { LoggerService } from "../../common"; 20 | import { UserData } from "../../svg-generator/model"; 21 | import { Public } from "../decorator/public.decorator"; 22 | import { 23 | LoginPipe, 24 | RegisterPipe, 25 | SendVerificationCodePipe, 26 | VerifyEmailPipe, 27 | } from "../flow"; 28 | import { 29 | AuthResponseDto, 30 | LoginDto, 31 | RegisterDto, 32 | SendVerificationCodeDto, 33 | VerifyEmailDto, 34 | } from "../model"; 35 | import { AuthService, JwtGuard } from "../service"; 36 | 37 | interface RequestWithUser extends FastifyRequest { 38 | user: UserData; 39 | token?: string; 40 | } 41 | 42 | @Controller("auth") 43 | @ApiTags("认证") 44 | export class AuthController { 45 | public constructor( 46 | private readonly logger: LoggerService, 47 | private readonly authService: AuthService 48 | ) {} 49 | 50 | @Public() 51 | @Post("register") 52 | @ApiOperation({ summary: "用户注册" }) 53 | @ApiResponse({ status: HttpStatus.CREATED, type: AuthResponseDto }) 54 | public async register( 55 | @Body(RegisterPipe) registerDto: RegisterDto 56 | ): Promise { 57 | this.logger.info( 58 | `用户注册: ${registerDto.username} (${registerDto.email})` 59 | ); 60 | return this.authService.register(registerDto); 61 | } 62 | 63 | @Public() 64 | @Post("login") 65 | @HttpCode(HttpStatus.OK) 66 | @ApiOperation({ summary: "用户登录" }) 67 | @ApiResponse({ status: HttpStatus.OK, type: AuthResponseDto }) 68 | public async login( 69 | @Body(LoginPipe) loginDto: LoginDto 70 | ): Promise { 71 | this.logger.info(`用户登录尝试: ${loginDto.emailOrUsername}`); 72 | return this.authService.login(loginDto); 73 | } 74 | 75 | @Post("logout") 76 | @UseGuards(JwtGuard) 77 | @ApiBearerAuth() 78 | @HttpCode(HttpStatus.OK) 79 | @ApiOperation({ summary: "用户退出登录" }) 80 | @ApiResponse({ 81 | status: HttpStatus.OK, 82 | schema: { 83 | type: "object", 84 | properties: { 85 | success: { type: "boolean" }, 86 | message: { type: "string" }, 87 | }, 88 | }, 89 | }) 90 | public logout(@Req() request: RequestWithUser): { 91 | success: boolean; 92 | message: string; 93 | } { 94 | this.logger.info(`用户登出: ${request.user.id}`); 95 | 96 | if (!request.token) { 97 | return { 98 | success: false, 99 | message: "无法获取认证令牌", 100 | }; 101 | } 102 | 103 | return this.authService.logout(request.token); 104 | } 105 | 106 | @Get("me") 107 | @UseGuards(JwtGuard) 108 | @ApiBearerAuth() 109 | @HttpCode(HttpStatus.OK) 110 | @ApiOperation({ summary: "获取当前登录用户信息" }) 111 | @ApiResponse({ status: HttpStatus.OK, type: UserData }) 112 | public getCurrentUser(@Req() request: RequestWithUser): UserData { 113 | this.logger.info(`获取用户信息: ${request.user.id}`); 114 | return request.user; 115 | } 116 | 117 | @Public() 118 | @Post("verification-code") 119 | @HttpCode(HttpStatus.OK) 120 | @ApiOperation({ summary: "发送邮箱验证码" }) 121 | @ApiResponse({ status: HttpStatus.OK, type: Boolean }) 122 | public async sendVerificationCode( 123 | @Body(SendVerificationCodePipe) dto: SendVerificationCodeDto 124 | ): Promise { 125 | this.logger.info(`发送验证码到邮箱: ${dto.email}`); 126 | return this.authService.sendVerificationCode(dto.email); 127 | } 128 | 129 | @Public() 130 | @Post("verify-email") 131 | @HttpCode(HttpStatus.OK) 132 | @ApiOperation({ summary: "验证邮箱验证码" }) 133 | @ApiResponse({ status: HttpStatus.OK, type: Boolean }) 134 | public async verifyEmail( 135 | @Body(VerifyEmailPipe) dto: VerifyEmailDto 136 | ): Promise { 137 | this.logger.info(`验证邮箱: ${dto.email}`); 138 | return this.authService.verifyEmailCode(dto.email, dto.code); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/modules/auth/controller/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./auth.controller"; 2 | -------------------------------------------------------------------------------- /src/modules/auth/decorator/public.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from "@nestjs/common"; 2 | 3 | /** 4 | * 标记一个路由为公开,不需要认证 5 | * 使用方法:@Public() 6 | */ 7 | export const IS_PUBLIC_KEY = "isPublic"; 8 | export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); 9 | -------------------------------------------------------------------------------- /src/modules/auth/flow/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./login.pipe"; 2 | export * from "./register.pipe"; 3 | export * from "./send-verification-code.pipe"; 4 | export * from "./verify-email.pipe"; 5 | -------------------------------------------------------------------------------- /src/modules/auth/flow/login.pipe.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from "joi"; 2 | import { JoiValidationPipe } from "../../common"; 3 | import { LoginDto } from "../model"; 4 | 5 | export class LoginPipe extends JoiValidationPipe { 6 | public buildSchema(): Joi.Schema { 7 | return Joi.object({ 8 | emailOrUsername: Joi.string().required(), 9 | password: Joi.string().required(), 10 | }); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/auth/flow/register.pipe.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from "joi"; 2 | import { JoiValidationPipe } from "../../common"; 3 | import { UserData } from "../../svg-generator/model"; 4 | import { RegisterDto } from "../model"; 5 | 6 | export class RegisterPipe extends JoiValidationPipe { 7 | public buildSchema(): Joi.Schema { 8 | return Joi.object({ 9 | username: Joi.string().required().max(UserData.USERNAME_LENGTH), 10 | email: Joi.string().email().required(), 11 | password: Joi.string().min(6).required(), 12 | verificationCode: Joi.string().required().length(6), 13 | inviteCode: Joi.string().optional(), 14 | }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/auth/flow/send-verification-code.pipe.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from "joi"; 2 | import { JoiValidationPipe } from "../../common"; 3 | import { SendVerificationCodeDto } from "../model"; 4 | 5 | export class SendVerificationCodePipe extends JoiValidationPipe { 6 | public buildSchema(): Joi.Schema { 7 | return Joi.object({ 8 | email: Joi.string().email().required(), 9 | }); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/auth/flow/verify-email.pipe.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from "joi"; 2 | import { JoiValidationPipe } from "../../common"; 3 | import { VerifyEmailDto } from "../model"; 4 | 5 | export class VerifyEmailPipe extends JoiValidationPipe { 6 | public buildSchema(): Joi.Schema { 7 | return Joi.object({ 8 | email: Joi.string().email().required(), 9 | code: Joi.string().required().length(6), 10 | }); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/auth/model/auth-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { UserData } from "../../svg-generator/model"; 3 | 4 | export class AuthResponseDto { 5 | @ApiProperty({ 6 | description: "认证令牌", 7 | example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", 8 | }) 9 | public readonly token: string; 10 | 11 | @ApiProperty({ description: "用户信息" }) 12 | public readonly user: UserData; 13 | } 14 | -------------------------------------------------------------------------------- /src/modules/auth/model/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./auth-response.dto"; 2 | export * from "./login.dto"; 3 | export * from "./mail-config"; 4 | export * from "./register.dto"; 5 | export * from "./send-verification-code.dto"; 6 | export * from "./verify-email.dto"; 7 | -------------------------------------------------------------------------------- /src/modules/auth/model/login.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | 3 | export class LoginDto { 4 | @ApiProperty({ 5 | description: "邮箱地址或用户名", 6 | example: "snailrun160@gmail.com", 7 | }) 8 | public readonly emailOrUsername: string; 9 | 10 | @ApiProperty({ description: "密码", example: "password123" }) 11 | public readonly password: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/auth/model/mail-config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 邮件发送配置 3 | */ 4 | export class MailConfig { 5 | /** SMTP服务器主机 */ 6 | public readonly host: string; 7 | 8 | /** SMTP服务器端口 */ 9 | public readonly port: number; 10 | 11 | /** 是否使用SSL */ 12 | public readonly secure: boolean; 13 | 14 | /** 用户名/邮箱地址 */ 15 | public readonly user: string; 16 | 17 | /** 密码或授权码 */ 18 | public readonly pass: string; 19 | 20 | /** 发件人名称 */ 21 | public readonly senderName: string; 22 | 23 | public constructor() { 24 | this.host = process.env.MAIL_HOST || "smtp.qq.com"; 25 | this.port = parseInt(process.env.MAIL_PORT || "465", 10); 26 | this.secure = process.env.MAIL_SECURE !== "false"; 27 | this.user = process.env.MAIL_USER || "3074994545@qq.com"; 28 | this.pass = process.env.MAIL_PASS || ""; 29 | this.senderName = process.env.MAIL_SENDER_NAME || "SVG秀"; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/modules/auth/model/register.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | 3 | export class RegisterDto { 4 | @ApiProperty({ description: "用户名", example: "luckySnail" }) 5 | public readonly username: string; 6 | 7 | @ApiProperty({ description: "邮箱地址", example: "snailrun160@gmail.com" }) 8 | public readonly email: string; 9 | 10 | @ApiProperty({ description: "密码", example: "password123" }) 11 | public readonly password: string; 12 | 13 | @ApiProperty({ description: "邮箱验证码", example: "123456" }) 14 | public readonly verificationCode: string; 15 | 16 | @ApiProperty({ 17 | description: "邀请码(邀请人ID)", 18 | example: "1", 19 | required: false, 20 | }) 21 | public readonly inviteCode?: string; 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/auth/model/send-verification-code.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | 3 | export class SendVerificationCodeDto { 4 | @ApiProperty({ description: "邮箱地址", example: "snailrun160@gmail.com" }) 5 | public readonly email: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/modules/auth/model/verify-email.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | 3 | export class VerifyEmailDto { 4 | @ApiProperty({ description: "邮箱地址", example: "snailrun160@gmail.com" }) 5 | public readonly email: string; 6 | 7 | @ApiProperty({ description: "验证码", example: "123456" }) 8 | public readonly code: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/modules/auth/service/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ConflictException, 3 | Injectable, 4 | NotFoundException, 5 | UnauthorizedException, 6 | } from "@nestjs/common"; 7 | import * as bcrypt from "bcrypt"; 8 | import * as jwt from "jsonwebtoken"; 9 | import { v4 as uuidv4 } from "uuid"; 10 | import { PrismaService } from "../../common"; 11 | import { UserData } from "../../svg-generator/model"; 12 | import { AuthResponseDto, LoginDto, RegisterDto } from "../model"; 13 | import { MailService } from "./mail.service"; 14 | import { TokenBlacklistService } from "./token-blacklist.service"; 15 | 16 | @Injectable() 17 | export class AuthService { 18 | private readonly JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; 19 | private readonly SALT_ROUNDS = 10; 20 | 21 | public constructor( 22 | private readonly prismaService: PrismaService, 23 | private readonly mailService: MailService, 24 | private readonly tokenBlacklistService: TokenBlacklistService 25 | ) {} 26 | 27 | /** 28 | * 发送邮箱验证码 29 | * 30 | * @param email 邮箱地址 31 | * @returns 是否发送成功 32 | */ 33 | public async sendVerificationCode(email: string): Promise { 34 | // 检查邮箱是否已被注册 35 | const existingUser = await this.prismaService.user.findUnique({ 36 | where: { email }, 37 | }); 38 | 39 | if (existingUser) { 40 | throw new ConflictException("邮箱已被注册"); 41 | } 42 | 43 | const code = this.mailService.generateVerificationCode(); 44 | 45 | // 存储验证码到数据库 46 | await this.prismaService.emailVerification.create({ 47 | data: { 48 | email, 49 | code, 50 | expiresAt: new Date(Date.now() + 30 * 60 * 1000), // 30分钟有效期 51 | }, 52 | }); 53 | 54 | // 发送验证码到邮箱 55 | return this.mailService.sendVerificationEmail(email, code); 56 | } 57 | 58 | /** 59 | * 验证邮箱验证码 60 | * 61 | * @param email 邮箱地址 62 | * @param code 验证码 63 | * @returns 是否验证成功 64 | */ 65 | public async verifyEmailCode( 66 | email: string, 67 | code: string 68 | ): Promise { 69 | const verification = 70 | (await this.prismaService.emailVerification.findFirst({ 71 | where: { 72 | email, 73 | code, 74 | isUsed: false, 75 | expiresAt: { 76 | gt: new Date(), 77 | }, 78 | }, 79 | })) as { 80 | email: string; 81 | code: string; 82 | isUsed: boolean; 83 | expiresAt: Date; 84 | } | null; 85 | 86 | if (!verification) { 87 | return false; 88 | } 89 | 90 | return true; 91 | } 92 | 93 | /** 94 | * 注册新用户 95 | * 96 | * @param registerDto 注册信息 97 | * @returns 注册结果,包含用户信息和token 98 | */ 99 | public async register(registerDto: RegisterDto): Promise { 100 | const { username, email, password, verificationCode, inviteCode } = 101 | registerDto; 102 | 103 | // 验证邮箱和验证码 104 | const isValidCode = await this.verifyEmailCode(email, verificationCode); 105 | if (!isValidCode) { 106 | throw new UnauthorizedException("验证码无效或已过期"); 107 | } 108 | 109 | // 检查用户名是否已被使用 110 | const existingUsername = await this.prismaService.user.findFirst({ 111 | where: { username }, 112 | }); 113 | if (existingUsername) { 114 | throw new ConflictException("用户名已被使用"); 115 | } 116 | 117 | // 验证邀请码 118 | let inviterId: number | null = null; 119 | let isInvited = false; 120 | console.log(inviteCode, "inviteCode"); 121 | 122 | if (inviteCode) { 123 | inviterId = parseInt(inviteCode, 10); 124 | if (isNaN(inviterId)) { 125 | throw new UnauthorizedException("无效的邀请码格式"); 126 | } 127 | 128 | // 查找邀请人 129 | const inviter = await this.prismaService.user.findUnique({ 130 | where: { id: inviterId }, 131 | }); 132 | 133 | if (!inviter) { 134 | throw new NotFoundException("邀请人不存在"); 135 | } 136 | 137 | isInvited = true; 138 | } 139 | 140 | // 密码加密 141 | const hashedPassword = await bcrypt.hash(password, this.SALT_ROUNDS); 142 | 143 | // 创建用户 144 | const user = await this.prismaService.user.create({ 145 | data: { 146 | username: username ?? uuidv4().slice(0, 10), 147 | email, 148 | password: hashedPassword, 149 | role: "USER", 150 | remainingCredits: 2, 151 | isInvited, 152 | invitedBy: inviterId, 153 | }, 154 | }); 155 | 156 | // 如果是通过邀请注册的,更新邀请人的积分 157 | if (isInvited && inviterId) { 158 | await this.prismaService.user.update({ 159 | where: { id: inviterId }, 160 | data: { 161 | remainingCredits: { 162 | increment: 2, // 给邀请人增加2点积分 163 | }, 164 | }, 165 | }); 166 | } 167 | 168 | // 将验证码标记为已使用 169 | await this.prismaService.emailVerification.updateMany({ 170 | where: { email, code: verificationCode }, 171 | data: { isUsed: true }, 172 | }); 173 | 174 | // 生成JWT token 175 | const token = this.generateToken(user.id); 176 | 177 | return { 178 | user: new UserData(user), 179 | token, 180 | }; 181 | } 182 | 183 | /** 184 | * 用户登录 185 | * 186 | * @param loginDto 登录信息 187 | * @returns 登录结果,包含用户信息和token 188 | */ 189 | public async login(loginDto: LoginDto): Promise { 190 | const { emailOrUsername, password } = loginDto; 191 | // 查找用户 192 | const user = await this.prismaService.user.findFirst({ 193 | where: { 194 | OR: [{ email: emailOrUsername }, { username: emailOrUsername }], 195 | }, 196 | }); 197 | 198 | if (!user) { 199 | throw new NotFoundException("用户不存在"); 200 | } 201 | 202 | // 验证密码 203 | const isPasswordValid = await bcrypt.compare( 204 | password, 205 | user.password ?? "" 206 | ); 207 | if (!isPasswordValid) { 208 | throw new UnauthorizedException("密码错误"); 209 | } 210 | 211 | // 更新最后登录时间 212 | await this.prismaService.user.update({ 213 | where: { id: user.id }, 214 | data: { lastLoginAt: new Date() }, 215 | }); 216 | 217 | // 生成JWT token 218 | const token = this.generateToken(user.id); 219 | 220 | return { 221 | user: new UserData(user), 222 | token, 223 | }; 224 | } 225 | 226 | /** 227 | * 用户登出 228 | * 229 | * @param token JWT令牌 230 | * @returns 是否成功登出 231 | */ 232 | public logout(token: string): { success: boolean; message: string } { 233 | // 将token加入黑名单 234 | const success = this.tokenBlacklistService.addToBlacklist(token); 235 | 236 | if (success) { 237 | return { 238 | success: true, 239 | message: "已成功登出", 240 | }; 241 | } 242 | 243 | return { 244 | success: false, 245 | message: "登出失败,无法使令牌失效", 246 | }; 247 | } 248 | 249 | /** 250 | * 生成JWT令牌 251 | * 252 | * @param userId 用户ID 253 | * @returns JWT令牌 254 | */ 255 | private generateToken(userId: number): string { 256 | const payload = { sub: userId }; 257 | return jwt.sign(payload, this.JWT_SECRET, { expiresIn: "30d" }); 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /src/modules/auth/service/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./auth.service"; 2 | export * from "./jwt.guard"; 3 | export * from "./jwt.strategy"; 4 | export * from "./mail-template.service"; 5 | export * from "./mail.service"; 6 | export * from "./token-blacklist.service"; 7 | -------------------------------------------------------------------------------- /src/modules/auth/service/jwt.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | Injectable, 5 | UnauthorizedException, 6 | } from "@nestjs/common"; 7 | import { Reflector } from "@nestjs/core"; 8 | import { FastifyRequest } from "fastify"; 9 | import { UserData } from "../../svg-generator/model"; 10 | import { IS_PUBLIC_KEY } from "../decorator/public.decorator"; 11 | import { JwtStrategy } from "./jwt.strategy"; 12 | import { TokenBlacklistService } from "./token-blacklist.service"; 13 | 14 | interface RequestWithUser extends FastifyRequest { 15 | user: UserData; 16 | token?: string; 17 | } 18 | 19 | @Injectable() 20 | export class JwtGuard implements CanActivate { 21 | public constructor( 22 | private readonly jwtStrategy: JwtStrategy, 23 | private readonly tokenBlacklistService: TokenBlacklistService, 24 | private readonly reflector: Reflector 25 | ) {} 26 | 27 | /** 28 | * 验证请求是否包含有效的JWT令牌 29 | * 30 | * @param context 执行上下文 31 | * @returns 是否通过验证 32 | */ 33 | public async canActivate(context: ExecutionContext): Promise { 34 | // 检查是否为公开API,若是则跳过验证 35 | const isPublic = this.reflector.getAllAndOverride( 36 | IS_PUBLIC_KEY, 37 | [context.getHandler(), context.getClass()] 38 | ); 39 | 40 | if (isPublic) { 41 | return true; 42 | } 43 | 44 | const request = context.switchToHttp().getRequest(); 45 | const token = this.extractTokenFromHeader(request); 46 | 47 | if (!token) { 48 | throw new UnauthorizedException("缺少认证令牌"); 49 | } 50 | 51 | // 检查令牌是否在黑名单中 52 | if (this.tokenBlacklistService.isBlacklisted(token)) { 53 | throw new UnauthorizedException("令牌已失效,请重新登录"); 54 | } 55 | 56 | try { 57 | // 验证令牌并获取用户信息 58 | const user = await this.jwtStrategy.validate(token); 59 | // 将用户信息添加到请求对象 60 | request.user = user; 61 | // 将原始token附加到请求对象,以便可能的注销操作 62 | request.token = token; 63 | return true; 64 | } catch (error) { 65 | throw new UnauthorizedException("无效的认证令牌"); 66 | } 67 | } 68 | 69 | /** 70 | * 从请求头中提取JWT令牌 71 | * 72 | * @param request 请求对象 73 | * @returns JWT令牌 74 | */ 75 | private extractTokenFromHeader( 76 | request: FastifyRequest 77 | ): string | undefined { 78 | const authHeader = request.headers.authorization; 79 | if (!authHeader || typeof authHeader !== "string") { 80 | return undefined; 81 | } 82 | 83 | const [type, token] = authHeader.split(" "); 84 | return type === "Bearer" ? token : undefined; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/modules/auth/service/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from "@nestjs/common"; 2 | import * as jwt from "jsonwebtoken"; 3 | import { PrismaService } from "../../common"; 4 | import { UserData } from "../../svg-generator/model"; 5 | 6 | interface CustomJwtPayload { 7 | sub: number; 8 | iat?: number; 9 | exp?: number; 10 | } 11 | 12 | @Injectable() 13 | export class JwtStrategy { 14 | private readonly JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; 15 | 16 | public constructor(private readonly prismaService: PrismaService) {} 17 | 18 | /** 19 | * 验证JWT令牌并返回用户信息 20 | * 21 | * @param token JWT令牌 22 | * @returns 用户信息 23 | */ 24 | public async validate(token: string): Promise { 25 | try { 26 | // 验证令牌 27 | const decoded = jwt.verify(token, this.JWT_SECRET); 28 | // 安全地进行类型转换 29 | const payload = decoded as unknown as CustomJwtPayload; 30 | const userId = payload.sub; 31 | 32 | // 查找用户 33 | const user = await this.prismaService.user.findUnique({ 34 | where: { id: userId }, 35 | }); 36 | 37 | if (!user || !user.isActive || user.isDeleted) { 38 | throw new UnauthorizedException("无效的用户"); 39 | } 40 | 41 | return new UserData(user); 42 | } catch (error) { 43 | throw new UnauthorizedException("无效的令牌"); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/modules/auth/service/mail-template.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | 3 | /** 4 | * 邮件模板服务 5 | */ 6 | @Injectable() 7 | export class MailTemplateService { 8 | /** 9 | * 生成验证码邮件模板 10 | * 11 | * @param code 验证码 12 | * @returns HTML 格式的邮件内容 13 | */ 14 | public getVerificationCodeTemplate(code: string): string { 15 | const currentYear = new Date().getFullYear(); 16 | 17 | return ` 18 | 19 | 20 | 21 | 22 | 23 | 邮箱验证码 24 | 81 | 82 | 83 |
84 |
85 | 86 |
87 |
88 |

尊敬的用户:

89 |

您好!感谢您使用 SVG 生成器。您正在进行邮箱验证,请使用以下验证码完成操作:

90 | 91 |
92 |
${code}
93 |
94 | 95 |

此验证码将在 30 分钟 后过期。

96 | 97 |
98 |

若您没有请求此验证码,请忽略此邮件。这可能是有人误输入了您的邮箱地址。

99 |

请勿回复此邮件,此邮箱不接受回复邮件。

100 |
101 |
102 | 105 |
106 | 107 | 108 | `; 109 | } 110 | 111 | /** 112 | * 获取文本格式的验证码内容 113 | * 114 | * @param code 验证码 115 | * @returns 纯文本格式的邮件内容 116 | */ 117 | public getVerificationCodeText(code: string): string { 118 | return ` 119 | SVG 生成器 - 邮箱验证码 120 | 121 | 尊敬的用户: 122 | 123 | 您好!您正在进行 SVG 生成器的邮箱验证,您的验证码是:${code} 124 | 125 | 此验证码将在 30 分钟后过期。 126 | 127 | 若您没有请求此验证码,请忽略此邮件。 128 | 请勿回复此邮件,此邮箱不接受回复邮件。 129 | 130 | © ${new Date().getFullYear()} SVG 生成器团队 131 | `; 132 | } 133 | 134 | /** 135 | * 生成道歉邮件模板 136 | * 137 | * @returns HTML 格式的邮件内容 138 | */ 139 | public getApologyTemplate(): string { 140 | const currentYear = new Date().getFullYear(); 141 | 142 | return ` 143 | 144 | 145 | 146 | 147 | 148 | 服务恢复通知 149 | 197 | 198 | 199 |
200 |
201 | 202 |
203 |
204 |

尊敬的用户:

205 |

您好!非常抱歉地通知您,我们的 Claude 服务商之前出现了一些技术问题,这可能影响了您使用我们服务的体验。

206 | 207 |

我们很高兴地告知您,目前所有服务已经完全恢复正常

208 | 209 |
210 | 如需继续使用我们的服务,欢迎添加微信:RELEASE500,我们将免费赠送您 3 次使用机会作为补偿。 211 |
212 | 213 |

感谢您的理解与支持,我们将继续努力提供更稳定、更优质的服务。

214 |
215 | 218 |
219 | 220 | 221 | `; 222 | } 223 | 224 | /** 225 | * 获取文本格式的道歉邮件内容 226 | * 227 | * @returns 纯文本格式的邮件内容 228 | */ 229 | public getApologyText(): string { 230 | return ` 231 | SVG 生成器 - 服务恢复通知 232 | 233 | 尊敬的用户: 234 | 235 | 您好!非常抱歉地通知您,我们的 Claude 服务商之前出现了一些技术问题,这可能影响了您使用我们服务的体验。 236 | 237 | 我们很高兴地告知您,目前所有服务已经完全恢复正常。 238 | 239 | 如需继续使用我们的服务,欢迎添加微信,我们将免费赠送您 3 次使用机会作为补偿。 240 | 241 | 感谢您的理解与支持,我们将继续努力提供更稳定、更优质的服务。 242 | 243 | © ${new Date().getFullYear()} SVG 生成器团队 244 | `; 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/modules/auth/service/mail.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from "@nestjs/common"; 2 | import * as nodemailer from "nodemailer"; 3 | import { MailConfig } from "../model"; 4 | import { MailTemplateService } from "./mail-template.service"; 5 | 6 | @Injectable() 7 | export class MailService { 8 | private readonly logger = new Logger(MailService.name); 9 | private readonly transporter: nodemailer.Transporter; 10 | private readonly mailConfig: MailConfig; 11 | 12 | /** 13 | * 初始化邮件服务 14 | */ 15 | public constructor( 16 | private readonly mailTemplateService: MailTemplateService 17 | ) { 18 | // 初始化邮件配置 19 | this.mailConfig = new MailConfig(); 20 | 21 | // 创建Nodemailer传输器 22 | this.transporter = nodemailer.createTransport({ 23 | host: this.mailConfig.host, 24 | port: this.mailConfig.port, 25 | secure: this.mailConfig.secure, 26 | auth: { 27 | user: this.mailConfig.user, 28 | pass: this.mailConfig.pass, 29 | }, 30 | }); 31 | 32 | // 启动时验证SMTP配置 33 | void this.verifyConnection(); 34 | } 35 | 36 | /** 37 | * 生成随机的6位数验证码 38 | * 39 | * @returns 6位数验证码 40 | */ 41 | public generateVerificationCode(): string { 42 | return Math.floor(100000 + Math.random() * 900000).toString(); 43 | } 44 | 45 | /** 46 | * 发送验证码邮件 47 | * 48 | * @param email 收件人邮箱 49 | * @param code 验证码 50 | * @returns 是否发送成功 51 | */ 52 | public async sendVerificationEmail( 53 | email: string, 54 | code: string 55 | ): Promise { 56 | try { 57 | // 获取邮件模板 58 | const htmlContent = 59 | this.mailTemplateService.getVerificationCodeTemplate(code); 60 | const textContent = 61 | this.mailTemplateService.getVerificationCodeText(code); 62 | 63 | // 准备邮件内容 64 | const mailOptions = { 65 | from: `"${this.mailConfig.senderName}" <${this.mailConfig.user}>`, 66 | to: email, 67 | subject: "您的验证码 - SVG生成器", 68 | text: textContent, 69 | html: htmlContent, 70 | }; 71 | 72 | // 尝试发送邮件 73 | try { 74 | await this.transporter.sendMail(mailOptions); 75 | this.logger.log(`成功发送验证码到邮箱: ${email}`); 76 | return true; 77 | } catch (error: unknown) { 78 | const errorMessage = 79 | error instanceof Error ? error.message : "未知错误"; 80 | this.logger.error(`使用SMTP发送邮件失败: ${errorMessage}`); 81 | 82 | // 如果SMTP发送失败,使用控制台模拟发送(开发/测试环境) 83 | if (process.env.NODE_ENV !== "production") { 84 | this.logger.log( 85 | `[模拟发送] 发送验证码 ${code} 到邮箱 ${email}` 86 | ); 87 | return true; 88 | } 89 | return false; 90 | } 91 | } catch (error: unknown) { 92 | this.logger.error("发送邮件失败:", error); 93 | return false; 94 | } 95 | } 96 | /** 97 | * 验证SMTP连接配置 98 | * 99 | * @private 100 | */ 101 | private async verifyConnection(): Promise { 102 | try { 103 | await this.transporter.verify(); 104 | this.logger.log("SMTP服务器连接成功"); 105 | } catch (error: unknown) { 106 | const errorMessage = 107 | error instanceof Error ? error.message : "未知错误"; 108 | this.logger.warn(`SMTP服务器连接失败: ${errorMessage}`); 109 | this.logger.warn("邮件功能将使用控制台模拟发送"); 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/modules/auth/service/token-blacklist.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from "@nestjs/common"; 2 | import * as jwt from "jsonwebtoken"; 3 | 4 | /** 5 | * Token黑名单服务 6 | * 用于管理已失效的JWT令牌 7 | */ 8 | @Injectable() 9 | export class TokenBlacklistService { 10 | private readonly logger = new Logger(TokenBlacklistService.name); 11 | private readonly blacklist: Map = new Map(); // token -> expiry timestamp 12 | 13 | /** 14 | * 初始化定时清理任务 15 | */ 16 | public constructor() { 17 | // 每小时清理一次过期的黑名单token 18 | setInterval(() => this.cleanupExpiredTokens(), 60 * 60 * 1000); 19 | } 20 | 21 | /** 22 | * 将令牌加入黑名单 23 | * 24 | * @param token JWT令牌 25 | * @returns 是否成功加入黑名单 26 | */ 27 | public addToBlacklist(token: string): boolean { 28 | try { 29 | // 解析令牌以获取过期时间 30 | const decoded = jwt.decode(token) as { exp?: number }; 31 | 32 | if (!decoded || !decoded.exp) { 33 | this.logger.warn("无法解析令牌或令牌没有过期时间"); 34 | return false; 35 | } 36 | 37 | // 存储令牌和过期时间 38 | this.blacklist.set(token, decoded.exp); 39 | this.logger.log( 40 | `令牌已加入黑名单,将在 ${new Date( 41 | decoded.exp * 1000 42 | ).toISOString()} 过期` 43 | ); 44 | return true; 45 | } catch (error) { 46 | this.logger.error("将令牌加入黑名单时出错", error); 47 | return false; 48 | } 49 | } 50 | 51 | /** 52 | * 检查令牌是否在黑名单中 53 | * 54 | * @param token JWT令牌 55 | * @returns 是否在黑名单中 56 | */ 57 | public isBlacklisted(token: string): boolean { 58 | return this.blacklist.has(token); 59 | } 60 | 61 | /** 62 | * 清理过期的黑名单令牌 63 | * 64 | * @private 65 | */ 66 | private cleanupExpiredTokens(): void { 67 | const now = Math.floor(Date.now() / 1000); 68 | let expiredCount = 0; 69 | 70 | for (const [token, expiry] of this.blacklist.entries()) { 71 | if (expiry < now) { 72 | this.blacklist.delete(token); 73 | expiredCount++; 74 | } 75 | } 76 | 77 | if (expiredCount > 0) { 78 | this.logger.log(`已清理 ${expiredCount} 个过期的黑名单令牌`); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/modules/common/common.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TerminusModule } from '@nestjs/terminus'; 3 | 4 | import { HealthController } from './controller'; 5 | import { LogInterceptor } from './flow'; 6 | import { configProvider, LoggerService, PrismaService } from './provider'; 7 | 8 | @Module({ 9 | imports: [ 10 | TerminusModule 11 | ], 12 | providers: [ 13 | configProvider, 14 | LoggerService, 15 | LogInterceptor, 16 | PrismaService 17 | ], 18 | exports: [ 19 | configProvider, 20 | LoggerService, 21 | LogInterceptor, 22 | PrismaService 23 | ], 24 | controllers: [ 25 | HealthController 26 | ], 27 | }) 28 | export class CommonModule {} 29 | -------------------------------------------------------------------------------- /src/modules/common/controller/health.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, UseGuards } from '@nestjs/common'; 2 | import { HealthCheckService, PrismaHealthIndicator } from '@nestjs/terminus'; 3 | 4 | import { PrismaService } from '../provider'; 5 | import { HealthGuard } from '../security/health.guard'; 6 | 7 | @Controller('health') 8 | export class HealthController { 9 | 10 | public constructor( 11 | private readonly health: HealthCheckService, 12 | private readonly database: PrismaHealthIndicator, 13 | private readonly prisma: PrismaService 14 | ) {} 15 | 16 | @Get() 17 | @UseGuards(HealthGuard) 18 | public async healthCheck() { 19 | 20 | return this.health.check([ 21 | async () => this.database.pingCheck('database', this.prisma), 22 | () => ({ 23 | http: { 24 | status: 'up', 25 | uptime: process.uptime() 26 | } 27 | }) 28 | ]); 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/modules/common/controller/index.ts: -------------------------------------------------------------------------------- 1 | export { HealthController } from './health.controller'; 2 | -------------------------------------------------------------------------------- /src/modules/common/flow/index.ts: -------------------------------------------------------------------------------- 1 | export { LogInterceptor } from './log.interceptor'; 2 | export { JoiValidationPipe } from './joi-validation.pipe'; 3 | -------------------------------------------------------------------------------- /src/modules/common/flow/joi-validation.pipe.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus, Injectable, PipeTransform } from '@nestjs/common'; 2 | import * as Joi from 'joi'; 3 | 4 | @Injectable() 5 | export abstract class JoiValidationPipe implements PipeTransform { 6 | 7 | public transform(value: unknown): unknown { 8 | 9 | const result = this.buildSchema().validate(value); 10 | 11 | if (result.error) { 12 | throw new HttpException({ 13 | message: 'Validation failed', 14 | detail: result.error.message.replace(/"/g, '\''), 15 | statusCode: HttpStatus.BAD_REQUEST 16 | }, HttpStatus.BAD_REQUEST); 17 | } 18 | 19 | return result.value; 20 | } 21 | 22 | public abstract buildSchema(): Joi.Schema; 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/common/flow/log.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, HttpStatus, Injectable, NestInterceptor } from '@nestjs/common'; 2 | import { FastifyRequest, FastifyReply } from 'fastify'; 3 | import { Observable, throwError } from 'rxjs'; 4 | import { catchError, map } from 'rxjs/operators'; 5 | 6 | import { LoggerService } from '../provider'; 7 | 8 | @Injectable() 9 | export class LogInterceptor implements NestInterceptor { 10 | 11 | public constructor( 12 | private readonly logger: LoggerService 13 | ) { } 14 | 15 | public intercept(context: ExecutionContext, next: CallHandler): Observable { 16 | 17 | const startTime = new Date().getTime(); 18 | const request = context.switchToHttp().getRequest(); 19 | 20 | return next.handle().pipe( 21 | map((data: FastifyReply) => { 22 | const responseStatus = (request.method === 'POST') ? HttpStatus.CREATED : HttpStatus.OK; 23 | this.logger.info(`${this.getTimeDelta(startTime)}ms ${request.ip} ${responseStatus} ${request.method} ${this.getUrl(request)}`); 24 | return data; 25 | }), 26 | catchError((err: unknown) => { 27 | // Log fomat inspired by the Squid docs 28 | // See https://docs.trafficserver.apache.org/en/6.1.x/admin-guide/monitoring/logging/log-formats.en.html 29 | const status = this.hasStatus(err) ? err.status : 'XXX'; 30 | this.logger.error(`${this.getTimeDelta(startTime)}ms ${request.ip} ${status} ${request.method} ${this.getUrl(request)}`); 31 | return throwError(err); 32 | }) 33 | ); 34 | } 35 | 36 | private getTimeDelta(startTime: number): number { 37 | return new Date().getTime() - startTime; 38 | } 39 | 40 | private getUrl(request: FastifyRequest): string { 41 | return `${request.protocol}://${request.hostname}${request.originalUrl}`; 42 | } 43 | 44 | private hasStatus(err: unknown): err is { status: number } { 45 | return (err as { status: number })?.status !== undefined && typeof (err as { status: number }).status === 'number'; 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /src/modules/common/index.ts: -------------------------------------------------------------------------------- 1 | export { CommonModule } from "./common.module"; 2 | 3 | export * from "./flow"; 4 | export * from "./model"; 5 | export * from "./provider"; 6 | export * from "./security"; 7 | -------------------------------------------------------------------------------- /src/modules/common/model/config.ts: -------------------------------------------------------------------------------- 1 | export interface Config { 2 | readonly API_PORT: number; 3 | 4 | readonly API_PREFIX: string; 5 | 6 | readonly SWAGGER_ENABLE: number; 7 | 8 | readonly JWT_SECRET: string; 9 | 10 | readonly JWT_ISSUER: string; 11 | 12 | readonly HEALTH_TOKEN: string; 13 | 14 | readonly PASSENGERS_ALLOWED: string; 15 | 16 | readonly ANTHROPIC_API_KEY: string; 17 | 18 | readonly ANTHROPIC_API_URL: string; 19 | } 20 | -------------------------------------------------------------------------------- /src/modules/common/model/index.ts: -------------------------------------------------------------------------------- 1 | export { Config } from './config'; 2 | -------------------------------------------------------------------------------- /src/modules/common/provider/config.provider.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | 3 | import { Service } from '../../tokens'; 4 | import { Config } from '../model'; 5 | 6 | export const configProvider = { 7 | 8 | provide: Service.CONFIG, 9 | useFactory: (): Config => { 10 | 11 | const env = process.env; 12 | const validationSchema = Joi.object().unknown().keys({ 13 | API_PORT: Joi.number().required(), 14 | API_PREFIX: Joi.string().required(), 15 | SWAGGER_ENABLE: Joi.number().required(), 16 | JWT_SECRET: Joi.string().required(), 17 | JWT_ISSUER: Joi.string().required(), 18 | HEALTH_TOKEN: Joi.string().required(), 19 | PASSENGERS_ALLOWED: Joi.string().valid('yes', 'no').required() 20 | }); 21 | 22 | const result = validationSchema.validate(env); 23 | if (result.error) { 24 | throw new Error(`Configuration not valid: ${result.error.message}`); 25 | } 26 | 27 | return result.value; 28 | } 29 | 30 | }; 31 | -------------------------------------------------------------------------------- /src/modules/common/provider/index.ts: -------------------------------------------------------------------------------- 1 | export { configProvider } from './config.provider'; 2 | export { LoggerService } from './logger.service'; 3 | export { PrismaService } from './prisma.provider'; 4 | -------------------------------------------------------------------------------- /src/modules/common/provider/logger.service.ts: -------------------------------------------------------------------------------- 1 | import * as winston from 'winston'; 2 | 3 | export class LoggerService { 4 | 5 | private readonly instance: winston.Logger; 6 | 7 | public constructor() { 8 | 9 | const format = this.isProductionEnv() ? 10 | winston.format.combine( 11 | winston.format.timestamp(), 12 | winston.format.json() 13 | ) : 14 | winston.format.combine( 15 | winston.format.colorize(), 16 | winston.format.simple() 17 | ); 18 | 19 | this.instance = winston.createLogger({ 20 | level: 'info', 21 | silent: this.isTestEnv(), 22 | format, 23 | transports: [ 24 | new winston.transports.Console({ 25 | stderrLevels: ['error'] 26 | }) 27 | ] 28 | }); 29 | } 30 | 31 | public info(message: string) { 32 | this.instance.info(message); 33 | } 34 | 35 | public error(message: string) { 36 | this.instance.error(message); 37 | } 38 | 39 | private isTestEnv(): boolean { 40 | return process.env.NODE_ENV === 'test'; 41 | } 42 | 43 | private isProductionEnv(): boolean { 44 | return process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'staging'; 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/modules/common/provider/prisma.provider.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnModuleInit } from '@nestjs/common'; 2 | import { PrismaClient } from '@prisma/client'; 3 | 4 | @Injectable() 5 | export class PrismaService extends PrismaClient implements OnModuleInit { 6 | 7 | public async onModuleInit() { 8 | await this.$connect(); 9 | } 10 | 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/common/security/guest.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { FastifyRequest } from 'fastify'; 3 | import { Observable } from 'rxjs'; 4 | 5 | @Injectable() 6 | export class GuestGuard implements CanActivate { 7 | 8 | public canActivate(context: ExecutionContext): boolean | Promise | Observable { 9 | 10 | const header = context.switchToHttp().getRequest().headers.authorization; 11 | return header === undefined || !header; 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/modules/common/security/health.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; 2 | import { FastifyRequest } from 'fastify'; 3 | 4 | @Injectable() 5 | export class HealthGuard implements CanActivate { 6 | 7 | public canActivate(context: ExecutionContext): boolean { 8 | 9 | const request = context.switchToHttp().getRequest(); 10 | return request.headers.authorization === `Bearer ${process.env.HEALTH_TOKEN}`; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/common/security/index.ts: -------------------------------------------------------------------------------- 1 | export { RestrictedGuard } from './restricted.guard'; 2 | export { GuestGuard } from './guest.guard'; 3 | export * from './security-utils'; 4 | -------------------------------------------------------------------------------- /src/modules/common/security/restricted.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { FastifyRequest } from 'fastify'; 3 | 4 | import { Role } from '../../tokens'; 5 | import { extractTokenPayload } from './security-utils'; 6 | 7 | @Injectable() 8 | export class RestrictedGuard implements CanActivate { 9 | 10 | public canActivate(context: ExecutionContext): boolean { 11 | 12 | const payload = extractTokenPayload(context.switchToHttp().getRequest()); 13 | if (!payload) { 14 | return false; 15 | } 16 | 17 | return (payload.role === Role.RESTRICTED); 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /src/modules/common/security/security-utils.ts: -------------------------------------------------------------------------------- 1 | import { FastifyRequest } from 'fastify'; 2 | import * as jwt from 'jsonwebtoken'; 3 | 4 | import { Role } from '../../tokens'; 5 | 6 | export function extractTokenPayload(request: FastifyRequest): { role: Role } | null { 7 | 8 | const header = request.headers.authorization; 9 | if (!header || !header.startsWith('Bearer ')) { 10 | return null; 11 | } 12 | 13 | const [, tokenChunk] = header.split(' '); 14 | if (!tokenChunk) { 15 | return null; 16 | } 17 | 18 | try { 19 | 20 | const env = process.env; 21 | const payload = jwt.verify(tokenChunk, `${env.JWT_SECRET}`, { 22 | algorithms: ['HS256'], 23 | issuer: env.JWT_ISSUER 24 | }); 25 | 26 | if (typeof payload === 'string') { 27 | return null; 28 | } 29 | 30 | return payload as { role: Role }; 31 | 32 | } 33 | catch (err) { 34 | return null; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/modules/common/spec/basic-unit-test.spec.ts: -------------------------------------------------------------------------------- 1 | // @todo Use these files to perform unit tests 2 | describe('Basic unit test', () => { 3 | 4 | it('Should validate a test', () => { 5 | 6 | expect(1).toEqual(1); 7 | 8 | }); 9 | 10 | }); 11 | -------------------------------------------------------------------------------- /src/modules/svg-generator/controller/index.ts: -------------------------------------------------------------------------------- 1 | export { SvgGeneratorController } from "./svg-generation.controller"; 2 | export { UserController } from "./user.controller"; 3 | -------------------------------------------------------------------------------- /src/modules/svg-generator/controller/svg-generation.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | ForbiddenException, 5 | Get, 6 | HttpStatus, 7 | Param, 8 | Post, 9 | Put, 10 | Query, 11 | Req, 12 | Res, 13 | } from "@nestjs/common"; 14 | import { 15 | ApiBearerAuth, 16 | ApiOperation, 17 | ApiQuery, 18 | ApiResponse, 19 | ApiTags, 20 | } from "@nestjs/swagger"; 21 | import { FastifyReply, FastifyRequest } from "fastify"; 22 | 23 | import { Public } from "../../auth/decorator/public.decorator"; 24 | import { LoggerService } from "../../common"; 25 | 26 | import { SvgGenerationPipe, UpdatePublicStatusPipe } from "../flow"; 27 | import { 28 | PaginatedSvgGenerationResponse, 29 | SvgGenerationData, 30 | SvgGenerationInput, 31 | SvgGenerationWithVersionData, 32 | SvgVersionData, 33 | SvgVersionUpdateDto, 34 | UpdatePublicStatusDto, 35 | } from "../model"; 36 | import { SvgGenerationService } from "../service"; 37 | 38 | // 定义请求用户接口 39 | interface RequestWithUser extends FastifyRequest { 40 | user: { 41 | [key: string]: any; 42 | id: number; 43 | role: string; 44 | }; 45 | token?: string; 46 | } 47 | 48 | @Controller("svg-generator/generations") 49 | @ApiTags("svg-generations") 50 | @ApiBearerAuth() 51 | export class SvgGeneratorController { 52 | public constructor( 53 | private readonly logger: LoggerService, 54 | private readonly svgGenerationService: SvgGenerationService 55 | ) {} 56 | 57 | @Get("public") 58 | @Public() 59 | @ApiOperation({ summary: "查询公开的 SVG 生成内容,无需认证" }) 60 | @ApiQuery({ name: "page", required: false, description: "页码,默认为 1" }) 61 | @ApiQuery({ 62 | name: "pageSize", 63 | required: false, 64 | description: "每页大小,默认为 20,最大为 24", 65 | }) 66 | @ApiResponse({ 67 | status: HttpStatus.OK, 68 | type: PaginatedSvgGenerationResponse, 69 | }) 70 | public async findPublicGenerations( 71 | @Query("page") page?: string, 72 | @Query("pageSize") pageSize?: string 73 | ): Promise { 74 | // 处理分页参数 75 | const pageNumber = page ? Math.max(1, parseInt(page, 10)) : 1; 76 | let pageSizeNumber = pageSize ? parseInt(pageSize, 10) : 20; 77 | // 限制每页大小不超过 24 78 | pageSizeNumber = Math.min(24, Math.max(1, pageSizeNumber)); 79 | 80 | // 查询公开的 SVG 生成内容 81 | const result = await this.svgGenerationService.findPublicPaginated( 82 | pageNumber, 83 | pageSizeNumber 84 | ); 85 | 86 | return new PaginatedSvgGenerationResponse( 87 | result.items, 88 | result.total, 89 | pageNumber, 90 | pageSizeNumber 91 | ); 92 | } 93 | 94 | @Get() 95 | @ApiOperation({ summary: "Find SVG generations" }) 96 | @ApiQuery({ name: "userId", required: false }) 97 | @ApiQuery({ name: "page", required: false, description: "页码,默认为 1" }) 98 | @ApiQuery({ 99 | name: "pageSize", 100 | required: false, 101 | description: "每页大小,默认为 20,最大为 24", 102 | }) 103 | @ApiResponse({ 104 | status: HttpStatus.OK, 105 | type: PaginatedSvgGenerationResponse, 106 | }) 107 | public async findGenerations( 108 | @Req() request: RequestWithUser, 109 | @Query("userId") userId?: string, 110 | @Query("page") page?: string, 111 | @Query("pageSize") pageSize?: string 112 | ): Promise { 113 | // 处理分页参数 114 | const pageNumber = page ? Math.max(1, parseInt(page, 10)) : 1; 115 | let pageSizeNumber = pageSize ? parseInt(pageSize, 10) : 20; 116 | // 限制每页大小不超过 24 117 | pageSizeNumber = Math.min(24, Math.max(1, pageSizeNumber)); 118 | 119 | let result: { items: SvgGenerationWithVersionData[]; total: number }; 120 | 121 | // 根据是否有 userId 进行不同的查询 122 | if (userId) { 123 | result = await this.svgGenerationService.findByUserIdPaginated( 124 | parseInt(userId, 10), 125 | pageNumber, 126 | pageSizeNumber 127 | ); 128 | } else { 129 | // 检查当前用户是否为管理员 130 | if (request.user.role !== "ADMIN") { 131 | throw new ForbiddenException( 132 | "没有权限查看所有 SVG 生成记录,只有管理员可以访问此功能" 133 | ); 134 | } 135 | 136 | result = await this.svgGenerationService.findAllPaginated( 137 | pageNumber, 138 | pageSizeNumber 139 | ); 140 | } 141 | 142 | return new PaginatedSvgGenerationResponse( 143 | result.items, 144 | result.total, 145 | pageNumber, 146 | pageSizeNumber 147 | ); 148 | } 149 | 150 | @Post() 151 | @ApiOperation({ summary: "Create SVG generation" }) 152 | @ApiResponse({ status: HttpStatus.CREATED, type: SvgGenerationData }) 153 | public async createGeneration( 154 | @Body(SvgGenerationPipe) input: SvgGenerationInput, 155 | @Query("userId") userId: string 156 | ): Promise { 157 | const generation = await this.svgGenerationService.create( 158 | parseInt(userId, 10), 159 | input 160 | ); 161 | this.logger.info(`Created new SVG generation with ID ${generation.id}`); 162 | return generation; 163 | } 164 | 165 | @Post("stream") 166 | @ApiOperation({ summary: "Create SVG generation with stream response" }) 167 | @ApiResponse({ 168 | status: HttpStatus.OK, 169 | description: "Server-Sent Events stream for SVG generation", 170 | }) 171 | public async createGenerationStream( 172 | @Body(SvgGenerationPipe) input: SvgGenerationInput, 173 | @Query("userId") userId: string, 174 | @Res() reply: FastifyReply 175 | ): Promise { 176 | this.logger.info( 177 | `Starting streamed SVG generation for user ${userId} with prompt: ${input.inputContent.substring( 178 | 0, 179 | 100 180 | )}... Model: ${ 181 | input.isThinking === "thinking" 182 | ? "claude-3-7-sonnet-latest-thinking" 183 | : "claude-3-7-sonnet-all" 184 | }` 185 | ); 186 | await this.svgGenerationService.createStream( 187 | parseInt(userId, 10), 188 | input, 189 | reply 190 | ); 191 | } 192 | 193 | @Get(":id/versions") 194 | @ApiOperation({ summary: "Get versions of an SVG generation" }) 195 | @ApiResponse({ status: HttpStatus.OK, isArray: true, type: SvgVersionData }) 196 | public async getVersions( 197 | @Param("id") id: string 198 | ): Promise { 199 | return this.svgGenerationService.findVersions(parseInt(id, 10)); 200 | } 201 | 202 | @Put("versions/:id") 203 | @ApiOperation({ summary: "更新 SVG 版本内容" }) 204 | @ApiResponse({ 205 | status: HttpStatus.OK, 206 | type: SvgVersionData, 207 | }) 208 | public async updateSvgVersion( 209 | @Param("id") id: string, 210 | @Body() updateDto: SvgVersionUpdateDto 211 | ): Promise { 212 | return this.svgGenerationService.updateSvgVersion( 213 | Number(id), 214 | updateDto.svgContent, 215 | updateDto.userId 216 | ); 217 | } 218 | 219 | @Put(":id/public-status") 220 | @ApiOperation({ summary: "更新 SVG 生成记录的公开状态(仅管理员)" }) 221 | @ApiResponse({ 222 | status: HttpStatus.OK, 223 | type: SvgGenerationData, 224 | }) 225 | public async updatePublicStatus( 226 | @Param("id") id: string, 227 | @Body(UpdatePublicStatusPipe) updateDto: UpdatePublicStatusDto, 228 | @Req() request: RequestWithUser 229 | ): Promise { 230 | // 检查当前用户是否为管理员 231 | if (request.user.role !== "ADMIN") { 232 | throw new ForbiddenException( 233 | "只有管理员可以更新 SVG 生成记录的公开状态" 234 | ); 235 | } 236 | 237 | return this.svgGenerationService.updatePublicStatus( 238 | parseInt(id, 10), 239 | updateDto.isPublic 240 | ); 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /src/modules/svg-generator/controller/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | HttpStatus, 6 | Post, 7 | UseGuards, 8 | } from "@nestjs/common"; 9 | import { 10 | ApiBearerAuth, 11 | ApiOperation, 12 | ApiResponse, 13 | ApiTags, 14 | } from "@nestjs/swagger"; 15 | 16 | import { LoggerService, RestrictedGuard } from "../../common"; 17 | 18 | import { UserPipe } from "../flow"; 19 | import { UserData, UserInput } from "../model"; 20 | import { UserService } from "../service"; 21 | 22 | @Controller("svg-generator/users") 23 | @ApiTags("users") 24 | @ApiBearerAuth() 25 | export class UserController { 26 | public constructor( 27 | private readonly logger: LoggerService, 28 | private readonly userService: UserService 29 | ) {} 30 | 31 | @Get() 32 | @UseGuards(RestrictedGuard) 33 | @ApiOperation({ summary: "Find users" }) 34 | @ApiResponse({ status: HttpStatus.OK, isArray: true, type: UserData }) 35 | public async findUsers(): Promise { 36 | return this.userService.findAll(); 37 | } 38 | 39 | @Post() 40 | @ApiOperation({ summary: "Create user" }) 41 | @ApiResponse({ status: HttpStatus.CREATED, type: UserData }) 42 | public async createUser( 43 | @Body(UserPipe) input: UserInput 44 | ): Promise { 45 | const user = await this.userService.create(input); 46 | this.logger.info(`Created new user with ID ${user.id}`); 47 | return user; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/modules/svg-generator/flow/index.ts: -------------------------------------------------------------------------------- 1 | export { SvgGenerationPipe } from "./svg-generation.pipe"; 2 | export { UpdatePublicStatusPipe } from "./update-public-status.pipe"; 3 | export { UserPipe } from "./user.pipe"; 4 | -------------------------------------------------------------------------------- /src/modules/svg-generator/flow/svg-generation.pipe.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from "joi"; 2 | 3 | import { JoiValidationPipe } from "../../common"; 4 | import { SvgGenerationInput } from "../model"; 5 | 6 | export class SvgGenerationPipe extends JoiValidationPipe { 7 | public buildSchema(): Joi.Schema { 8 | return Joi.object({ 9 | inputContent: Joi.string().required(), 10 | style: Joi.string().optional(), 11 | aspectRatio: Joi.string().optional(), 12 | isThinking: Joi.string().valid("base", "thinking").default("base"), 13 | image: Joi.alternatives() 14 | .try(Joi.string(), Joi.binary(), Joi.object()) 15 | .optional(), 16 | file: Joi.alternatives() 17 | .try(Joi.string(), Joi.binary(), Joi.object()) 18 | .optional(), 19 | configuration: Joi.object().optional(), 20 | }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/svg-generator/flow/update-public-status.pipe.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from "joi"; 2 | 3 | import { JoiValidationPipe } from "../../common"; 4 | import { UpdatePublicStatusDto } from "../model"; 5 | 6 | /** 7 | * 用于验证SVG公开状态更新请求的管道 8 | */ 9 | export class UpdatePublicStatusPipe extends JoiValidationPipe { 10 | public buildSchema(): Joi.Schema { 11 | return Joi.object({ 12 | isPublic: Joi.boolean().required(), 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/modules/svg-generator/flow/user.pipe.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from "joi"; 2 | 3 | import { JoiValidationPipe } from "../../common"; 4 | import { UserData, UserInput } from "../model"; 5 | 6 | export class UserPipe extends JoiValidationPipe { 7 | public buildSchema(): Joi.Schema { 8 | return Joi.object({ 9 | username: Joi.string().required().max(UserData.USERNAME_LENGTH), 10 | email: Joi.string().email().optional(), 11 | wechatOpenId: Joi.string().optional(), 12 | miniappOpenId: Joi.string().optional(), 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/modules/svg-generator/model/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./paginated-response.data"; 2 | export { PaginatedSvgGenerationResponse } from "./paginated-response.data"; 3 | export * from "./svg-generation-with-version.data"; 4 | export { SvgGenerationWithVersionData } from "./svg-generation-with-version.data"; 5 | export { SvgGenerationData } from "./svg-generation.data"; 6 | export { SvgGenerationInput } from "./svg-generation.input"; 7 | export { SvgVersionUpdateDto } from "./svg-version-update.dto"; 8 | export { SvgModifyRecord, SvgVersionData } from "./svg-version.data"; 9 | export { UpdatePublicStatusDto } from "./update-public-status.dto"; 10 | export { UserData } from "./user.data"; 11 | export { UserInput } from "./user.input"; 12 | -------------------------------------------------------------------------------- /src/modules/svg-generator/model/paginated-response.data.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { SvgGenerationWithVersionData } from "./svg-generation-with-version.data"; 3 | 4 | export class PaginatedSvgGenerationResponse { 5 | @ApiProperty({ 6 | description: "SVG generations", 7 | type: [SvgGenerationWithVersionData], 8 | }) 9 | public readonly items: SvgGenerationWithVersionData[]; 10 | 11 | @ApiProperty({ description: "Total count of items", example: 100 }) 12 | public readonly total: number; 13 | 14 | @ApiProperty({ description: "Current page number", example: 1 }) 15 | public readonly page: number; 16 | 17 | @ApiProperty({ description: "Page size", example: 20 }) 18 | public readonly pageSize: number; 19 | 20 | @ApiProperty({ description: "Total number of pages", example: 5 }) 21 | public readonly totalPages: number; 22 | 23 | public constructor( 24 | items: SvgGenerationWithVersionData[], 25 | total: number, 26 | page: number, 27 | pageSize: number 28 | ) { 29 | this.items = items; 30 | this.total = total; 31 | this.page = page; 32 | this.pageSize = pageSize; 33 | this.totalPages = Math.ceil(total / pageSize); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/modules/svg-generator/model/svg-generation-with-version.data.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | import { ApiProperty } from "@nestjs/swagger"; 3 | import { SvgGeneration } from "@prisma/client"; 4 | import { JsonValue } from "@prisma/client/runtime/library"; 5 | import { SvgVersionData } from "./svg-version.data"; 6 | 7 | export class SvgGenerationWithVersionData { 8 | @ApiProperty({ description: "Generation unique ID", example: "1" }) 9 | public readonly id: number; 10 | 11 | @ApiProperty({ description: "User ID", example: "1" }) 12 | public readonly userId: number; 13 | 14 | @ApiProperty({ 15 | description: "Input content for generation", 16 | example: "A mountain landscape with sunset", 17 | }) 18 | public readonly inputContent: string; 19 | 20 | @ApiProperty({ 21 | description: "Style preference", 22 | example: "Minimalist", 23 | required: false, 24 | }) 25 | public readonly style?: string; 26 | 27 | @ApiProperty({ 28 | description: "Aspect ratio", 29 | example: "16:9", 30 | required: false, 31 | }) 32 | public readonly aspectRatio?: string; 33 | 34 | @ApiProperty({ 35 | description: "Additional configuration parameters", 36 | required: false, 37 | type: Object, 38 | }) 39 | public readonly configuration: JsonValue; 40 | 41 | @ApiProperty({ 42 | description: "AI models used for generation", 43 | example: ["deepseek", "claude3.7"], 44 | }) 45 | public readonly modelNames: string[]; 46 | 47 | @ApiProperty({ 48 | description: "Title of the generation", 49 | example: "Mountain Sunset", 50 | required: false, 51 | }) 52 | public readonly title?: string; 53 | 54 | @ApiProperty({ 55 | description: "Whether the generation is public", 56 | example: false, 57 | }) 58 | public readonly isPublic: boolean; 59 | 60 | @ApiProperty({ 61 | description: "Creation timestamp", 62 | example: "2025-03-02T10:30:00Z", 63 | }) 64 | public readonly createdAt: Date; 65 | 66 | @ApiProperty({ 67 | description: "Latest SVG version data", 68 | type: SvgVersionData, 69 | }) 70 | public readonly latestVersion?: SvgVersionData; 71 | 72 | public constructor(entity: SvgGeneration, latestVersion?: SvgVersionData) { 73 | this.id = entity.id; 74 | this.userId = entity.userId; 75 | this.inputContent = entity.inputContent; 76 | this.style = entity.style || undefined; 77 | this.aspectRatio = entity.aspectRatio || undefined; 78 | this.configuration = entity.configuration; 79 | this.modelNames = entity.modelNames; 80 | this.title = entity.title || undefined; 81 | this.isPublic = entity.isPublic; 82 | this.createdAt = entity.createdAt; 83 | this.latestVersion = latestVersion; 84 | } 85 | } 86 | 87 | export class PaginatedSvgGenerationResponse { 88 | @ApiProperty({ 89 | description: "SVG generations", 90 | type: [SvgGenerationWithVersionData], 91 | }) 92 | public readonly items: SvgGenerationWithVersionData[]; 93 | 94 | @ApiProperty({ description: "Total count of items", example: 100 }) 95 | public readonly total: number; 96 | 97 | @ApiProperty({ description: "Current page number", example: 1 }) 98 | public readonly page: number; 99 | 100 | @ApiProperty({ description: "Page size", example: 20 }) 101 | public readonly pageSize: number; 102 | 103 | @ApiProperty({ description: "Total number of pages", example: 5 }) 104 | public readonly totalPages: number; 105 | 106 | public constructor( 107 | items: SvgGenerationWithVersionData[], 108 | total: number, 109 | page: number, 110 | pageSize: number 111 | ) { 112 | this.items = items; 113 | this.total = total; 114 | this.page = page; 115 | this.pageSize = pageSize; 116 | this.totalPages = Math.ceil(total / pageSize); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/modules/svg-generator/model/svg-generation.data.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { SvgGeneration } from "@prisma/client"; 3 | import { JsonValue } from "@prisma/client/runtime/library"; 4 | 5 | export class SvgGenerationData { 6 | @ApiProperty({ description: "Generation unique ID", example: "1" }) 7 | public readonly id: number; 8 | 9 | @ApiProperty({ description: "User ID", example: "1" }) 10 | public readonly userId: number; 11 | 12 | @ApiProperty({ 13 | description: "Input content for generation", 14 | example: "A mountain landscape with sunset", 15 | }) 16 | public readonly inputContent: string; 17 | 18 | @ApiProperty({ 19 | description: "Style preference", 20 | example: "Minimalist", 21 | required: false, 22 | }) 23 | public readonly style?: string; 24 | 25 | @ApiProperty({ 26 | description: "Additional configuration parameters", 27 | required: false, 28 | type: Object, // 可以指定为 Object 或者更具体的类型 29 | }) 30 | public readonly configuration: JsonValue; 31 | 32 | @ApiProperty({ 33 | description: "AI models used for generation", 34 | example: ["deepseek", "claude3.7"], 35 | }) 36 | public readonly modelNames: string[]; 37 | 38 | @ApiProperty({ 39 | description: "Title of the generation", 40 | example: "Mountain Sunset", 41 | required: false, 42 | }) 43 | public readonly title?: string; 44 | 45 | @ApiProperty({ 46 | description: "Creation timestamp", 47 | example: "2025-03-02T10:30:00Z", 48 | }) 49 | public readonly createdAt: Date; 50 | 51 | public constructor(entity: SvgGeneration) { 52 | this.id = entity.id; 53 | this.userId = entity.userId; 54 | this.inputContent = entity.inputContent; 55 | this.style = entity.style || undefined; 56 | this.configuration = entity.configuration; 57 | this.modelNames = entity.modelNames; 58 | this.title = entity.title || undefined; 59 | this.createdAt = entity.createdAt; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/modules/svg-generator/model/svg-generation.input.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { JsonValue } from "@prisma/client/runtime/library"; 3 | 4 | export class SvgGenerationInput { 5 | @ApiProperty({ 6 | description: "Input content for generation", 7 | example: "A mountain landscape with sunset", 8 | }) 9 | public readonly inputContent: string; 10 | 11 | @ApiProperty({ 12 | description: "Style preference", 13 | example: "Minimalist", 14 | required: false, 15 | }) 16 | public readonly style?: string; 17 | 18 | @ApiProperty({ 19 | description: "宽高比例", 20 | example: "16:9", 21 | required: false, 22 | }) 23 | public readonly aspectRatio?: string; 24 | 25 | @ApiProperty({ 26 | description: "是否使用 thinking 模型进行生成", 27 | example: "base", 28 | required: false, 29 | enum: ["base", "thinking"], 30 | default: "base", 31 | }) 32 | public readonly isThinking?: string; 33 | 34 | @ApiProperty({ 35 | description: "图片数据(可以是 Base64 编码字符串、URL 或二进制数据)", 36 | required: false, 37 | }) 38 | public readonly image?: string | Uint8Array | Buffer | ArrayBuffer; 39 | 40 | @ApiProperty({ 41 | description: "文件数据(可以是 Base64 编码字符串、URL 或二进制数据)", 42 | required: false, 43 | }) 44 | public readonly file?: { 45 | data: string | Uint8Array | Buffer | ArrayBuffer; 46 | mimeType: string; 47 | }; 48 | 49 | @ApiProperty({ 50 | description: "生成类型,决定系统 prompt 和 prompt 内容", 51 | example: "default", 52 | required: false, 53 | default: "default", 54 | }) 55 | public readonly type?: string; 56 | 57 | @ApiProperty({ 58 | description: "Additional configuration parameters", 59 | required: false, 60 | }) 61 | public readonly configuration?: JsonValue; 62 | } 63 | -------------------------------------------------------------------------------- /src/modules/svg-generator/model/svg-version-update.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | 3 | export class SvgVersionUpdateDto { 4 | @ApiProperty({ 5 | description: "新的SVG内容", 6 | example: '...', 7 | }) 8 | public readonly svgContent: string; 9 | 10 | @ApiProperty({ 11 | description: "用户ID", 12 | example: 3, 13 | }) 14 | public readonly userId: number; 15 | } 16 | -------------------------------------------------------------------------------- /src/modules/svg-generator/model/svg-version.data.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { Prisma, SvgVersion } from "@prisma/client"; 3 | 4 | // 定义修改历史记录的接口 5 | export interface SvgModifyRecord { 6 | content: string; 7 | timestamp: string; 8 | editedBy: number; 9 | } 10 | 11 | // 扩展SvgVersion类型以包含新字段 12 | type SvgVersionWithModifyList = SvgVersion & { 13 | svgModifyList?: Prisma.JsonValue; 14 | lastEditedAt?: Date | null; 15 | lastEditedBy?: number | null; 16 | }; 17 | 18 | export class SvgVersionData { 19 | @ApiProperty({ description: "Version unique ID", example: "1" }) 20 | public readonly id: number; 21 | 22 | @ApiProperty({ description: "Generation ID", example: "1" }) 23 | public readonly generationId: number; 24 | 25 | @ApiProperty({ description: "SVG content", example: "..." }) 26 | public readonly svgContent: string; 27 | 28 | @ApiProperty({ 29 | description: "SVG修改历史", 30 | example: [ 31 | { 32 | content: "...", 33 | timestamp: "2024-03-05T10:00:00Z", 34 | editedBy: 3, 35 | }, 36 | ], 37 | required: false, 38 | }) 39 | public readonly svgModifyList?: SvgModifyRecord[]; 40 | 41 | @ApiProperty({ description: "Version number", example: 1 }) 42 | public readonly versionNumber: number; 43 | 44 | @ApiProperty({ 45 | description: "Whether version is AI generated", 46 | example: true, 47 | }) 48 | public readonly isAiGenerated: boolean; 49 | 50 | @ApiProperty({ 51 | description: "Creation timestamp", 52 | example: "2025-03-02T10:30:00Z", 53 | }) 54 | public readonly createdAt: Date; 55 | 56 | @ApiProperty({ 57 | description: "Last edit timestamp", 58 | example: "2025-03-05T10:30:00Z", 59 | required: false, 60 | }) 61 | public readonly lastEditedAt?: Date; 62 | 63 | @ApiProperty({ 64 | description: "Last editor user ID", 65 | example: 3, 66 | required: false, 67 | }) 68 | public readonly lastEditedBy?: number; 69 | 70 | public constructor(entity: SvgVersionWithModifyList) { 71 | this.id = entity.id; 72 | this.generationId = entity.generationId; 73 | this.svgContent = entity.svgContent; 74 | this.versionNumber = entity.versionNumber; 75 | this.isAiGenerated = entity.isAiGenerated; 76 | this.createdAt = entity.createdAt; 77 | this.lastEditedAt = entity.lastEditedAt ?? undefined; 78 | this.lastEditedBy = entity.lastEditedBy ?? undefined; 79 | 80 | // 安全地处理JSON数据 81 | if (entity.svgModifyList) { 82 | try { 83 | const modifyList = entity.svgModifyList as unknown; 84 | // 确保是数组且包含所需字段 85 | if ( 86 | Array.isArray(modifyList) && 87 | modifyList.every( 88 | (item) => 89 | typeof item === "object" && 90 | item !== null && 91 | "content" in item && 92 | "timestamp" in item && 93 | "editedBy" in item 94 | ) 95 | ) { 96 | this.svgModifyList = modifyList as SvgModifyRecord[]; 97 | } else { 98 | this.svgModifyList = []; 99 | } 100 | } catch { 101 | this.svgModifyList = []; 102 | } 103 | } else { 104 | this.svgModifyList = []; 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/modules/svg-generator/model/update-public-status.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | 3 | /** 4 | * 更新SVG生成记录公开状态的数据传输对象 5 | */ 6 | export class UpdatePublicStatusDto { 7 | @ApiProperty({ 8 | description: "是否公开SVG生成记录", 9 | example: true, 10 | }) 11 | public isPublic: boolean; 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/svg-generator/model/user.data.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from "@nestjs/swagger"; 2 | import { User } from "@prisma/client"; 3 | 4 | export class UserData { 5 | public static readonly USERNAME_LENGTH = 50; 6 | 7 | @ApiProperty({ description: "User unique ID", example: "1" }) 8 | public readonly id: number; 9 | 10 | @ApiProperty({ description: "Username", example: "luckySnail" }) 11 | public readonly username: string; 12 | 13 | @ApiProperty({ 14 | description: "Email address", 15 | example: "snailrun160@gmail.com", 16 | required: false, 17 | }) 18 | public readonly email?: string; 19 | 20 | @ApiProperty({ 21 | description: "User role", 22 | example: "USER", 23 | enum: ["ADMIN", "USER"], 24 | }) 25 | public readonly role: string; 26 | 27 | @ApiProperty({ description: "Remaining generation credits", example: 5 }) 28 | public readonly remainingCredits: number; 29 | 30 | @ApiProperty({ description: "Whether user was invited", example: false }) 31 | public readonly isInvited: boolean; 32 | 33 | public constructor(entity: User) { 34 | this.id = entity.id; 35 | this.username = entity.username; 36 | this.email = entity.email || undefined; 37 | this.role = entity.role; 38 | this.remainingCredits = entity.remainingCredits; 39 | this.isInvited = entity.isInvited; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/modules/svg-generator/model/user.input.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, PickType } from "@nestjs/swagger"; 2 | import { UserData } from "./user.data"; 3 | 4 | export class UserInput extends PickType(UserData, [ 5 | "username", 6 | "email", 7 | ] as const) { 8 | @ApiProperty({ 9 | description: "WeChat public account OpenID", 10 | required: false, 11 | }) 12 | public readonly wechatOpenId?: string; 13 | 14 | @ApiProperty({ description: "WeChat mini-program OpenID", required: false }) 15 | public readonly miniappOpenId?: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/svg-generator/service/index.ts: -------------------------------------------------------------------------------- 1 | export { SvgGenerationService } from "./svg-generation.service"; 2 | export { UserService } from "./user.service"; 3 | -------------------------------------------------------------------------------- /src/modules/svg-generator/service/svg-generation.service.ts: -------------------------------------------------------------------------------- 1 | import { createOpenAI } from "@ai-sdk/openai"; 2 | import { Injectable, Logger, NotFoundException } from "@nestjs/common"; 3 | import { Prisma } from "@prisma/client"; 4 | import { FilePart, ImagePart, streamText, TextPart } from "ai"; 5 | import type { FastifyReply } from "fastify"; 6 | import { PrismaService } from "../../common"; 7 | import { 8 | SvgGenerationData, 9 | SvgGenerationInput, 10 | SvgGenerationWithVersionData, 11 | SvgModifyRecord, 12 | SvgVersionData, 13 | } from "../model"; 14 | 15 | // 自定义响应接口,用于处理 OpenAI 的流式响应 16 | interface CustomStreamResponse { 17 | status: string; 18 | message?: string; 19 | id?: number; 20 | chunk?: string; 21 | } 22 | 23 | @Injectable() 24 | export class SvgGenerationService { 25 | private readonly SVG_WIDTH = 800; // 固定 SVG 宽度为 800px 26 | private readonly logger = new Logger(SvgGenerationService.name); 27 | public constructor(private readonly prismaService: PrismaService) {} 28 | 29 | /** 30 | * Find all SVG generations in the database 31 | * 32 | * @returns A list of SVG generations 33 | */ 34 | public async findAll(): Promise { 35 | const generations = await this.prismaService.svgGeneration.findMany({}); 36 | return generations.map( 37 | (generation) => new SvgGenerationData(generation) 38 | ); 39 | } 40 | 41 | /** 42 | * Find all SVG generations with pagination and include latest versions 43 | * 44 | * @param page Page number (1-based) 45 | * @param pageSize Number of items per page 46 | * @returns A paginated list of SVG generations with their latest versions 47 | */ 48 | public async findAllPaginated( 49 | page: number, 50 | pageSize: number 51 | ): Promise<{ items: SvgGenerationWithVersionData[]; total: number }> { 52 | const skip = (page - 1) * pageSize; 53 | const take = pageSize; 54 | 55 | // 获取总数 56 | const total = await this.prismaService.svgGeneration.count(); 57 | 58 | // 获取分页后的生成记录 59 | const generations = await this.prismaService.svgGeneration.findMany({ 60 | skip, 61 | take, 62 | orderBy: { createdAt: "desc" }, 63 | include: { 64 | svgVersions: { 65 | orderBy: { versionNumber: "desc" }, 66 | take: 1, 67 | }, 68 | }, 69 | }); 70 | 71 | // 转换为 DTO,包含最新版本 72 | const generationsWithVersions = generations.map((generation) => { 73 | const latestVersion = generation.svgVersions[0] 74 | ? new SvgVersionData(generation.svgVersions[0]) 75 | : undefined; 76 | return new SvgGenerationWithVersionData(generation, latestVersion); 77 | }); 78 | 79 | return { items: generationsWithVersions, total }; 80 | } 81 | 82 | /** 83 | * Find SVG generations by user ID with pagination and include latest versions 84 | * 85 | * @param userId User ID 86 | * @param page Page number (1-based) 87 | * @param pageSize Number of items per page 88 | * @returns A paginated list of SVG generations with their latest versions for the user 89 | */ 90 | public async findByUserIdPaginated( 91 | userId: number, 92 | page: number, 93 | pageSize: number 94 | ): Promise<{ items: SvgGenerationWithVersionData[]; total: number }> { 95 | const skip = (page - 1) * pageSize; 96 | const take = pageSize; 97 | 98 | // 获取用户生成记录总数 99 | const total = await this.prismaService.svgGeneration.count({ 100 | where: { userId }, 101 | }); 102 | 103 | // 获取分页后的用户生成记录 104 | const generations = await this.prismaService.svgGeneration.findMany({ 105 | where: { userId }, 106 | skip, 107 | take, 108 | orderBy: { createdAt: "desc" }, 109 | include: { 110 | svgVersions: { 111 | orderBy: { versionNumber: "desc" }, 112 | take: 1, 113 | }, 114 | }, 115 | }); 116 | 117 | // 转换为 DTO,包含最新版本 118 | const generationsWithVersions = generations.map((generation) => { 119 | const latestVersion = generation.svgVersions[0] 120 | ? new SvgVersionData(generation.svgVersions[0]) 121 | : undefined; 122 | // 过滤掉没有内容的生成记录 123 | return new SvgGenerationWithVersionData(generation, latestVersion); 124 | }); 125 | 126 | return { items: generationsWithVersions, total }; 127 | } 128 | 129 | /** 130 | * Find SVG generations by user ID 131 | * 132 | * @param userId User ID 133 | * @returns A list of SVG generations for the user 134 | */ 135 | public async findByUserId(userId: number): Promise { 136 | const generations = await this.prismaService.svgGeneration.findMany({ 137 | where: { userId }, 138 | }); 139 | return generations.map( 140 | (generation) => new SvgGenerationData(generation) 141 | ); 142 | } 143 | 144 | /** 145 | * Find a specific SVG generation by ID 146 | * 147 | * @param id Generation ID 148 | * @returns The SVG generation if found 149 | */ 150 | public async findById(id: number): Promise { 151 | const generation = await this.prismaService.svgGeneration.findUnique({ 152 | where: { id }, 153 | }); 154 | return generation ? new SvgGenerationData(generation) : null; 155 | } 156 | 157 | /** 158 | * Find all versions of an SVG generation 159 | * 160 | * @param generationId Generation ID 161 | * @returns A list of SVG versions 162 | */ 163 | public async findVersions(generationId: number): Promise { 164 | const versions = await this.prismaService.svgVersion.findMany({ 165 | where: { generationId }, 166 | orderBy: { versionNumber: "asc" }, 167 | }); 168 | return versions.map((version) => new SvgVersionData(version)); 169 | } 170 | 171 | /** 172 | * 创建 SVG 生成(普通方式) 173 | * 174 | * @param userId 用户 ID 175 | * @param data SVG 生成详情 176 | * @returns 创建的 SVG 生成 177 | */ 178 | public async create( 179 | userId: number, 180 | data: SvgGenerationInput 181 | ): Promise { 182 | // 检查用户是否存在并且有足够的积分 183 | const user = await this.prismaService.user.findUnique({ 184 | where: { id: userId }, 185 | }); 186 | 187 | if (!user) { 188 | throw new NotFoundException(`用户 ID ${userId} 不存在`); 189 | } 190 | 191 | if (user.remainingCredits <= 0) { 192 | throw new Error("用户没有剩余积分"); 193 | } 194 | 195 | // 解析宽高比并计算高度 196 | let height = this.SVG_WIDTH; // 默认为正方形 197 | if (data.aspectRatio) { 198 | height = this.calculateHeightFromAspectRatio(data.aspectRatio); 199 | } 200 | 201 | // 在一个事务中创建生成记录和初始版本 202 | const result = await this.prismaService.$transaction(async (prisma) => { 203 | // 减少用户积分 204 | await prisma.user.update({ 205 | where: { id: userId }, 206 | data: { remainingCredits: { decrement: 1 } }, 207 | }); 208 | 209 | // 创建配置对象,包含宽高信息 210 | const configWithSize = this.prepareConfiguration(data, height); 211 | 212 | // 创建生成记录 213 | const generation = await prisma.svgGeneration.create({ 214 | data: { 215 | userId, 216 | inputContent: data.inputContent, 217 | style: data.style, 218 | aspectRatio: data.aspectRatio, 219 | configuration: configWithSize, 220 | modelNames: ["claude-3-7-sonnet-all"], // 默认使用标准模型 221 | }, 222 | }); 223 | 224 | // 创建初始版本 225 | const svgPlaceholder = 226 | '` + 229 | '正在生成 SVG...'; 230 | 231 | await prisma.svgVersion.create({ 232 | data: { 233 | generationId: generation.id, 234 | svgContent: svgPlaceholder, 235 | versionNumber: 1, 236 | isAiGenerated: true, 237 | }, 238 | }); 239 | 240 | return generation; 241 | }); 242 | 243 | return new SvgGenerationData(result); 244 | } 245 | 246 | /** 247 | * 流式创建 SVG 生成 248 | * 249 | * @param userId 用户 ID 250 | * @param data SVG 生成详情 251 | * @param reply Fastify 响应对象,用于流式传输 252 | */ 253 | public async createStream( 254 | userId: number, 255 | data: SvgGenerationInput, 256 | reply: FastifyReply 257 | ): Promise { 258 | // 定义一个变量追踪响应是否已经结束 259 | let hasEnded = false; 260 | this.logger.log(`开始流式创建 SVG 生成,用户 ID: ${userId}`); 261 | try { 262 | // 检查用户是否存在并且有足够的积分 263 | const user = await this.prismaService.user.findUnique({ 264 | where: { id: userId }, 265 | }); 266 | 267 | if (!user) { 268 | throw new NotFoundException(`用户 ID ${userId} 不存在`); 269 | } 270 | 271 | if (user.remainingCredits <= 0) { 272 | throw new Error("用户没有剩余积分"); 273 | } 274 | 275 | // 解析宽高比并计算高度 276 | let height = this.SVG_WIDTH; // 默认为正方形 277 | if (data.aspectRatio) { 278 | height = this.calculateHeightFromAspectRatio(data.aspectRatio); 279 | } 280 | 281 | // 设置流式响应头 282 | reply.raw.writeHead(200, { 283 | "Content-Type": "text/event-stream", 284 | "Cache-Control": "no-cache, no-transform", 285 | Connection: "keep-alive", 286 | "X-Accel-Buffering": "no", // 禁用Nginx缓冲 287 | "Transfer-Encoding": "chunked", // 使用分块传输编码 288 | }); 289 | 290 | // 尝试禁用响应缓冲区 291 | if (reply.raw.socket) { 292 | // 设置TCP层不合并小包 293 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access 294 | reply.raw.socket.setNoDelay?.(true); 295 | // 尝试设置TCP保持活动状态 296 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access 297 | reply.raw.socket.setKeepAlive?.(true, 30000); 298 | } 299 | 300 | // 每30秒发送一个保持连接存活的心跳消息 301 | const keepAliveInterval = setInterval(() => { 302 | if (!hasEnded && !reply.raw.writableEnded) { 303 | try { 304 | // 发送注释消息作为心跳 305 | reply.raw.write(": keepalive\n\n"); 306 | } catch (e) { 307 | this.logger.error(`发送心跳消息失败:${e}`); 308 | } 309 | } else { 310 | clearInterval(keepAliveInterval); 311 | } 312 | }, 30000); 313 | 314 | // 确保在函数结束时清除心跳定时器 315 | const cleanup = () => { 316 | clearInterval(keepAliveInterval); 317 | }; 318 | 319 | // 响应结束时清理资源 320 | reply.raw.on("close", () => { 321 | this.logger.log("客户端连接已关闭"); 322 | cleanup(); 323 | hasEnded = true; 324 | }); 325 | 326 | reply.raw.on("error", (err) => { 327 | this.logger.error( 328 | `响应出错:${ 329 | err instanceof Error ? err.message : String(err) 330 | }` 331 | ); 332 | cleanup(); 333 | hasEnded = true; 334 | }); 335 | 336 | // 创建配置对象,包含宽高信息 337 | const configWithSize = this.prepareConfiguration(data, height); 338 | // 创建生成记录 339 | const generation = await this.prismaService.svgGeneration.create({ 340 | data: { 341 | userId, 342 | inputContent: data.inputContent, 343 | style: data.style, 344 | aspectRatio: data.aspectRatio, 345 | configuration: configWithSize, 346 | modelNames: [ 347 | data.isThinking === "thinking" 348 | ? "claude-3-7-sonnet-thinking-all" 349 | : "claude-3-7-sonnet-all", 350 | ], // 根据 isThinking 选择模型 351 | }, 352 | }); 353 | 354 | // 发送初始状态更新 355 | try { 356 | const startEvent = `data: ${JSON.stringify({ 357 | status: "started", 358 | message: "开始生成 SVG", 359 | id: generation.id, 360 | } as CustomStreamResponse)}\n\n`; 361 | reply.raw.write(startEvent); 362 | 363 | // 强制刷新缓冲区 364 | const rawReplyStart = reply.raw as unknown as { 365 | flushHeaders?: () => void; 366 | }; 367 | if (rawReplyStart.flushHeaders) { 368 | rawReplyStart.flushHeaders(); 369 | } 370 | } catch (e) { 371 | console.error("发送初始状态更新时出错:", e); 372 | hasEnded = true; 373 | throw e; // 重新抛出错误,让外层 catch 捕获 374 | } 375 | 376 | try { 377 | const { fullPrompt, systemPrompt } = this.getPrompts( 378 | data.type ?? "", 379 | data, 380 | this.SVG_WIDTH, 381 | height 382 | ); 383 | 384 | // 创建自定义的 OpenAI 客户端实例 385 | const customOpenAI = createOpenAI({ 386 | apiKey: process.env.ANTHROPIC_API_KEY, // 使用请求提供的 apiKey,如果没有则使用环境变量 387 | baseURL: process.env.ANTHROPIC_API_URL, // 使用请求提供的 baseURL,如果没有则使用默认值 388 | }); 389 | // 构建消息内容,包括文本和图片 390 | const messageContent: Array = [ 391 | { 392 | type: "text", 393 | text: fullPrompt ?? "", 394 | }, 395 | ]; 396 | if (configWithSize.config_image) { 397 | messageContent.push({ 398 | type: "image", 399 | image: data.image as unknown as string, 400 | mimeType: (data.image as unknown as string) 401 | .split(";")[0] 402 | .split(":")[1], 403 | providerOptions: { 404 | anthropic: { 405 | type: "base64", 406 | }, 407 | 408 | // "media-type": (data.image as unknown as string) 409 | // .split(";")[0] 410 | // .split(":")[1], 411 | // data: data.image as unknown as string, 412 | }, 413 | }); 414 | } 415 | if (configWithSize.fileContent) { 416 | messageContent.push({ 417 | type: "file", 418 | data: configWithSize.fileContent as unknown as string, 419 | mimeType: ( 420 | configWithSize.fileContent as unknown as string 421 | ) 422 | .split(";")[0] 423 | .split(":")[1], 424 | providerOptions: { 425 | anthropic: { 426 | type: "base64", 427 | }, 428 | }, 429 | }); 430 | } 431 | 432 | // 初始化变量以跟踪尝试的模型 433 | let lastError: unknown = null; 434 | let streamResult = null; 435 | let success = false; 436 | const currentModel = 437 | data.isThinking === "thinking" 438 | ? "claude-3-7-sonnet-thinking-all" 439 | : "deepseek-chat"; 440 | this.logger.log("messageContent:", messageContent); 441 | try { 442 | // 使用当前模型调用 API 443 | // 定义基本的流选项 444 | const baseStreamOptions = { 445 | model: customOpenAI(currentModel), 446 | system: systemPrompt ?? "", 447 | // maxTokens: 64000, 448 | maxTokens: 4096, 449 | abortSignal: AbortSignal.timeout(60000 * 20), // 设置 2 分钟超时 450 | temperature: 0.1, 451 | // 添加事件处理函数 452 | onError: (error: unknown) => { 453 | this.logger.error( 454 | `AI 生成过程中出错,用户 ID: ${userId},模型:${ 455 | data.isThinking === "thinking" 456 | ? "claude-3-7-sonnet-thinking-all" 457 | : "deepseek-chat" 458 | },错误信息:${JSON.stringify(error)}` 459 | ); 460 | // 保存错误以便稍后重试其他模型 461 | lastError = error; 462 | }, 463 | }; 464 | 465 | // 根据是否有图片或文件内容,选择不同的调用方式 466 | if ( 467 | configWithSize.config_image || 468 | configWithSize.fileContent 469 | ) { 470 | // 使用 messages 格式以支持图片和文件 471 | this.logger.log( 472 | `使用带有多媒体内容的 messages 格式调用模型 ${currentModel}` 473 | ); 474 | streamResult = streamText({ 475 | ...baseStreamOptions, 476 | messages: [ 477 | { 478 | role: "user", 479 | content: messageContent, 480 | }, 481 | ], 482 | }); 483 | } else { 484 | // 只有文本内容时使用 prompt 字段 485 | this.logger.log( 486 | `使用纯文本 prompt 字段调用模型 ${currentModel}` 487 | ); 488 | streamResult = streamText({ 489 | ...baseStreamOptions, 490 | prompt: fullPrompt ?? "", 491 | }); 492 | } 493 | 494 | // 如果没有抛出异常,则标记为成功并退出循环 495 | success = true; 496 | this.logger.log(`成功使用模型:${currentModel}`); 497 | } catch (error) { 498 | // 记录当前模型的失败 499 | this.logger.error( 500 | `模型 ${currentModel} 调用失败:${ 501 | error instanceof Error ? error.message : "未知错误" 502 | }` 503 | ); 504 | lastError = error; 505 | } 506 | 507 | // 如果所有模型都失败,返回错误 508 | if (!success) { 509 | if (!hasEnded) { 510 | const errorMessage = this.parseAIError(lastError); 511 | const errorEvent = `data: ${JSON.stringify({ 512 | status: "error", 513 | message: `所有可用模型都无法生成SVG: ${errorMessage}`, 514 | id: generation.id, 515 | } as CustomStreamResponse)}\n\n`; 516 | 517 | reply.raw.write(errorEvent); 518 | reply.raw.end(); 519 | hasEnded = true; 520 | } 521 | return; 522 | } 523 | 524 | let processedSvgContent = ""; // 用于收集处理后的 SVG 内容 525 | this.logger.log("streamResult:", streamResult); 526 | // 处理流式响应 527 | if (streamResult && streamResult.textStream) { 528 | this.logger.log("准备处理 textStream,开始迭代..."); 529 | let chunkCount = 0; 530 | try { 531 | for await (const textPart of streamResult.textStream) { 532 | // 防止响应已结束情况下继续写入 533 | if (hasEnded) { 534 | this.logger.log("响应已结束,停止处理流"); 535 | break; 536 | } 537 | 538 | // 跳过空数据块 539 | if (!textPart || textPart.trim() === "") { 540 | this.logger.log("收到空数据块,跳过"); 541 | continue; 542 | } 543 | 544 | chunkCount++; 545 | processedSvgContent += textPart; 546 | this.logger.log( 547 | `收到第 ${chunkCount} 个数据块,长度:${ 548 | textPart.length 549 | }字节,内容:${textPart.slice(0, 100)}` 550 | ); 551 | 552 | // 发送每个数据块到前端 553 | try { 554 | const chunkEvent = `data: ${JSON.stringify({ 555 | status: "streaming", 556 | id: generation.id, 557 | chunk: textPart, 558 | } as CustomStreamResponse)}\n\n`; 559 | 560 | const writeResult = reply.raw.write(chunkEvent); 561 | this.logger.log( 562 | `数据块 ${chunkCount} 写入结果:${ 563 | writeResult ? "成功" : "缓冲区已满" 564 | }` 565 | ); 566 | 567 | // 如果缓冲区已满,等待 drain 事件 568 | if (!writeResult && !hasEnded) { 569 | this.logger.log( 570 | "缓冲区已满,等待 drain 事件..." 571 | ); 572 | await new Promise((resolve) => { 573 | const onDrain = () => { 574 | reply.raw.removeListener( 575 | "drain", 576 | onDrain 577 | ); 578 | resolve(); 579 | }; 580 | reply.raw.on("drain", onDrain); 581 | }); 582 | this.logger.log("缓冲区已清空,继续写入"); 583 | } 584 | 585 | // 强制刷新缓冲区,确保数据立即发送给客户端 586 | if (reply.raw.flushHeaders) { 587 | reply.raw.flushHeaders(); 588 | } 589 | 590 | // 额外尝试刷新 591 | const rawReply = reply.raw as { 592 | flush?: () => void; 593 | }; 594 | if (typeof rawReply.flush === "function") { 595 | rawReply.flush(); 596 | } 597 | } catch (e) { 598 | this.logger.error( 599 | `发送数据块时出错,用户 ID: ${userId},数据块 ${chunkCount},错误信息:${e}` 600 | ); 601 | if (!hasEnded) { 602 | try { 603 | const errorEvent = `data: ${JSON.stringify( 604 | { 605 | status: "error", 606 | message: "数据传输过程中断", 607 | id: generation.id, 608 | } as CustomStreamResponse 609 | )}\n\n`; 610 | reply.raw.write(errorEvent); 611 | reply.raw.end(); 612 | hasEnded = true; 613 | } catch (endError) { 614 | this.logger.error( 615 | `尝试结束响应时出错,用户 ID: ${userId},错误信息:${endError}` 616 | ); 617 | } 618 | } 619 | throw e; // 重新抛出错误,让外层 catch 捕获 620 | } 621 | } 622 | this.logger.log( 623 | `流处理完成,共处理了 ${chunkCount} 个数据块` 624 | ); 625 | } catch (streamError) { 626 | this.logger.error( 627 | `处理流时发生错误:${ 628 | streamError instanceof Error 629 | ? streamError.message 630 | : "未知错误" 631 | }` 632 | ); 633 | if (!hasEnded) { 634 | const errorEvent = `data: ${JSON.stringify({ 635 | status: "error", 636 | message: `流处理错误: ${ 637 | streamError instanceof Error 638 | ? streamError.message 639 | : "未知错误" 640 | }`, 641 | id: generation.id, 642 | } as CustomStreamResponse)}\n\n`; 643 | reply.raw.write(errorEvent); 644 | } 645 | throw streamError; 646 | } 647 | } else { 648 | this.logger.warn( 649 | "streamResult 存在但 textStream 不可用或为空" 650 | ); 651 | } 652 | 653 | // 发送完成状态 654 | try { 655 | const completeEvent = `data: ${JSON.stringify({ 656 | status: "completed", 657 | message: "SVG 生成完成", 658 | id: generation.id, 659 | } as CustomStreamResponse)}\n\n`; 660 | 661 | const writeResult = reply.raw.write(completeEvent); 662 | this.logger.log( 663 | `完成状态写入结果:${ 664 | writeResult ? "成功" : "缓冲区已满" 665 | }` 666 | ); 667 | 668 | // 强制刷新缓冲区 669 | if (reply.raw.flushHeaders) { 670 | reply.raw.flushHeaders(); 671 | } 672 | 673 | // 额外尝试刷新 674 | const rawReplyComplete = reply.raw as { 675 | flush?: () => void; 676 | }; 677 | if (typeof rawReplyComplete.flush === "function") { 678 | rawReplyComplete.flush(); 679 | } 680 | } catch (e) { 681 | this.logger.error( 682 | `发送完成状态更新时出错,用户 ID: ${userId},错误信息:${e}` 683 | ); 684 | if (!hasEnded) { 685 | try { 686 | reply.raw.end(); 687 | hasEnded = true; 688 | } catch (endError) { 689 | this.logger.error( 690 | `尝试结束响应时出错,用户 ID: ${userId},错误信息:${endError}` 691 | ); 692 | } 693 | } 694 | throw e; // 重新抛出错误,让外层 catch 捕获 695 | } 696 | this.logger.log( 697 | `AI 生成完成,用户 ID: ${userId},生成 ID: ${generation.id},生成内容:${processedSvgContent}` 698 | ); 699 | // 将 AI 返回的 SVG 保存到数据库 700 | await this.prismaService.svgVersion.create({ 701 | data: { 702 | generationId: generation.id, 703 | svgContent: this.cleanSvgContent(processedSvgContent), 704 | // 存储原始内容到 svgModifyList 705 | svgModifyList: [ 706 | { 707 | content: processedSvgContent, 708 | timestamp: new Date().toISOString(), 709 | editedBy: userId, 710 | }, 711 | ], 712 | versionNumber: 1, 713 | isAiGenerated: true, 714 | }, 715 | }); 716 | 717 | // 结束响应,只有在响应还没结束的情况下才结束 718 | if (!hasEnded) { 719 | reply.raw.end(); 720 | hasEnded = true; 721 | } 722 | 723 | // 减少用户积分 724 | await this.prismaService.user.update({ 725 | where: { id: userId }, 726 | data: { 727 | remainingCredits: { decrement: 1 }, 728 | }, 729 | }); 730 | } catch (error) { 731 | // 如果 API 调用失败,将错误信息传递给前端 732 | const errorMessage = this.parseAIError(error); 733 | this.logger.error("API 调用错误:", errorMessage, hasEnded); 734 | const errorEvent = `data: ${JSON.stringify({ 735 | status: "error", 736 | message: `生成SVG时出错: ${errorMessage}`, 737 | id: generation.id, 738 | } as CustomStreamResponse)}\n\n`; 739 | 740 | // 只有在响应还没结束的情况下才写入和结束 741 | if (!hasEnded) { 742 | reply.raw.write(errorEvent); 743 | reply.raw.end(); 744 | hasEnded = true; 745 | } 746 | } 747 | } catch (error) { 748 | // 处理初始化或数据库错误 749 | const errorMessage = 750 | error instanceof Error ? error.message : "未知错误"; 751 | console.error("初始化错误:", errorMessage); 752 | 753 | const errorEvent = `data: ${JSON.stringify({ 754 | status: "error", 755 | message: `生成SVG时出错: ${errorMessage}`, 756 | } as CustomStreamResponse)}\n\n`; 757 | 758 | // 只有在响应还没结束的情况下才写入和结束 759 | if (!hasEnded) { 760 | reply.raw.write(errorEvent); 761 | reply.raw.end(); 762 | hasEnded = true; 763 | } 764 | } finally { 765 | // 最后确保响应一定结束 766 | if (!hasEnded && !reply.raw.writableEnded) { 767 | try { 768 | const finalErrorEvent = `data: ${JSON.stringify({ 769 | status: "error", 770 | message: "生成过程意外中断", 771 | } as CustomStreamResponse)}\n\n`; 772 | reply.raw.write(finalErrorEvent); 773 | reply.raw.end(); 774 | } catch (e) { 775 | console.error("尝试关闭响应时出错:", e); 776 | } 777 | } 778 | } 779 | } 780 | 781 | /** 782 | * 更新 SVG 版本内容并记录修改历史 783 | * @param versionId SVG 版本 ID 784 | * @param svgContent 新的 SVG 内容 785 | * @param userId 用户 ID 786 | * @returns 更新后的 SVG 版本数据 787 | */ 788 | public async updateSvgVersion( 789 | versionId: number, 790 | svgContent: string, 791 | userId: number 792 | ): Promise { 793 | // 清理 SVG 内容,确保只包含有效的 SVG 代码 794 | const cleanedSvgContent = this.cleanSvgContent(svgContent); 795 | 796 | // 查找现有版本 797 | const existingVersion = await this.prismaService.svgVersion.findUnique({ 798 | where: { id: versionId }, 799 | }); 800 | 801 | if (!existingVersion) { 802 | throw new NotFoundException(`SVG 版本 ID ${versionId} 不存在`); 803 | } 804 | 805 | // 准备修改记录 806 | const modifyRecord: SvgModifyRecord = { 807 | content: cleanedSvgContent, // 保存当前内容作为历史记录 808 | timestamp: new Date().toISOString(), 809 | editedBy: userId, 810 | }; 811 | 812 | // 获取现有的修改历史 813 | let modifyList: SvgModifyRecord[] = []; 814 | if (existingVersion.svgModifyList) { 815 | try { 816 | // 安全地将 JSON 转换为 SvgModifyRecord 数组 817 | const rawList = existingVersion.svgModifyList as unknown; 818 | if (Array.isArray(rawList)) { 819 | modifyList = rawList.filter( 820 | (item) => 821 | typeof item === "object" && 822 | item !== null && 823 | "content" in item && 824 | "timestamp" in item && 825 | "editedBy" in item 826 | ) as SvgModifyRecord[]; 827 | } 828 | } catch (e) { 829 | console.error("解析修改历史失败:", e); 830 | modifyList = []; 831 | } 832 | } 833 | 834 | // 添加新的修改记录 835 | // 限制修改历史记录为最新的 10 条 836 | modifyList.unshift(modifyRecord); 837 | if (modifyList.length > 10) { 838 | // 如果超过 10 条,删除最旧的一条记录(数组中的第一个元素) 839 | modifyList.pop(); 840 | } 841 | 842 | // 更新版本 843 | const updatedVersion = await this.prismaService.svgVersion.update({ 844 | where: { id: versionId }, 845 | data: { 846 | svgContent: cleanedSvgContent, // 更新为新内容 847 | svgModifyList: modifyList as unknown as Prisma.InputJsonValue, // 更新修改历史 848 | lastEditedAt: new Date(), 849 | lastEditedBy: userId, 850 | }, 851 | }); 852 | 853 | return new SvgVersionData(updatedVersion); 854 | } 855 | 856 | /** 857 | * 查询公开的 SVG 生成内容,支持分页,返回包含最新版本的生成记录 858 | * 859 | * @param page 页码(从 1 开始) 860 | * @param pageSize 每页数量 861 | * @returns 包含分页 SVG 生成内容及总数的对象 862 | */ 863 | public async findPublicPaginated( 864 | page: number, 865 | pageSize: number 866 | ): Promise<{ items: SvgGenerationWithVersionData[]; total: number }> { 867 | const skip = (page - 1) * pageSize; 868 | const take = pageSize; 869 | 870 | // 获取公开的生成记录总数 871 | const total = await this.prismaService.svgGeneration.count({ 872 | where: { isPublic: true }, 873 | }); 874 | 875 | // 获取分页后的公开生成记录 876 | const generations = await this.prismaService.svgGeneration.findMany({ 877 | where: { isPublic: true }, 878 | skip, 879 | take, 880 | orderBy: { createdAt: "desc" }, 881 | include: { 882 | svgVersions: { 883 | orderBy: { versionNumber: "desc" }, 884 | take: 1, 885 | }, 886 | }, 887 | }); 888 | 889 | // 转换为 DTO,包含最新版本 890 | const generationsWithVersions = generations.map((generation) => { 891 | const latestVersion = generation.svgVersions[0] 892 | ? new SvgVersionData(generation.svgVersions[0]) 893 | : undefined; 894 | return new SvgGenerationWithVersionData(generation, latestVersion); 895 | }); 896 | 897 | return { items: generationsWithVersions, total }; 898 | } 899 | 900 | /** 901 | * 更新 SVG 生成记录的公开状态 902 | * 903 | * @param generationId SVG 生成记录 ID 904 | * @param isPublic 是否公开 905 | * @returns 更新后的 SVG 生成记录 906 | */ 907 | public async updatePublicStatus( 908 | generationId: number, 909 | isPublic: boolean 910 | ): Promise { 911 | // 检查记录是否存在 912 | const generation = await this.prismaService.svgGeneration.findUnique({ 913 | where: { id: generationId }, 914 | }); 915 | 916 | if (!generation) { 917 | throw new NotFoundException( 918 | `未找到 ID 为${generationId}的 SVG 生成记录` 919 | ); 920 | } 921 | 922 | // 更新公开状态 923 | const updatedGeneration = await this.prismaService.svgGeneration.update( 924 | { 925 | where: { id: generationId }, 926 | data: { isPublic }, 927 | } 928 | ); 929 | 930 | return new SvgGenerationData(updatedGeneration); 931 | } 932 | 933 | /** 934 | * 准备配置对象,处理图片和文件数据 935 | * 936 | * @private 937 | * @param data SVG 生成详情 938 | * @param height 计算后的高度 939 | * @returns 处理后的配置对象 940 | */ 941 | private prepareConfiguration( 942 | data: SvgGenerationInput, 943 | height: number 944 | ): Record { 945 | // 创建配置对象,包含宽高信息 946 | const configWithSize: Record = { 947 | ...((data.configuration as Record) || {}), 948 | width: this.SVG_WIDTH, 949 | height, 950 | aspectRatio: data.aspectRatio || "1:1", 951 | }; 952 | 953 | // 定义类型安全的图片和文件信息 954 | type FileInfo = { 955 | data: string; 956 | mimeType: string; 957 | }; 958 | 959 | // 如果有图片数据,确保正确存储到配置对象中,而不是单独的列 960 | if (data.image) { 961 | // 对于二进制数据,转换为 Base64 962 | if ( 963 | data.image instanceof Uint8Array || 964 | Buffer.isBuffer(data.image) || 965 | data.image instanceof ArrayBuffer 966 | ) { 967 | let buffer: Buffer; 968 | 969 | if (Buffer.isBuffer(data.image)) { 970 | buffer = data.image; 971 | } else if (data.image instanceof Uint8Array) { 972 | buffer = Buffer.from(data.image); 973 | } else { 974 | buffer = Buffer.from(new Uint8Array(data.image)); 975 | } 976 | 977 | // 修改这里,使用 "config_image" 作为键,避免被误解为 image_data 列 978 | configWithSize.config_image = `data:image/png;base64,${buffer.toString( 979 | "base64" 980 | )}`; 981 | } else if (typeof data.image === "string") { 982 | // 字符串直接存储(假设它是 URL 或已经是 Base64) 983 | configWithSize.config_image = data.image; 984 | } 985 | } 986 | 987 | // 如果有文件数据,确保正确存储 988 | if (data.file && data.file.data) { 989 | const fileData = data.file.data; 990 | const mimeType = data.file.mimeType; 991 | 992 | // 对于二进制数据,转换为 Base64 993 | if ( 994 | fileData instanceof Uint8Array || 995 | Buffer.isBuffer(fileData) || 996 | fileData instanceof ArrayBuffer 997 | ) { 998 | let buffer: Buffer; 999 | 1000 | if (Buffer.isBuffer(fileData)) { 1001 | buffer = fileData; 1002 | } else if (fileData instanceof Uint8Array) { 1003 | buffer = Buffer.from(fileData); 1004 | } else { 1005 | buffer = Buffer.from(new Uint8Array(fileData)); 1006 | } 1007 | 1008 | configWithSize.fileContent = { 1009 | data: `data:${mimeType};base64,${buffer.toString( 1010 | "base64" 1011 | )}`, 1012 | mimeType, 1013 | } as FileInfo; 1014 | } else if (typeof fileData === "string") { 1015 | // 字符串直接存储 1016 | configWithSize.fileContent = { 1017 | data: fileData, 1018 | mimeType, 1019 | } as FileInfo; 1020 | } 1021 | } 1022 | 1023 | return configWithSize; 1024 | } 1025 | 1026 | /** 1027 | * 获取用于生成 SVG 的提示词 1028 | * 1029 | * @param type 提示词类型,"base"使用默认提示词,"custom"不使用提示词 1030 | * @param data SVG 生成输入数据 1031 | * @param width SVG 宽度 1032 | * @param height SVG 高度 1033 | * @returns 包含 fullPrompt 和 systemPrompt 的对象,或者空对象 1034 | */ 1035 | private getPrompts( 1036 | type: string, 1037 | data: SvgGenerationInput, 1038 | width: number, 1039 | height: number 1040 | ): { 1041 | fullPrompt?: string; 1042 | systemPrompt?: string; 1043 | } { 1044 | if (type === "custom") { 1045 | return { 1046 | fullPrompt: "", 1047 | systemPrompt: "", 1048 | }; // 不使用提示词 1049 | } 1050 | 1051 | // 默认提示词 (base) 1052 | const fullPrompt = `请根据提供的主题或内容,创建一个独特、引人注目且技术精湛的 SVG 图: 1053 | [${data.inputContent}]`; 1054 | 1055 | const systemPrompt = `你是一名专业的图形设计师和 SVG 开发专家,对视觉美学和技术实现有极高造诣。 1056 | 你是超级创意助手,精通所有现代设计趋势和 SVG 技术。你的任务是将文本描述转换为高质量的 SVG 代码。 1057 | 1058 | ## 重要:输出格式要求 1059 | - 你的回复必须且只能包含一个完整的 SVG 代码,不包含任何其他内容 1060 | - 不要添加任何前言、解释、标记(如\`\`\`svg)或结语 1061 | - 不要描述你的思考过程或设计理念 1062 | - 不要在SVG代码前后添加任何文本 1063 | 1064 | ## 内容要求 1065 | - 保持原始主题的核心信息,但以更具视觉冲击力的方式呈现,默认背景白色 1066 | - 可搜索补充其他视觉元素或设计灵感,目的为增强海报的表现力 1067 | 1068 | ## 设计风格 1069 | - 根据主题选择合适的设计风格,优先使用:${ 1070 | data.style || "极简现代" 1071 | }风格的视觉设计 1072 | - 使用强烈的视觉层次结构,确保信息高效传达 1073 | - 配色方案应富有表现力且和谐,符合主题情感 1074 | - 字体选择考究,混合使用不超过三种字体,确保可读性与美感并存 1075 | - 充分利用 SVG 的矢量特性,呈现精致细节和锐利边缘 1076 | 1077 | ## 技术规范 1078 | - 使用纯 SVG 格式,确保无损缩放和最佳兼容性 1079 | - 代码整洁,结构清晰,包含适当注释 1080 | - 优化 SVG 代码,删除不必要的元素和属性 1081 | - 实现适当的动画效果(如果需要),使用 SVG 原生动画能力 1082 | - SVG 总元素数量不应超过 200 个,确保渲染效率 1083 | - 避免使用实验性或低兼容性的 SVG 特性 1084 | 1085 | ## 兼容性要求 1086 | - 设计必须在 Chrome、Firefox、Safari 等主流浏览器中正确显示 1087 | - 确保所有关键内容在标准 viewBox 范围内完全可见 1088 | - 验证 SVG 在移除所有高级效果(动画、滤镜)后仍能清晰传达核心信息 1089 | - 避免依赖特定浏览器或平台的专有特性 1090 | - 设置合理的文本大小,确保在多种缩放比例下均保持可读性 1091 | 1092 | ## 尺寸与比例 1093 | - 尺寸为 viewBox="0 0 ${width} ${height}" 1094 | - 不要添加width/height属性 1095 | - 核心内容应位于视图中心区域,避免边缘布局 1096 | 1097 | ## 图形与视觉元素 1098 | - 创建原创矢量图形,展现主题的本质 1099 | - 使用渐变、图案和滤镜等 SVG 高级特性增强视觉效果,但每个 SVG 限制在 3 种滤镜以内 1100 | - 精心设计的构图,确保视觉平衡和动态张力 1101 | - 避免过度拥挤的设计,避免元素被完全遮挡 1102 | - 装饰元素不应干扰或掩盖主要信息 1103 | - 所有元素都在viewBox范围内 1104 | 1105 | ## 视觉层次与排版 1106 | - 建立清晰的视觉导向,引导观众视线 1107 | - 文字排版精致,考虑中文字体的特性和美感 1108 | - 标题、副标题和正文之间有明确区分 1109 | - 使用大小、粗细、颜色和位置创建层次感 1110 | - 确保所有文字内容在视觉设计中的优先级高于装饰元素 1111 | 1112 | ## 性能优化 1113 | - 确保 SVG 文件大小适中,避免不必要的复杂路径 1114 | - 正确使用 SVG 元素(如 path、rect、circle 等) 1115 | - 优化路径数据,删除冗余点和曲线 1116 | - 合并可合并的路径和形状,减少总元素数 1117 | - 简化复杂的形状,使用基本元素组合而非复杂路径 1118 | - 避免过大的阴影和模糊效果,它们在某些环境中可能导致性能问题 1119 | 1120 | ## 严格输出格式 1121 | - 直接输出SVG代码,不添加任何其他文本、标记或注释 1122 | - 回复中必须以结尾 1123 | - 不要在SVG前后添加任何说明性文字或代码块标记 1124 | - 这一点极其重要:无论如何都只输出纯SVG代码,没有其他任何内容 1125 | 1126 | 你的输出应该是这样的格式: 1127 | 1128 | 1129 | `; 1130 | 1131 | return { fullPrompt, systemPrompt }; 1132 | } 1133 | 1134 | /** 1135 | * 根据宽高比计算高度 1136 | * 支持的格式:"16:9", "4:3", "1:1" 等 1137 | * 1138 | * @private 1139 | * @param aspectRatio 宽高比字符串 1140 | * @returns 计算出的高度 1141 | */ 1142 | private calculateHeightFromAspectRatio(aspectRatio: string): number { 1143 | if (!aspectRatio || !aspectRatio.includes(":")) { 1144 | return this.SVG_WIDTH; // 默认为正方形 1145 | } 1146 | 1147 | try { 1148 | const [width, height] = aspectRatio.split(":").map(Number); 1149 | if (width <= 0 || height <= 0) { 1150 | return this.SVG_WIDTH; 1151 | } 1152 | return Math.round((this.SVG_WIDTH * height) / width); 1153 | } catch (e) { 1154 | return this.SVG_WIDTH; // 解析失败则返回默认值 1155 | } 1156 | } 1157 | 1158 | /** 1159 | * 清理 SVG 内容,提取纯 SVG 代码 1160 | * 移除可能存在的 Markdown 代码块标记和其他非 SVG 内容 1161 | * 如果无法提取有效的 SVG,则返回默认的 AI 生成内容 1162 | * 1163 | * @private 1164 | * @param svgContent 原始 SVG 内容 1165 | * @returns 清理后的纯 SVG 代码 1166 | */ 1167 | private cleanSvgContent(svgContent: string): string { 1168 | // 处理空内容情况 1169 | if (!svgContent || svgContent.trim() === "") { 1170 | return this.getDefaultSvg("无法解析内容"); 1171 | } 1172 | 1173 | // 如果已经是合法的完整SVG,直接返回 1174 | if (this.isValidFullSvg(svgContent)) { 1175 | return svgContent; 1176 | } 1177 | 1178 | // 移除各种可能的代码块标记和前导/尾随空白 1179 | const cleaned = svgContent 1180 | .replace(/```(svg|xml|html)?\s*/gi, "") // 删除开始的代码块标记 1181 | .replace(/```\s*$/g, "") // 删除结束的代码块标记 1182 | .trim(); 1183 | 1184 | // 尝试提取完整的SVG标签 1185 | const fullSvgRegex = //i; 1186 | const fullMatch = cleaned.match(fullSvgRegex); 1187 | 1188 | if (fullMatch && fullMatch[0]) { 1189 | const extractedSvg = fullMatch[0]; 1190 | if (this.isValidFullSvg(extractedSvg)) { 1191 | return extractedSvg; 1192 | } 1193 | } 1194 | 1195 | // 尝试处理不完整的 SVG(有开始标签但没有结束标签) 1196 | const openTagRegex = /]*>/i; 1197 | const openTagMatch = cleaned.match(openTagRegex); 1198 | 1199 | if (openTagMatch && openTagMatch[0]) { 1200 | const svgStart = cleaned.indexOf(openTagMatch[0]); 1201 | const svgContentFragment = cleaned.substring(svgStart); 1202 | 1203 | // 如果有开始标签但没有结束标签,添加结束标签 1204 | if (!svgContentFragment.includes("")) { 1205 | return `${svgContentFragment}`; 1206 | } 1207 | 1208 | // 提取从开始标签到首个结束标签的完整内容 1209 | const closingTagIndex = svgContentFragment.indexOf("") + 6; 1210 | if (closingTagIndex > 6) { 1211 | const extractedSvg = svgContentFragment.substring( 1212 | 0, 1213 | closingTagIndex 1214 | ); 1215 | if (this.isValidFullSvg(extractedSvg)) { 1216 | return extractedSvg; 1217 | } 1218 | } 1219 | } 1220 | 1221 | // 尝试处理只有内容片段的情况(无 SVG 标签但有 SVG 元素) 1222 | if ( 1223 | cleaned.match( 1224 | /<(circle|rect|path|g|text|line|polyline|polygon|ellipse)[\s\S]*?>/i 1225 | ) 1226 | ) { 1227 | const svgWrapper = `${cleaned}`; 1228 | if (this.isValidFullSvg(svgWrapper)) { 1229 | return svgWrapper; 1230 | } 1231 | } 1232 | 1233 | // 兜底:无法提取到有效 SVG 内容时,返回默认 SVG 1234 | return this.getDefaultSvg("解析失败"); 1235 | } 1236 | 1237 | /** 1238 | * 验证是否为有效的完整 SVG 1239 | * @private 1240 | * @param svgContent SVG 内容 1241 | * @returns 是否为有效的完整 SVG 1242 | */ 1243 | private isValidFullSvg(svgContent: string): boolean { 1244 | // 基本验证:必须包含开始和结束标签 1245 | if (!svgContent.match(/]*>[\s\S]*<\/svg>/i)) { 1246 | return false; 1247 | } 1248 | 1249 | // 验证必要的属性:xmlns 1250 | let validatedSvg = svgContent; 1251 | if (!validatedSvg.includes('xmlns="http://www.w3.org/2000/svg"')) { 1252 | // 尝试修复缺少 xmlns 的 SVG 1253 | validatedSvg = validatedSvg.replace( 1254 | //gi); 1264 | 1265 | return !!( 1266 | openTags && 1267 | closeTags && 1268 | openTags.length === 1 && 1269 | closeTags.length === 1 1270 | ); 1271 | } catch (e) { 1272 | return false; 1273 | } 1274 | } 1275 | 1276 | /** 1277 | * 获取默认的 SVG 内容 1278 | * @private 1279 | * @param message 错误信息 1280 | * @returns 默认 SVG 内容 1281 | */ 1282 | private getDefaultSvg(message: string): string { 1283 | return ` 1284 | 1285 | 1286 | ${message} - AI 生成内容将稍后显示 1287 | 1288 | 1289 | 请稍候或尝试重新生成 1290 | 1291 | `; 1292 | } 1293 | 1294 | /** 1295 | * 解析 AI 错误信息,处理多种嵌套错误格式 1296 | * 1297 | * @private 1298 | * @param error 错误对象 1299 | * @returns 格式化的用户友好错误消息 1300 | */ 1301 | private parseAIError(error: any): string { 1302 | // 默认错误信息 1303 | let errorMessage = "当前无可用模型,请稍后重试"; 1304 | 1305 | try { 1306 | // 如果是标准 Error 对象 1307 | if (error instanceof Error) { 1308 | errorMessage = error.message; 1309 | } 1310 | // 检查是否是对象 1311 | else if (typeof error === "object" && error !== null) { 1312 | // 1. 先检查直接的 error.message 1313 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 1314 | if (error.message) { 1315 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 1316 | errorMessage = String(error.message); 1317 | } 1318 | 1319 | // 2. 检查 AI_RetryError 结构 1320 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 1321 | if (error.name === "AI_RetryError") { 1322 | // 2.1 优先使用 lastError 1323 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 1324 | if (error.lastError?.data?.error?.message) { 1325 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 1326 | const msg = String(error.lastError.data.error.message); 1327 | if (msg.includes("无可用渠道")) { 1328 | return "AI 模型暂时不可用,请稍后再试或选择其他模型"; 1329 | } 1330 | return msg; 1331 | } 1332 | 1333 | // 2.2 使用 errors 数组中的第一个错误 1334 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 1335 | if ( 1336 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 1337 | Array.isArray(error.errors) && 1338 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 1339 | error.errors.length > 0 1340 | ) { 1341 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment 1342 | const firstError = error.errors[0]; 1343 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 1344 | if (firstError.data?.error?.message) { 1345 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 1346 | const msg = String(firstError.data.error.message); 1347 | if (msg.includes("无可用渠道")) { 1348 | return "AI 模型暂时不可用,请稍后再试或选择其他模型"; 1349 | } 1350 | return msg; 1351 | } 1352 | 1353 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 1354 | if (firstError.responseBody) { 1355 | try { 1356 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 1357 | const responseData = JSON.parse( 1358 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access 1359 | firstError.responseBody 1360 | ); 1361 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 1362 | if (responseData.error?.message) { 1363 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 1364 | const msg = String( 1365 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 1366 | responseData.error.message 1367 | ); 1368 | if (msg.includes("无可用渠道")) { 1369 | return "AI 模型暂时不可用,请稍后再试或选择其他模型"; 1370 | } 1371 | return msg; 1372 | } 1373 | } catch (e) { 1374 | // 解析 JSON 失败,忽略 1375 | } 1376 | } 1377 | } 1378 | } 1379 | 1380 | // 3. 检查 error.data.error.message 结构 1381 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 1382 | if (error.data?.error?.message) { 1383 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 1384 | const msg = String(error.data.error.message); 1385 | if (msg.includes("无可用渠道")) { 1386 | return "AI 模型暂时不可用,请稍后再试或选择其他模型"; 1387 | } 1388 | return msg; 1389 | } 1390 | 1391 | // 4. 尝试解析 error.data 1392 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 1393 | if (error.data && typeof error.data === "object") { 1394 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 1395 | if (error.data.message) { 1396 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 1397 | return String(error.data.message); 1398 | } 1399 | } 1400 | } 1401 | } catch (e) { 1402 | // 解析过程中出错,返回默认错误信息 1403 | console.error("解析 AI 错误时出错", e); 1404 | } 1405 | 1406 | return errorMessage; 1407 | } 1408 | } 1409 | -------------------------------------------------------------------------------- /src/modules/svg-generator/service/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | import { v4 as uuidv4 } from "uuid"; 3 | import { PrismaService } from "../../common"; 4 | import { UserData, UserInput } from "../model"; 5 | @Injectable() 6 | export class UserService { 7 | public constructor(private readonly prismaService: PrismaService) {} 8 | 9 | /** 10 | * Find all users in the database 11 | * 12 | * @returns A user list 13 | */ 14 | public async findAll(): Promise { 15 | const users = await this.prismaService.user.findMany({}); 16 | return users.map((user) => new UserData(user)); 17 | } 18 | 19 | /** 20 | * Find a user by ID 21 | * 22 | * @param id User ID 23 | * @returns The user if found 24 | */ 25 | public async findById(id: number): Promise { 26 | const user = await this.prismaService.user.findUnique({ 27 | where: { id }, 28 | }); 29 | return user ? new UserData(user) : null; 30 | } 31 | 32 | /** 33 | * Create a new user record 34 | * 35 | * @param data User details 36 | * @returns A user created in the database 37 | */ 38 | public async create(data: UserInput): Promise { 39 | const user = await this.prismaService.user.create({ 40 | data: { 41 | username: data.username ?? uuidv4().slice(0, 10), 42 | email: data.email, 43 | wechatOpenId: data.wechatOpenId, 44 | miniappOpenId: data.miniappOpenId, 45 | role: "USER", 46 | remainingCredits: 2, 47 | isInvited: false, 48 | }, 49 | }); 50 | 51 | return new UserData(user); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/modules/svg-generator/spec/passenger.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 2 | /* eslint-disable import/no-extraneous-dependencies */ 3 | 4 | import { HttpStatus, INestApplication } from '@nestjs/common'; 5 | import { Test } from '@nestjs/testing'; 6 | import * as jwt from 'jsonwebtoken'; 7 | import * as request from 'supertest'; 8 | 9 | import { App } from 'supertest/types'; 10 | import { ApplicationModule } from '../../app.module'; 11 | 12 | /** 13 | * Passenger API end-to-end tests 14 | * 15 | * This test suite performs end-to-end tests on the passenger API endpoints, 16 | * allowing us to test the behavior of the API and making sure that it fits 17 | * the requirements. 18 | */ 19 | describe('Passenger API', () => { 20 | 21 | let app: INestApplication; 22 | 23 | beforeAll(async () => { 24 | 25 | const module = await Test.createTestingModule({ 26 | imports: [ApplicationModule], 27 | }) 28 | .compile(); 29 | 30 | app = module.createNestApplication(); 31 | await app.init(); 32 | }); 33 | 34 | afterAll(async () => 35 | app.close() 36 | ); 37 | 38 | it('Should return empty passenger list', async () => 39 | 40 | request(app.getHttpServer() as App) 41 | .get('/passengers') 42 | .expect(HttpStatus.OK) 43 | .then(response => { 44 | expect(response.body).toBeInstanceOf(Array); 45 | expect(response.body.length).toEqual(0); 46 | }) 47 | ); 48 | 49 | it('Should insert new passenger in the API', async () => { 50 | 51 | const token = jwt.sign({ role: 'restricted' }, `${process.env.JWT_SECRET}`, { 52 | algorithm: 'HS256', 53 | issuer: `${process.env.JWT_ISSUER}` 54 | }); 55 | 56 | return request(app.getHttpServer() as App) 57 | .post('/passengers') 58 | .set('Authorization', `Bearer ${token}`) 59 | .send({ 60 | firstName: 'John', 61 | lastName: 'Doe' 62 | }) 63 | .expect(HttpStatus.CREATED) 64 | .then(response => { 65 | expect(response.body.firstName).toEqual('John'); 66 | expect(response.body.lastName).toEqual('Doe'); 67 | }); 68 | }); 69 | 70 | }); 71 | -------------------------------------------------------------------------------- /src/modules/svg-generator/svg-generator.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | 3 | import { CommonModule } from "../common"; 4 | import { SvgGeneratorController } from "./controller"; 5 | import { SvgGenerationService, UserService } from "./service"; 6 | 7 | @Module({ 8 | imports: [CommonModule], 9 | providers: [SvgGenerationService, UserService], 10 | controllers: [SvgGeneratorController], 11 | exports: [], 12 | }) 13 | export class SvgGeneratorModule {} 14 | -------------------------------------------------------------------------------- /src/modules/svg-generator/validation/user.schema.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from "joi"; 2 | 3 | export const userSchema = Joi.object({ 4 | username: Joi.string().required(), 5 | email: Joi.string().email().optional(), 6 | password: Joi.string().min(6).required(), 7 | wechatOpenId: Joi.string().optional(), 8 | miniappOpenId: Joi.string().optional(), 9 | }); 10 | -------------------------------------------------------------------------------- /src/modules/tokens.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * These tokens can be used for NestJS custom providers. For each required 3 | * custom provider, declare a new string below and use in the whole application. 4 | * 5 | * @see https://docs.nestjs.com/fundamentals/custom-providers 6 | */ 7 | export enum Service { 8 | 9 | STORAGE = 'storage.service', 10 | 11 | CONFIG = 'config.service', 12 | 13 | } 14 | 15 | export enum Role { 16 | 17 | RESTRICTED = 'restricted' 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/scripts/send-apology-emails.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "@nestjs/common"; 2 | import { NestFactory } from "@nestjs/core"; 3 | import * as nodemailer from "nodemailer"; 4 | import { ApplicationModule } from "../modules/app.module"; 5 | import { MailConfig } from "../modules/auth/model/mail-config"; 6 | import { MailTemplateService } from "../modules/auth/service/mail-template.service"; 7 | 8 | /** 9 | * 批量发送道歉邮件脚本 10 | */ 11 | async function bootstrap() { 12 | const logger = new Logger("SendApologyEmails"); 13 | logger.log("开始发送道歉邮件..."); 14 | 15 | try { 16 | // 创建 NestJS 应用程序实例 17 | const app = await NestFactory.createApplicationContext( 18 | ApplicationModule 19 | ); 20 | 21 | // 获取邮件模板服务 22 | const mailTemplateService = 23 | app.get(MailTemplateService); 24 | 25 | // 邮箱列表 26 | const emailList = [ 27 | "snailrun160@gmail.com", 28 | "superzhou2007@126.com", 29 | // "soul1899@gmail.com", 30 | // "8462304@qq.com", 31 | // "byebye_415@163.com", 32 | // "sdjnzq@sina.com", 33 | // "freedly@gmail.com", 34 | // "yudalang@ucas.edu.cn", 35 | // "gpyquw@mailto.plus", 36 | // "937723369@qq.com", 37 | // "gcc1117@gmail.com", 38 | // "lyfxsxh@163.com", 39 | // "3706435@qq.com", 40 | // "25169133@qq.com", 41 | // "mikerchen@msn.com", 42 | // "244760145@qq.com", 43 | // "flycallme557@gmail.com", 44 | ]; 45 | 46 | // 获取邮件模板 47 | const htmlContent = mailTemplateService.getApologyTemplate(); 48 | const textContent = mailTemplateService.getApologyText(); 49 | 50 | // 直接创建一个新的 MailConfig 实例 51 | const mailConfig = new MailConfig(); 52 | 53 | // 批量发送邮件 54 | let successCount = 0; 55 | let failCount = 0; 56 | 57 | // 创建一个新的 transporter 58 | const transporter = nodemailer.createTransport({ 59 | host: mailConfig.host, 60 | port: mailConfig.port, 61 | secure: mailConfig.secure, 62 | auth: { 63 | user: mailConfig.user, 64 | pass: mailConfig.pass, 65 | }, 66 | }); 67 | 68 | for (const email of emailList) { 69 | try { 70 | // 准备邮件内容 71 | const mailOptions = { 72 | from: `"${mailConfig.senderName}" <${mailConfig.user}>`, 73 | to: email, 74 | subject: "服务恢复通知 - SVG 生成器", 75 | text: textContent, 76 | html: htmlContent, 77 | }; 78 | 79 | // 发送邮件 80 | await transporter.sendMail(mailOptions); 81 | logger.log(`成功发送邮件到:${email}`); 82 | successCount++; 83 | 84 | // 添加延迟避免邮件服务器限制 85 | await new Promise((resolve) => setTimeout(resolve, 6000)); 86 | } catch (error) { 87 | logger.error( 88 | `发送邮件到 ${email} 失败:`, 89 | error instanceof Error ? error.message : String(error) 90 | ); 91 | failCount++; 92 | } 93 | } 94 | 95 | logger.log(`邮件发送完成。成功:${successCount}, 失败:${failCount}`); 96 | await app.close(); 97 | } catch (error) { 98 | logger.error( 99 | "批量发送邮件失败:", 100 | error instanceof Error ? error.message : String(error) 101 | ); 102 | } 103 | } 104 | 105 | // 执行脚本 106 | bootstrap() 107 | .then(() => process.exit(0)) 108 | .catch((error) => { 109 | console.error( 110 | "执行过程中发生错误:", 111 | error instanceof Error ? error.message : String(error) 112 | ); 113 | process.exit(1); 114 | }); 115 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from "@nestjs/common"; 2 | import { NestFactory } from "@nestjs/core"; 3 | import { 4 | FastifyAdapter, 5 | NestFastifyApplication, 6 | } from "@nestjs/platform-fastify"; 7 | import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; 8 | 9 | import { ApplicationModule } from "./modules/app.module"; 10 | import { JwtGuard } from "./modules/auth/service/jwt.guard"; 11 | import { CommonModule, LogInterceptor } from "./modules/common"; 12 | 13 | /** 14 | * These are API defaults that can be changed using environment variables, 15 | * it is not required to change them (see the `.env.example` file) 16 | */ 17 | const API_DEFAULT_PORT = 3001; 18 | const API_DEFAULT_PREFIX = "/api/v1/"; 19 | 20 | /** 21 | * The defaults below are dedicated to Swagger configuration, change them 22 | * following your needs (change at least the title & description). 23 | * 24 | * @todo Change the constants below following your API requirements 25 | */ 26 | const SWAGGER_TITLE = "svg-generator API"; 27 | const SWAGGER_DESCRIPTION = "API used for svg-generator management"; 28 | const SWAGGER_PREFIX = "/docs"; 29 | 30 | /** 31 | * Register a Swagger module in the NestJS application. 32 | * This method mutates the given `app` to register a new module dedicated to 33 | * Swagger API documentation. Any request performed on `SWAGGER_PREFIX` will 34 | * receive a documentation page as response. 35 | * 36 | * @todo See the `nestjs/swagger` NPM package documentation to customize the 37 | * code below with API keys, security requirements, tags and more. 38 | */ 39 | function createSwagger(app: INestApplication) { 40 | const options = new DocumentBuilder() 41 | .setTitle(SWAGGER_TITLE) 42 | .setDescription(SWAGGER_DESCRIPTION) 43 | .addBearerAuth() 44 | .build(); 45 | 46 | const document = SwaggerModule.createDocument(app, options); 47 | SwaggerModule.setup(SWAGGER_PREFIX, app, document); 48 | } 49 | 50 | /** 51 | * Build & bootstrap the NestJS API. 52 | * This method is the starting point of the API; it registers the application 53 | * module and registers essential components such as the logger and request 54 | * parsing middleware. 55 | */ 56 | async function bootstrap(): Promise { 57 | const app = await NestFactory.create( 58 | ApplicationModule, 59 | new FastifyAdapter() 60 | ); 61 | 62 | // @todo Enable Helmet for better API security headers 63 | 64 | app.setGlobalPrefix(process.env.API_PREFIX || API_DEFAULT_PREFIX); 65 | 66 | if (!process.env.SWAGGER_ENABLE || process.env.SWAGGER_ENABLE === "1") { 67 | createSwagger(app); 68 | } 69 | 70 | const logInterceptor = app.select(CommonModule).get(LogInterceptor); 71 | app.useGlobalInterceptors(logInterceptor); 72 | 73 | // 注册全局JWT认证守卫 74 | const jwtGuard = app.get(JwtGuard); 75 | app.useGlobalGuards(jwtGuard); 76 | 77 | // 尝试启动服务器,如果端口被占用则尝试下一个端口 78 | let port = parseInt( 79 | process.env.API_PORT || API_DEFAULT_PORT.toString(), 80 | 10 81 | ); 82 | const maxRetries = 10; // 最大尝试次数 83 | let retries = 0; 84 | while (retries < maxRetries) { 85 | try { 86 | await app.listen(port, "0.0.0.0"); 87 | 88 | // 获取服务器URL 89 | const url = await app.getUrl(); 90 | 91 | // 打印服务器信息 92 | console.log(`\n🚀 服务器已启动!`); 93 | console.log( 94 | `📡 本地访问: http://localhost:${port}${API_DEFAULT_PREFIX}` 95 | ); 96 | console.log(`🌐 网络访问: ${url}${API_DEFAULT_PREFIX}`); 97 | 98 | if ( 99 | !process.env.SWAGGER_ENABLE || 100 | process.env.SWAGGER_ENABLE === "1" 101 | ) { 102 | console.log( 103 | `📚 API文档: http://localhost:${port}${SWAGGER_PREFIX}` 104 | ); 105 | } 106 | 107 | break; // 成功启动,跳出循环 108 | } catch (error: unknown) { 109 | if ( 110 | typeof error === "object" && 111 | error !== null && 112 | "code" in error && 113 | error.code === "EADDRINUSE" 114 | ) { 115 | console.log( 116 | `⚠️ 端口 ${port} 已被占用,尝试端口 ${port + 1}...` 117 | ); 118 | port++; 119 | retries++; 120 | } else { 121 | throw error; // 如果是其他错误,则抛出 122 | } 123 | } 124 | } 125 | if (retries >= maxRetries) { 126 | throw new Error( 127 | `无法启动服务器:尝试了 ${maxRetries} 个端口,但都被占用` 128 | ); 129 | } 130 | } 131 | 132 | /** 133 | * It is now time to turn the lights on! 134 | * Any major error that can not be handled by NestJS will be caught in the code 135 | * below. The default behavior is to display the error on stdout and quit. 136 | * 137 | * @todo It is often advised to enhance the code below with an exception-catching 138 | * service for better error handling in production environments. 139 | */ 140 | bootstrap().catch((err) => { 141 | // eslint-disable-next-line no-console 142 | console.error(err); 143 | 144 | const defaultExitCode = 1; 145 | process.exit(defaultExitCode); 146 | }); 147 | -------------------------------------------------------------------------------- /src/tokens.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 依赖注入令牌常量 3 | */ 4 | export enum Service { 5 | CONFIG = "config", 6 | LOGGER = "logger", 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "include": [ 4 | "src/**/*", 5 | "**/*.spec.ts", 6 | ], 7 | "exclude": [ 8 | "node_modules", 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": false, 5 | "noImplicitAny": true, 6 | "removeComments": true, 7 | "noLib": false, 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "target": "ES2022", 11 | "sourceMap": true, 12 | "allowJs": true, 13 | "outDir": "./dist", 14 | "strictNullChecks": true, 15 | "noUnusedLocals": true 16 | }, 17 | "include": [ 18 | "src/**/*" 19 | ], 20 | "exclude": [ 21 | "node_modules", 22 | "**/*.spec.ts", 23 | ] 24 | } 25 | --------------------------------------------------------------------------------