diff --git a/.env.example b/.env.example index 9f610b7..74fa34e 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,8 @@ MINIO_PORT=9000 MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin MINIO_BUCKET=kt-template-online + +ADMIN_TOKEN_SECRET=change-me +ADMIN_COOKIE_SECURE=false +SNOWFLAKE_WORKER_ID=1 +SNOWFLAKE_DATACENTER_ID=1 diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..5c056bb --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +node scripts/validate-commit-msg.mjs "$1" diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..8ff2704 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm run verify:commit diff --git a/API.md b/API.md index 3dae025..425b59b 100644 --- a/API.md +++ b/API.md @@ -26,38 +26,54 @@ | 模块 | 说明 | | --------- | --------------------------------------------------------------------- | -| Component | 组件/图表模板的列表、详情、新增、编辑、逻辑删除 | -| Dict | 数据库字典查询,以及组件一级类型到二级类型的数据库关系映射 | +| Component | Admin 下受保护的组件/图表模板列表、详情、新增、编辑、逻辑删除,数据表为 `admin_component` | +| Dict | 基于新 `admin_dict` 表的数据库字典查询,以及组件一级类型到二级类型的数据库关系映射 | +| Admin | Vben Admin 真实接口,包含认证、用户、菜单、角色、部门、时区和上传适配 | | MinIO | Bucket 检查/创建、文件上传、列表、临时访问地址、下载和删除 | | Common | 统一响应 Swagger 注解、字典翻译注解、`POST */save` 请求体规范化拦截器 | ## 通用规则 +### 数字 ID + +后台主键统一使用 Snowflake 数字 ID。数据库字段使用 `BIGINT`;接口 JSON 中按字符串返回,例如 `"2041739550026043392"`,避免 JavaScript 直接用 `number` 承载 64 位长整型导致精度丢失。 + +如果旧版本曾经写入 `admin_user.id=0`,请先执行 `sql/fix-admin-user-zero-id.sql` 修复已有脏数据,再重启后端服务。 + ### Save 请求体规范化 系统全局注册 `SaveBodyInterceptor`,默认会对 `POST */save` 请求删除 `body.id`,避免新增接口因为前端误传 `id` 而走指定主键保存。 如果个别接口需要保留 `id`,可在对应 Controller 方法上使用 `@SkipSaveBodyNormalize()`。 +### 后台认证 + +Admin 与 Component 业务接口统一走 `JwtAuthGuard`。请求可以通过 `Authorization: Bearer ` 传递 accessToken,也可以携带登录接口写入的 httpOnly `admin_access_token` cookie。未认证时接口返回 HTTP `401`。 + +`ADMIN_COOKIE_SECURE=false` 适用于当前内网 HTTP 访问;如果后续切到 HTTPS 域名,可以改为 `true`,cookie 会使用 `Secure + SameSite=None`。 + +`@Public()` 可用于保留不需要认证的接口口子,目前登录、刷新 token、退出登录和部分示例状态测试接口放行。 + ### 数据库字典翻译 -字典数据维护在数据库 `dict` 表中。`Component.typeMsg`、`Component.componentTypeMsg` 会在 TypeORM `AfterLoad` 阶段根据字典缓存自动映射。 +组件数据维护在 `admin_component` 表中,字典数据维护在新的 `admin_dict` 表中。`Component.typeMsg`、`Component.componentTypeMsg` 会在 TypeORM `AfterLoad` 阶段根据字典缓存自动映射;旧 `/dict/*` 接口路径保持兼容。 -`dict` 表核心字段: +`admin_dict` 表核心字段: | 字段 | 类型 | 说明 | | ----------- | ------- | ------------------------------------------------------ | -| id | string | 字典 ID | -| dictKey | string | 字典分组,例如 `COMPONENT_TYPE`、`CHART`、`COMPONENT` | +| id | string | 字典数字 ID | +| dictCode | string | 字典分组,例如 `COMPONENT_TYPE`、`CHART`、`COMPONENT` | | label | string | 展示文本 | | value | string | 字典值 | -| childrenKey | string | 子字典分组,例如 `COMPONENT_TYPE.value=1` 指向 `CHART` | +| childrenCode | string | 子字典分组,例如 `COMPONENT_TYPE.value=1` 指向 `CHART` | | sort | number | 排序 | -| is_deleted | boolean | 逻辑删除标记 | +| status | number | 启停状态,`1` 启用 | +| isDeleted | boolean | 逻辑删除标记 | 当前数据库示例关系: -| dictKey | value | label | childrenKey | +| dictCode | value | label | childrenCode | | -------------- | ----- | ----- | ----------- | | COMPONENT_TYPE | 1 | 图表 | CHART | | COMPONENT_TYPE | 2 | 组件 | COMPONENT | @@ -68,7 +84,7 @@ | 字段 | 类型 | 说明 | | ---------------- | ------- | ---------------------------------- | -| id | string | 组件 ID,新增时由后端生成 | +| id | string | 组件数字 ID,新增时由后端生成 | | name | string | 组件名称 | | type | number | 一级类型,实际含义由 `dict` 表维护 | | componentType | number | 二级类型,实际含义由 `dict` 表维护 | @@ -101,6 +117,8 @@ ## Component 接口 +组件接口仍保持 `/component/*` 路径兼容,但模块已迁入 Admin 目录并要求后台登录态。`kt-template-online-web` 和 `kt-template-online-playground` 收到 `401` 后会跳转到 `kt-template-admin` 登录页,登录完成再回到原页面。 + ### GET `/component/allList` 获取全部组件。 @@ -115,7 +133,7 @@ "msg": "操作成功", "data": [ { - "id": "1d8d3dd2-99f0-4d10-9a44-0cf9566b37c9", + "id": "2041739550026043392", "name": "基础折线图", "type": 1, "componentType": 1, @@ -188,7 +206,7 @@ Body: { "code": 200, "msg": "操作成功", - "data": "1d8d3dd2-99f0-4d10-9a44-0cf9566b37c9" + "data": "2041739550026043392" } ``` @@ -200,7 +218,7 @@ Body: ```json { - "id": "1d8d3dd2-99f0-4d10-9a44-0cf9566b37c9", + "id": "2041739550026043392", "name": "基础折线图", "type": 1, "componentType": 1, @@ -254,7 +272,7 @@ Query: 根据组件一级类型获取对应的二级类型字典。 -查询逻辑:先查 `dictKey=COMPONENT_TYPE` 且 `value=type` 的字典项,再使用该项的 `childrenKey` 查询子字典。 +查询逻辑:先查 `dictCode=COMPONENT_TYPE` 且 `value=type` 的字典项,再使用该项的 `childrenCode` 查询子字典。 Query: @@ -264,6 +282,57 @@ Query: 响应 `data`:`Array<{ label: string; value: number | string }>`。 +## Vben Admin 真实接口 + +这些接口用于 `Vue/kt-template-admin`,响应格式与 Vben 请求拦截器对齐: + +```json +{ + "code": 0, + "message": "ok", + "data": {} +} +``` + +核心接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/auth/login` | 登录,返回 `accessToken`,并写入 access token 与刷新 token cookie | +| POST | `/auth/refresh` | 通过刷新 token cookie 刷新 accessToken,并更新 token cookie | +| POST | `/auth/logout` | 退出登录并清理 access token 与刷新 token cookie | +| GET | `/auth/codes` | 获取当前用户权限码 | +| GET | `/user/info` | 获取当前用户信息 | +| GET | `/menu/all` | 获取当前用户可访问菜单 | +| GET | `/system/menu/list` | 获取系统菜单树 | +| GET | `/system/menu/name-exists` | 校验菜单 name 是否重复 | +| GET | `/system/menu/path-exists` | 校验菜单 path 是否重复 | +| POST | `/system/menu` | 新增菜单 | +| PUT | `/system/menu/:id` | 更新菜单 | +| DELETE | `/system/menu/:id` | 删除菜单及子菜单 | +| GET | `/system/role/list` | 分页查询角色 | +| POST | `/system/role` | 新增角色 | +| PUT | `/system/role/:id` | 更新角色 | +| DELETE | `/system/role/:id` | 删除角色 | +| GET | `/system/dept/list` | 获取部门树 | +| POST | `/system/dept` | 新增部门 | +| PUT | `/system/dept/:id` | 更新部门 | +| DELETE | `/system/dept/:id` | 删除部门 | +| GET | `/timezone/getTimezoneOptions` | 获取时区选项 | +| GET | `/timezone/getTimezone` | 获取当前用户时区 | +| POST | `/timezone/setTimezone` | 设置当前用户时区 | +| POST | `/upload` | Vben Upload 适配接口,真实上传到 MinIO 并返回 `{ url }` | +| GET | `/table/list` | Vben 示例远程表格数据 | +| GET | `/status` | Vben 状态码测试接口 | +| GET | `/demo/bigint` | Vben BigInt JSON 测试接口 | + +初始化 SQL: + +- `sql/vben-admin-init.sql`:创建 `admin_*` 表并导入基础用户、角色、菜单、部门、字典数据,同时创建空的 `admin_component` 表。 +- `sql/migrate-dict-to-admin-dict.sql`:将旧 `dict` 表数据迁移到 `admin_dict`。 +- `sql/migrate-component-to-admin-component.sql`:将旧 `component` 表数据迁移到 `admin_component`,并把旧表改名为备份表。 +- `sql/fix-admin-menu-meta.sql`:修复基础后台菜单 `meta` 被旧数据或错误保存覆盖为空的问题。 + ## MinIO 接口 ### GET `/minio/check` diff --git a/README.md b/README.md index 7a693f1..fe9b742 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,9 @@ | 模块 | 说明 | | --- | --- | -| `component` | 组件/图表模板的列表、详情、新增、编辑、逻辑删除 | -| `dict` | 数据库字典查询,维护组件一级类型和二级类型关系 | +| `component` | Admin 下受保护的组件/图表模板列表、详情、新增、编辑、逻辑删除,数据表为 `admin_component` | +| `dict` | 基于新 `admin_dict` 表的字典查询,维护组件一级类型和二级类型关系 | +| `admin` | Vben Admin 真实接口,包含登录、用户、菜单、角色、部门、时区、上传和示例表格 | | `minio` | Bucket 检查/创建、文件上传、列表、临时访问地址、下载和删除 | | `common` | 响应注解、字典翻译、`POST */save` 请求体规范化等通用能力 | @@ -25,8 +26,7 @@ ```text src common/ # 通用装饰器、拦截器、服务、Swagger 封装 - component/ # 组件模板模块 - dict/ # 字典模块 + admin/ # Vben Admin 后台认证、组件、字典、菜单、角色、部门等接口 minio/ # MinIO 文件模块 types/ # 全局类型声明 app.module.ts # 全局模块、数据库、MinIO、拦截器注册 @@ -50,9 +50,15 @@ MINIO_PORT=9000 MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin MINIO_BUCKET=kt-template-online + +ADMIN_TOKEN_SECRET=change-me +ADMIN_COOKIE_SECURE=false +SNOWFLAKE_WORKER_ID=1 +SNOWFLAKE_DATACENTER_ID=1 ``` `DB_SYNC=true` 会让 TypeORM 根据实体同步表结构。生产环境建议关闭同步,改用迁移脚本维护表结构。 +内网 HTTP 访问时保持 `ADMIN_COOKIE_SECURE=false`;如果未来切到 HTTPS 域名,再改为 `true`。 ## 启动 @@ -93,7 +99,14 @@ pnpm test:e2e # e2e 测试 ## 核心规则 -- `dict` 表是字典翻译数据源,`Component.typeMsg` 和 `Component.componentTypeMsg` 查询后自动映射。 +- `admin_component` 表保存组件/图表模板,`admin_dict` 表是统一字典翻译数据源,`Component.typeMsg` 和 `Component.componentTypeMsg` 查询后自动映射;旧 `/dict/*` 接口路径保持兼容。 +- 业务主键统一由 Snowflake 生成数字 ID,数据库使用 `BIGINT`,接口按字符串返回以避免前端长整型精度问题。 +- 如果基础后台菜单的 `meta` 被旧数据覆盖为空,执行 `sql/fix-admin-menu-meta.sql` 可以恢复初始化菜单的 `title/icon/order` 等元数据。 +- 旧 `component` 表迁移到 `admin_component` 时,执行 `sql/migrate-component-to-admin-component.sql`,脚本会把旧表重命名为备份表。 +- 如果旧版本曾写入 `admin_user.id=0`,先执行 `sql/fix-admin-user-zero-id.sql` 修复脏数据,再重启服务。 +- Admin 与 Component 业务接口统一走 `JwtAuthGuard`;登录、刷新 token、退出登录和部分示例状态测试接口通过 `@Public()` 放行。 +- `kt-template-admin` 登录会写入 access token 与刷新 token cookie,`kt-template-online-web` 和 `kt-template-online-playground` 可在回跳后通过刷新 token 重新持久化登录态。 +- `kt-template-admin` 开发环境通过 `/api` 代理到本服务 `48085`,已关闭 Vben Nitro Mock。 - `POST /component/save` 新增组件,`POST /component/update` 编辑组件。 - 全局 `SaveBodyInterceptor` 会删除 `POST */save` 请求体里的 `id`,避免新增接口误用前端主键。 - 如个别 `save` 接口必须保留 `id`,在 Controller 方法上使用 `@SkipSaveBodyNormalize()`。 @@ -101,8 +114,8 @@ pnpm test:e2e # e2e 测试 ## 联调关系 -- `kt-template-online-web` 读取 `/component/list`、`/component/detail`、`/dict/*` 展示组件列表,并生成 Playground 跳转链接。 -- `kt-template-online-playground` 读取 `/dict/*` 初始化分类,保存时上传截图到 `/minio/upload`,再调用 `/component/save` 或 `/component/update`。 +- `kt-template-online-web` 读取 `/component/list`、`/component/detail`、`/dict/*` 展示组件列表,并生成 Playground 跳转链接;组件接口返回 `401` 时跳转到 `kt-template-admin` 登录。 +- `kt-template-online-playground` 读取 `/dict/*` 初始化分类,保存时上传截图到 `/minio/upload`,再调用 `/component/save` 或 `/component/update`;组件接口返回 `401` 时跳转到 `kt-template-admin` 登录并在回跳后刷新 token。 - 前端项目通过 Vite 代理把 `/api` 转发到 `http://localhost:48085/`。 ## 轻量验证 diff --git a/package.json b/package.json index 2677c41..7804391 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,15 @@ "build": "nest build", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"", + "prepare": "husky", "start": "nest start", "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "cross-env NODE_ENV=production node dist/main", + "typecheck": "tsc --noEmit", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", "lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "verify:commit": "pnpm run lint && pnpm run typecheck", "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage", @@ -55,6 +58,7 @@ "eslint-config-prettier": "^8.10.2", "eslint-plugin-prettier": "^4.2.5", "eslint-plugin-typeorm": "0.0.19", + "husky": "^9.1.7", "jest": "29.3.1", "prettier": "^2.8.8", "source-map-support": "^0.5.21", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb259f5..29a7f7b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: eslint-plugin-typeorm: specifier: 0.0.19 version: 0.0.19 + husky: + specifier: ^9.1.7 + version: 9.1.7 jest: specifier: 29.3.1 version: 29.3.1(@types/node@18.11.18)(ts-node@10.9.2(@types/node@18.11.18)(typescript@4.9.5)) @@ -1913,6 +1916,11 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -5954,6 +5962,8 @@ snapshots: human-signals@2.1.0: {} + husky@9.1.7: {} + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 diff --git a/scripts/validate-commit-msg.mjs b/scripts/validate-commit-msg.mjs new file mode 100644 index 0000000..5bb0a68 --- /dev/null +++ b/scripts/validate-commit-msg.mjs @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs'; + +const messageFile = process.argv[2]; +const firstLine = readFileSync(messageFile, 'utf8').split(/\r?\n/)[0].trim(); + +const releaseMessages = /^(?:Merge|Revert|fixup!|squash!)/; +const ktCommitPattern = + /^(?:feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\([\w./-]+\))?: .+/; +const hasChineseText = /[\u4E00-\u9FFF]/; + +if (releaseMessages.test(firstLine)) { + process.exit(0); +} + +if (!ktCommitPattern.test(firstLine) || !hasChineseText.test(firstLine)) { + console.error( + [ + '提交信息格式不正确。', + '要求:英文类型前缀 + 可选 scope + 冒号空格 + 中文描述。', + '示例:feat(api): 增加提交校验', + ].join('\n'), + ); + process.exit(1); +} diff --git a/sql/fix-admin-menu-meta.sql b/sql/fix-admin-menu-meta.sql new file mode 100644 index 0000000..64f2b1a --- /dev/null +++ b/sql/fix-admin-menu-meta.sql @@ -0,0 +1,51 @@ +-- 修复基础后台菜单 meta 被写空的问题。 +-- 只覆盖初始化 SQL 中维护的系统菜单,不影响自定义菜单。 + +SET NAMES utf8mb4; + +UPDATE `admin_menu` +SET `meta` = CASE `name` + WHEN 'Dashboard' THEN '{"order":-1,"title":"page.dashboard.title"}' + WHEN 'Analytics' THEN '{"affixTab":true,"title":"page.dashboard.analytics"}' + WHEN 'Workspace' THEN '{"icon":"carbon:workspace","title":"page.dashboard.workspace"}' + WHEN 'System' THEN '{"badge":"new","badgeType":"normal","badgeVariants":"primary","icon":"carbon:settings","order":9997,"title":"system.title"}' + WHEN 'SystemRole' THEN '{"icon":"carbon:user-role","title":"system.role.title"}' + WHEN 'SystemRoleCreate' THEN '{"title":"common.create"}' + WHEN 'SystemRoleEdit' THEN '{"title":"common.edit"}' + WHEN 'SystemRoleDelete' THEN '{"title":"common.delete"}' + WHEN 'SystemMenu' THEN '{"icon":"carbon:menu","title":"system.menu.title"}' + WHEN 'SystemMenuCreate' THEN '{"title":"common.create"}' + WHEN 'SystemMenuEdit' THEN '{"title":"common.edit"}' + WHEN 'SystemMenuDelete' THEN '{"title":"common.delete"}' + WHEN 'SystemDept' THEN '{"icon":"carbon:container-services","title":"system.dept.title"}' + WHEN 'SystemDeptCreate' THEN '{"title":"common.create"}' + WHEN 'SystemDeptEdit' THEN '{"title":"common.edit"}' + WHEN 'SystemDeptDelete' THEN '{"title":"common.delete"}' + WHEN 'Project' THEN '{"badgeType":"dot","icon":"carbon:data-center","order":9998,"title":"demos.vben.title"}' + WHEN 'VbenDocument' THEN '{"icon":"carbon:book","iframeSrc":"https://doc.vben.pro","title":"demos.vben.document"}' + WHEN 'VbenGithub' THEN '{"icon":"carbon:logo-github","link":"https://github.com/vbenjs/vue-vben-admin","title":"Github"}' + WHEN 'About' THEN '{"icon":"lucide:copyright","order":9999,"title":"demos.vben.about"}' + ELSE `meta` +END +WHERE `name` IN ( + 'Dashboard', + 'Analytics', + 'Workspace', + 'System', + 'SystemRole', + 'SystemRoleCreate', + 'SystemRoleEdit', + 'SystemRoleDelete', + 'SystemMenu', + 'SystemMenuCreate', + 'SystemMenuEdit', + 'SystemMenuDelete', + 'SystemDept', + 'SystemDeptCreate', + 'SystemDeptEdit', + 'SystemDeptDelete', + 'Project', + 'VbenDocument', + 'VbenGithub', + 'About' +); diff --git a/sql/fix-admin-user-zero-id.sql b/sql/fix-admin-user-zero-id.sql new file mode 100644 index 0000000..79201ac --- /dev/null +++ b/sql/fix-admin-user-zero-id.sql @@ -0,0 +1,17 @@ +-- 修复旧 Snowflake 主键逻辑异常时写入的 admin_user.id = 0 数据。 +-- 执行后再重启后端;新代码会在插入前自动补齐合法数字 ID。 + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +SET @new_admin_user_id := 2041700000000099999; + +UPDATE `admin_user_role` +SET `user_id` = @new_admin_user_id +WHERE `user_id` = 0; + +UPDATE `admin_user` +SET `id` = @new_admin_user_id +WHERE `id` = 0; + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/sql/migrate-component-to-admin-component.sql b/sql/migrate-component-to-admin-component.sql new file mode 100644 index 0000000..2148eb5 --- /dev/null +++ b/sql/migrate-component-to-admin-component.sql @@ -0,0 +1,67 @@ +-- 将旧 component 表迁移为 admin_component。 +-- 旧表若存在会先复制数据,再重命名为 component_bak_before_admin_prefix_yyyyMMddHHmmss 备份表。 + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +CREATE TABLE IF NOT EXISTS `admin_component` ( + `id` bigint NOT NULL, + `name` varchar(255) NOT NULL DEFAULT '', + `type` int NOT NULL, + `component_type` int NOT NULL, + `image` mediumtext NOT NULL, + `template` mediumtext NOT NULL, + `is_deleted` tinyint(1) NOT NULL DEFAULT 0, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + KEY `idx_admin_component_type` (`type`), + KEY `idx_admin_component_component_type` (`component_type`), + KEY `idx_admin_component_deleted` (`is_deleted`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +SET @component_old_exists = ( + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = 'component' +); + +SET @component_row := 0; +SET @component_copy_sql = IF( + @component_old_exists = 1, + 'INSERT IGNORE INTO `admin_component` (`id`, `name`, `type`, `component_type`, `image`, `template`, `is_deleted`, `create_time`, `update_time`) + SELECT + CASE + WHEN CAST(`id` AS CHAR) REGEXP ''^[0-9]+$'' AND CAST(`id` AS UNSIGNED) > 0 THEN CAST(`id` AS UNSIGNED) + ELSE 2041700000000500000 + (@component_row := @component_row + 1) + END, + `name`, + `type`, + `component_type`, + `image`, + `template`, + `is_deleted`, + `create_time`, + `update_time` + FROM `component`', + 'SELECT 1' +); +PREPARE component_copy_stmt FROM @component_copy_sql; +EXECUTE component_copy_stmt; +DEALLOCATE PREPARE component_copy_stmt; + +SET @component_backup_table = CONCAT( + 'component_bak_before_admin_prefix_', + DATE_FORMAT(NOW(), '%Y%m%d%H%i%s') +); +SET @component_rename_sql = IF( + @component_old_exists = 1, + CONCAT('RENAME TABLE `component` TO `', @component_backup_table, '`'), + 'SELECT 1' +); +PREPARE component_rename_stmt FROM @component_rename_sql; +EXECUTE component_rename_stmt; +DEALLOCATE PREPARE component_rename_stmt; + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/sql/migrate-dict-to-admin-dict.sql b/sql/migrate-dict-to-admin-dict.sql new file mode 100644 index 0000000..bf0c4f1 --- /dev/null +++ b/sql/migrate-dict-to-admin-dict.sql @@ -0,0 +1,41 @@ +-- 旧 dict 表到新 admin_dict 表的数据迁移脚本。 +-- 使用方式:目标库里仍存在旧 `dict` 表时,在执行 `vben-admin-init.sql` 创建新表后执行本脚本。 + +SET NAMES utf8mb4; +SET @dict_snowflake_base := 2041700000000400000; +SET @dict_snowflake_row := 0; + +INSERT INTO `admin_dict` ( + `id`, + `dict_code`, + `label`, + `value`, + `children_code`, + `sort`, + `status`, + `is_deleted`, + `create_time`, + `update_time` +) +SELECT + CASE + WHEN CAST(`id` AS CHAR) REGEXP '^[0-9]+$' THEN CAST(`id` AS UNSIGNED) + ELSE @dict_snowflake_base + (@dict_snowflake_row := @dict_snowflake_row + 1) + END, + `dict_key`, + `label`, + `value`, + `children_key`, + COALESCE(`sort`, 0), + IF(COALESCE(`is_deleted`, 0) = 0, 1, 0), + COALESCE(`is_deleted`, 0), + COALESCE(`create_time`, CURRENT_TIMESTAMP(6)), + COALESCE(`update_time`, CURRENT_TIMESTAMP(6)) +FROM `dict` +ON DUPLICATE KEY UPDATE + `label` = VALUES(`label`), + `children_code` = VALUES(`children_code`), + `sort` = VALUES(`sort`), + `status` = VALUES(`status`), + `is_deleted` = VALUES(`is_deleted`), + `update_time` = VALUES(`update_time`); diff --git a/sql/vben-admin-init.sql b/sql/vben-admin-init.sql new file mode 100644 index 0000000..9bdd2ac --- /dev/null +++ b/sql/vben-admin-init.sql @@ -0,0 +1,250 @@ +-- Vben Admin 后台初始化 SQL +-- 用途:为 kt-template-admin 提供用户、角色、菜单、部门、字典表结构和基础数据。 +-- 说明:应用启动不会自动写入这些数据;请按目标环境手动导入本文件。 + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +CREATE TABLE IF NOT EXISTS `admin_menu` ( + `id` bigint NOT NULL, + `pid` bigint NOT NULL DEFAULT 0, + `name` varchar(120) NOT NULL, + `path` varchar(255) DEFAULT NULL, + `component` varchar(255) DEFAULT NULL, + `redirect` varchar(255) DEFAULT NULL, + `auth_code` varchar(120) DEFAULT NULL, + `type` varchar(32) NOT NULL DEFAULT 'menu', + `meta` longtext DEFAULT NULL, + `status` int NOT NULL DEFAULT 1, + `sort` int NOT NULL DEFAULT 0, + `is_deleted` tinyint(1) NOT NULL DEFAULT 0, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_admin_menu_name` (`name`), + KEY `idx_admin_menu_pid` (`pid`), + KEY `idx_admin_menu_path` (`path`), + KEY `idx_admin_menu_auth_code` (`auth_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `admin_role` ( + `id` bigint NOT NULL, + `role_code` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `remark` varchar(255) NOT NULL DEFAULT '', + `status` int NOT NULL DEFAULT 1, + `is_deleted` tinyint(1) NOT NULL DEFAULT 0, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_admin_role_code` (`role_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `admin_user` ( + `id` bigint NOT NULL, + `username` varchar(255) NOT NULL, + `password` varchar(255) NOT NULL, + `real_name` varchar(255) NOT NULL, + `home_path` varchar(255) NOT NULL DEFAULT '', + `timezone` varchar(255) NOT NULL DEFAULT 'Asia/Shanghai', + `status` int NOT NULL DEFAULT 1, + `is_deleted` tinyint(1) NOT NULL DEFAULT 0, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_admin_user_username` (`username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `admin_dept` ( + `id` bigint NOT NULL, + `pid` bigint NOT NULL DEFAULT 0, + `name` varchar(255) NOT NULL, + `status` int NOT NULL DEFAULT 1, + `remark` varchar(255) NOT NULL DEFAULT '', + `is_deleted` tinyint(1) NOT NULL DEFAULT 0, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + KEY `idx_admin_dept_pid` (`pid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `admin_dict` ( + `id` bigint NOT NULL, + `dict_code` varchar(255) NOT NULL, + `label` varchar(255) NOT NULL, + `value` varchar(255) NOT NULL, + `children_code` varchar(255) DEFAULT NULL, + `sort` int NOT NULL DEFAULT 0, + `status` int NOT NULL DEFAULT 1, + `is_deleted` tinyint(1) NOT NULL DEFAULT 0, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_admin_dict_code_value` (`dict_code`, `value`), + KEY `idx_admin_dict_code` (`dict_code`), + KEY `idx_admin_dict_children_code` (`children_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `admin_component` ( + `id` bigint NOT NULL, + `name` varchar(255) NOT NULL DEFAULT '', + `type` int NOT NULL, + `component_type` int NOT NULL, + `image` mediumtext NOT NULL, + `template` mediumtext NOT NULL, + `is_deleted` tinyint(1) NOT NULL DEFAULT 0, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + KEY `idx_admin_component_type` (`type`), + KEY `idx_admin_component_component_type` (`component_type`), + KEY `idx_admin_component_deleted` (`is_deleted`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `admin_user_role` ( + `user_id` bigint NOT NULL, + `role_id` bigint NOT NULL, + PRIMARY KEY (`user_id`, `role_id`), + KEY `idx_admin_user_role_role_id` (`role_id`), + CONSTRAINT `fk_admin_user_role_user` FOREIGN KEY (`user_id`) REFERENCES `admin_user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_admin_user_role_role` FOREIGN KEY (`role_id`) REFERENCES `admin_role` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `admin_role_menu` ( + `role_id` bigint NOT NULL, + `menu_id` bigint NOT NULL, + PRIMARY KEY (`role_id`, `menu_id`), + KEY `idx_admin_role_menu_menu_id` (`menu_id`), + CONSTRAINT `fk_admin_role_menu_role` FOREIGN KEY (`role_id`) REFERENCES `admin_role` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_admin_role_menu_menu` FOREIGN KEY (`menu_id`) REFERENCES `admin_menu` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO `admin_menu` (`id`, `pid`, `name`, `path`, `component`, `redirect`, `auth_code`, `type`, `meta`, `status`, `sort`) +VALUES + (2041700000000100001, 0, 'Dashboard', '/dashboard', NULL, '/analytics', NULL, 'catalog', '{"order":-1,"title":"page.dashboard.title"}', 1, 0), + (2041700000000100101, 2041700000000100001, 'Analytics', '/analytics', '/dashboard/analytics/index', NULL, NULL, 'menu', '{"affixTab":true,"title":"page.dashboard.analytics"}', 1, 0), + (2041700000000100102, 2041700000000100001, 'Workspace', '/workspace', '/dashboard/workspace/index', NULL, NULL, 'menu', '{"icon":"carbon:workspace","title":"page.dashboard.workspace"}', 1, 0), + (2041700000000100002, 0, 'System', '/system', NULL, NULL, NULL, 'catalog', '{"badge":"new","badgeType":"normal","badgeVariants":"primary","icon":"carbon:settings","order":9997,"title":"system.title"}', 1, 9997), + (2041700000000100200, 2041700000000100002, 'SystemRole', '/system/role', '/system/role/list', NULL, 'System:Role:List', 'menu', '{"icon":"carbon:user-role","title":"system.role.title"}', 1, 0), + (2041700000000120001, 2041700000000100200, 'SystemRoleCreate', NULL, NULL, NULL, 'System:Role:Create', 'button', '{"title":"common.create"}', 1, 0), + (2041700000000120002, 2041700000000100200, 'SystemRoleEdit', NULL, NULL, NULL, 'System:Role:Edit', 'button', '{"title":"common.edit"}', 1, 0), + (2041700000000120003, 2041700000000100200, 'SystemRoleDelete', NULL, NULL, NULL, 'System:Role:Delete', 'button', '{"title":"common.delete"}', 1, 0), + (2041700000000100201, 2041700000000100002, 'SystemMenu', '/system/menu', '/system/menu/list', NULL, 'System:Menu:List', 'menu', '{"icon":"carbon:menu","title":"system.menu.title"}', 1, 0), + (2041700000000120101, 2041700000000100201, 'SystemMenuCreate', NULL, NULL, NULL, 'System:Menu:Create', 'button', '{"title":"common.create"}', 1, 0), + (2041700000000120102, 2041700000000100201, 'SystemMenuEdit', NULL, NULL, NULL, 'System:Menu:Edit', 'button', '{"title":"common.edit"}', 1, 0), + (2041700000000120103, 2041700000000100201, 'SystemMenuDelete', NULL, NULL, NULL, 'System:Menu:Delete', 'button', '{"title":"common.delete"}', 1, 0), + (2041700000000100202, 2041700000000100002, 'SystemDept', '/system/dept', '/system/dept/list', NULL, 'System:Dept:List', 'menu', '{"icon":"carbon:container-services","title":"system.dept.title"}', 1, 0), + (2041700000000120201, 2041700000000100202, 'SystemDeptCreate', NULL, NULL, NULL, 'System:Dept:Create', 'button', '{"title":"common.create"}', 1, 0), + (2041700000000120202, 2041700000000100202, 'SystemDeptEdit', NULL, NULL, NULL, 'System:Dept:Edit', 'button', '{"title":"common.edit"}', 1, 0), + (2041700000000120203, 2041700000000100202, 'SystemDeptDelete', NULL, NULL, NULL, 'System:Dept:Delete', 'button', '{"title":"common.delete"}', 1, 0), + (2041700000000100009, 0, 'Project', '/vben-admin', NULL, NULL, NULL, 'catalog', '{"badgeType":"dot","icon":"carbon:data-center","order":9998,"title":"demos.vben.title"}', 1, 9998), + (2041700000000100901, 2041700000000100009, 'VbenDocument', '/vben-admin/document', 'IFrameView', NULL, NULL, 'embedded', '{"icon":"carbon:book","iframeSrc":"https://doc.vben.pro","title":"demos.vben.document"}', 1, 0), + (2041700000000100902, 2041700000000100009, 'VbenGithub', '/vben-admin/github', 'IFrameView', NULL, NULL, 'link', '{"icon":"carbon:logo-github","link":"https://github.com/vbenjs/vue-vben-admin","title":"Github"}', 1, 0), + (2041700000000100010, 0, 'About', '/about', '_core/about/index', NULL, NULL, 'menu', '{"icon":"lucide:copyright","order":9999,"title":"demos.vben.about"}', 1, 9999) +ON DUPLICATE KEY UPDATE + `pid` = VALUES(`pid`), + `path` = VALUES(`path`), + `component` = VALUES(`component`), + `redirect` = VALUES(`redirect`), + `auth_code` = VALUES(`auth_code`), + `type` = VALUES(`type`), + `meta` = VALUES(`meta`), + `status` = VALUES(`status`), + `sort` = VALUES(`sort`), + `is_deleted` = 0; + +INSERT INTO `admin_role` (`id`, `role_code`, `name`, `remark`, `status`) +VALUES + (2041700000000010001, 'super', '超级管理员', '拥有所有后台权限', 1), + (2041700000000010002, 'admin', '管理员', '拥有系统管理与工作台权限', 1), + (2041700000000010003, 'user', '普通用户', '仅拥有基础查看权限', 1) +ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), + `remark` = VALUES(`remark`), + `status` = VALUES(`status`), + `is_deleted` = 0; + +INSERT INTO `admin_user` (`id`, `username`, `password`, `real_name`, `home_path`, `timezone`, `status`) +VALUES + (2041700000000000001, 'vben', '123456', 'Vben', '/workspace', 'Asia/Shanghai', 1), + (2041700000000000002, 'admin', '123456', 'Admin', '/workspace', 'Asia/Shanghai', 1), + (2041700000000000003, 'jack', '123456', 'Jack', '/analytics', 'Asia/Shanghai', 1) +ON DUPLICATE KEY UPDATE + `password` = VALUES(`password`), + `real_name` = VALUES(`real_name`), + `home_path` = VALUES(`home_path`), + `timezone` = VALUES(`timezone`), + `status` = VALUES(`status`), + `is_deleted` = 0; + +INSERT INTO `admin_dept` (`id`, `pid`, `name`, `status`, `remark`) +VALUES + (2041700000000200001, 0, 'KT 总部', 1, '根部门'), + (2041700000000200002, 2041700000000200001, '研发中心', 1, '产品研发与平台建设'), + (2041700000000200003, 2041700000000200001, '运营中心', 1, '模板运营与内容管理') +ON DUPLICATE KEY UPDATE + `pid` = VALUES(`pid`), + `name` = VALUES(`name`), + `status` = VALUES(`status`), + `remark` = VALUES(`remark`), + `is_deleted` = 0; + +INSERT INTO `admin_dict` (`id`, `dict_code`, `label`, `value`, `children_code`, `sort`, `status`) +VALUES + (2041700000000300001, 'COMPONENT_TYPE', '图表', '1', 'CHART', 1, 1), + (2041700000000300002, 'COMPONENT_TYPE', '组件', '2', 'COMPONENT', 2, 1), + (2041700000000300101, 'CHART', '未分类', '-1', NULL, 0, 1), + (2041700000000300102, 'CHART', '折线图', '1', NULL, 1, 1), + (2041700000000300103, 'CHART', '柱状图', '2', NULL, 2, 1), + (2041700000000300104, 'CHART', '饼图', '3', NULL, 3, 1), + (2041700000000300201, 'COMPONENT', '未分类', '-1', NULL, 0, 1), + (2041700000000300202, 'COMPONENT', '基础组件', '1', NULL, 1, 1), + (2041700000000300203, 'COMPONENT', '业务组件', '2', NULL, 2, 1) +ON DUPLICATE KEY UPDATE + `label` = VALUES(`label`), + `children_code` = VALUES(`children_code`), + `sort` = VALUES(`sort`), + `status` = VALUES(`status`), + `is_deleted` = 0; + +DELETE FROM `admin_user_role` +WHERE `user_id` IN ( + 2041700000000000001, + 2041700000000000002, + 2041700000000000003 +); + +INSERT INTO `admin_user_role` (`user_id`, `role_id`) +VALUES + (2041700000000000001, 2041700000000010001), + (2041700000000000002, 2041700000000010002), + (2041700000000000003, 2041700000000010003); + +DELETE FROM `admin_role_menu` +WHERE `role_id` IN ( + 2041700000000010001, + 2041700000000010002, + 2041700000000010003 +); + +INSERT INTO `admin_role_menu` (`role_id`, `menu_id`) +SELECT 2041700000000010001, `id` +FROM `admin_menu` +WHERE `is_deleted` = 0; + +INSERT INTO `admin_role_menu` (`role_id`, `menu_id`) +SELECT 2041700000000010002, `id` +FROM `admin_menu` +WHERE `is_deleted` = 0; + +INSERT INTO `admin_role_menu` (`role_id`, `menu_id`) +VALUES + (2041700000000010003, 2041700000000100001), + (2041700000000010003, 2041700000000100101), + (2041700000000010003, 2041700000000100102), + (2041700000000010003, 2041700000000100009), + (2041700000000010003, 2041700000000100901), + (2041700000000010003, 2041700000000100902), + (2041700000000010003, 2041700000000100010); + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/src/admin/admin.module.ts b/src/admin/admin.module.ts new file mode 100644 index 0000000..9c2fffa --- /dev/null +++ b/src/admin/admin.module.ts @@ -0,0 +1,64 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AdminAuthController } from './auth/admin-auth.controller'; +import { AdminAuthService } from './auth/admin-auth.service'; +import { JwtAuthGuard } from './auth/jwt-auth.guard'; +import { AdminTokenService } from './auth/admin-token.service'; +import { ComponentController } from './component/component.controller'; +import { Component } from './component/component.entity'; +import { ComponentService } from './component/component.service'; +import { AdminDeptController } from './dept/admin-dept.controller'; +import { AdminDept } from './dept/admin-dept.entity'; +import { AdminDeptService } from './dept/admin-dept.service'; +import { DictModule } from './dict/dict.module'; +import { AdminExampleController } from './example/admin-example.controller'; +import { AdminMenuController } from './menu/admin-menu.controller'; +import { AdminMenu } from './menu/admin-menu.entity'; +import { AdminMenuService } from './menu/admin-menu.service'; +import { AdminRoleController } from './role/admin-role.controller'; +import { AdminRole } from './role/admin-role.entity'; +import { AdminRoleService } from './role/admin-role.service'; +import { AdminTimezoneController } from './timezone/admin-timezone.controller'; +import { AdminTimezoneService } from './timezone/admin-timezone.service'; +import { AdminUserController } from './user/admin-user.controller'; +import { AdminUser } from './user/admin-user.entity'; +import { AdminUserService } from './user/admin-user.service'; +import { ToolsService } from '@/common'; +import { MinioClientModule } from '@/minio/minio.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + AdminUser, + AdminRole, + AdminMenu, + AdminDept, + Component, + ]), + DictModule, + MinioClientModule, + ], + controllers: [ + AdminAuthController, + AdminUserController, + AdminMenuController, + AdminRoleController, + AdminDeptController, + ComponentController, + AdminTimezoneController, + AdminExampleController, + ], + providers: [ + AdminAuthService, + ComponentService, + AdminDeptService, + AdminMenuService, + AdminRoleService, + AdminTimezoneService, + AdminTokenService, + AdminUserService, + JwtAuthGuard, + ToolsService, + ], +}) +export class AdminModule {} diff --git a/src/admin/auth/admin-auth.controller.ts b/src/admin/auth/admin-auth.controller.ts new file mode 100644 index 0000000..cf373e6 --- /dev/null +++ b/src/admin/auth/admin-auth.controller.ts @@ -0,0 +1,72 @@ +import { + Body, + Controller, + Get, + Post, + Req, + Res, + UseGuards, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import type { Request, Response } from 'express'; +import { CurrentAdminUser, Public, vbenSuccess } from '@/common'; +import { AdminMenuService } from '../menu/admin-menu.service'; +import { AdminUser } from '../user/admin-user.entity'; +import { AdminUserService } from '../user/admin-user.service'; +import { AdminAuthService } from './admin-auth.service'; +import { JwtAuthGuard } from './jwt-auth.guard'; + +@ApiTags('admin-auth') +@Controller() +@UseGuards(JwtAuthGuard) +export class AdminAuthController { + constructor( + private readonly authService: AdminAuthService, + private readonly menuService: AdminMenuService, + private readonly userService: AdminUserService, + ) {} + + @Post('auth/login') + @Public() + async login( + @Body() body: { password?: string; username?: string }, + @Res({ passthrough: true }) res: Response, + ) { + const { accessToken, refreshToken, user } = await this.authService.login( + body.username, + body.password, + ); + this.authService.setAccessTokenCookie(res, accessToken); + this.authService.setRefreshTokenCookie(res, refreshToken); + return vbenSuccess({ + ...this.userService.serializeUser(user), + accessToken, + }); + } + + @Post('auth/refresh') + @Public() + async refresh( + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + ) { + const refreshToken = this.authService.getRefreshTokenFromRequest(req); + const refreshed = await this.authService.refresh(refreshToken); + this.authService.setAccessTokenCookie(res, refreshed.accessToken); + this.authService.setRefreshTokenCookie(res, refreshed.refreshToken); + return refreshed.accessToken; + } + + @Post('auth/logout') + @Public() + logout(@Res({ passthrough: true }) res: Response) { + this.authService.clearAccessTokenCookie(res); + this.authService.clearRefreshTokenCookie(res); + return vbenSuccess(''); + } + + @Get('auth/codes') + async getAccessCodes(@CurrentAdminUser() user: AdminUser) { + return vbenSuccess(await this.menuService.getAccessCodes(user)); + } +} diff --git a/src/admin/auth/admin-auth.service.ts b/src/admin/auth/admin-auth.service.ts new file mode 100644 index 0000000..a35c734 --- /dev/null +++ b/src/admin/auth/admin-auth.service.ts @@ -0,0 +1,156 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import type { Request, Response } from 'express'; +import { Repository } from 'typeorm'; +import { throwVbenError } from '@/common'; +import { AdminUser } from '../user/admin-user.entity'; +import { AdminTokenService } from './admin-token.service'; + +const ACCESS_TOKEN_COOKIE = 'admin_access_token'; +const REFRESH_TOKEN_COOKIE = 'jwt'; + +@Injectable() +export class AdminAuthService { + constructor( + @InjectRepository(AdminUser) + private readonly userRepository: Repository, + private readonly tokenService: AdminTokenService, + ) {} + + async login(username?: string, password?: string) { + if (!username || !password) { + throwVbenError( + 'Username and password are required', + HttpStatus.BAD_REQUEST, + 'BadRequestException', + ); + } + + const user = await this.findUserByUsername(username); + if (!user || user.password !== password) { + throwVbenError( + 'Username or password is incorrect.', + HttpStatus.FORBIDDEN, + ); + } + + return { + accessToken: this.tokenService.signAccessToken(user), + refreshToken: this.tokenService.signRefreshToken(user), + user, + }; + } + + async refresh(refreshToken?: string) { + if (!refreshToken) { + throwVbenError('Forbidden Exception', HttpStatus.FORBIDDEN); + } + + const payload = this.tokenService.verifyRefreshToken(refreshToken); + if (!payload) throwVbenError('Forbidden Exception', HttpStatus.FORBIDDEN); + + const user = await this.findUserByUsername(payload.username); + if (!user) throwVbenError('Forbidden Exception', HttpStatus.FORBIDDEN); + + return { + accessToken: this.tokenService.signAccessToken(user), + refreshToken: this.tokenService.signRefreshToken(user), + }; + } + + async currentUser(authHeader?: string, req?: Request) { + const tokens = [ + this.readBearerToken(authHeader), + this.readCookie(req, ACCESS_TOKEN_COOKIE), + ].filter((token): token is string => !!token); + const payload = tokens + .map((token) => this.tokenService.verifyAccessToken(token)) + .find(Boolean); + if (!payload) { + throwVbenError('Unauthorized Exception', HttpStatus.UNAUTHORIZED); + } + + const user = await this.userRepository.findOne({ + relations: ['roles', 'roles.menus'], + where: { + id: payload.sub, + isDeleted: false, + status: 1, + }, + }); + if (!user) throwVbenError('Unauthorized Exception', HttpStatus.UNAUTHORIZED); + return user; + } + + getRefreshTokenFromRequest(req: Request) { + return this.readCookie(req, REFRESH_TOKEN_COOKIE); + } + + setAccessTokenCookie(res: Response, token: string) { + res.cookie(ACCESS_TOKEN_COOKIE, token, { + ...this.getTokenCookieOptions(), + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + } + + setRefreshTokenCookie(res: Response, token: string) { + res.cookie(REFRESH_TOKEN_COOKIE, token, { + ...this.getTokenCookieOptions(), + maxAge: 30 * 24 * 60 * 60 * 1000, + }); + } + + clearRefreshTokenCookie(res: Response) { + this.clearTokenCookie(res, REFRESH_TOKEN_COOKIE); + } + + clearAccessTokenCookie(res: Response) { + this.clearTokenCookie(res, ACCESS_TOKEN_COOKIE); + } + + private async findUserByUsername(username: string) { + return this.userRepository.findOne({ + relations: ['roles', 'roles.menus'], + where: { + isDeleted: false, + status: 1, + username, + }, + }); + } + + private readBearerToken(authHeader?: string) { + if (!authHeader?.startsWith('Bearer ')) return null; + return authHeader.split(' ')[1]; + } + + private readCookie(req: Request | undefined, cookieName: string) { + const cookie = req?.headers.cookie || ''; + const cookies = cookie + .split(';') + .reduce>((acc, item) => { + const [key, ...value] = item.trim().split('='); + if (key) acc[key] = decodeURIComponent(value.join('=')); + return acc; + }, {}); + return cookies[cookieName]; + } + + private getTokenCookieOptions() { + const secure = process.env.ADMIN_COOKIE_SECURE === 'true'; + return { + httpOnly: true, + path: '/', + sameSite: secure ? ('none' as const) : ('lax' as const), + secure, + }; + } + + private clearTokenCookie(res: Response, cookieName: string) { + const options = this.getTokenCookieOptions(); + res.clearCookie(cookieName, options); + // 兼容旧版本未显式指定 path 时由浏览器按接口路径生成的 cookie。 + res.clearCookie(cookieName, { ...options, path: '/api/auth' }); + res.clearCookie(cookieName, { ...options, path: '/auth' }); + } +} diff --git a/src/admin/auth/admin-token.service.ts b/src/admin/auth/admin-token.service.ts new file mode 100644 index 0000000..efca004 --- /dev/null +++ b/src/admin/auth/admin-token.service.ts @@ -0,0 +1,90 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createHmac, timingSafeEqual } from 'crypto'; + +export type AdminTokenPayload = { + exp: number; + iat: number; + sub: string; + type: 'access' | 'refresh'; + username: string; +}; + +@Injectable() +export class AdminTokenService { + constructor(private readonly configService: ConfigService) {} + + signAccessToken(user: { id: string; username: string }) { + return this.sign(user, 'access', 7 * 24 * 60 * 60); + } + + signRefreshToken(user: { id: string; username: string }) { + return this.sign(user, 'refresh', 30 * 24 * 60 * 60); + } + + verifyAccessToken(token: string): AdminTokenPayload | null { + return this.verify(token, 'access'); + } + + verifyRefreshToken(token: string): AdminTokenPayload | null { + return this.verify(token, 'refresh'); + } + + private sign( + user: { id: string; username: string }, + type: AdminTokenPayload['type'], + ttlSeconds: number, + ) { + const now = Math.floor(Date.now() / 1000); + const payload: AdminTokenPayload = { + exp: now + ttlSeconds, + iat: now, + sub: user.id, + type, + username: user.username, + }; + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString( + 'base64url', + ); + const signature = this.signPayload(encodedPayload); + return `${encodedPayload}.${signature}`; + } + + private verify( + token: string, + type: AdminTokenPayload['type'], + ): AdminTokenPayload | null { + const [encodedPayload, signature] = token.split('.'); + if (!encodedPayload || !signature) return null; + + const expected = this.signPayload(encodedPayload); + if (!this.safeEqual(signature, expected)) return null; + + try { + const payload = JSON.parse( + Buffer.from(encodedPayload, 'base64url').toString('utf8'), + ) as AdminTokenPayload; + const now = Math.floor(Date.now() / 1000); + if (payload.type !== type || payload.exp <= now) return null; + return payload; + } catch { + return null; + } + } + + private signPayload(payload: string) { + const secret = + this.configService.get('ADMIN_TOKEN_SECRET') || + 'kt-template-online-admin-token-secret'; + return createHmac('sha256', secret).update(payload).digest('base64url'); + } + + private safeEqual(left: string, right: string) { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + return ( + leftBuffer.length === rightBuffer.length && + timingSafeEqual(leftBuffer, rightBuffer) + ); + } +} diff --git a/src/admin/auth/jwt-auth.guard.ts b/src/admin/auth/jwt-auth.guard.ts new file mode 100644 index 0000000..2507891 --- /dev/null +++ b/src/admin/auth/jwt-auth.guard.ts @@ -0,0 +1,38 @@ +import { + CanActivate, + ExecutionContext, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import type { Request } from 'express'; +import { AdminUser } from '../user/admin-user.entity'; +import { AdminAuthService } from './admin-auth.service'; +import { IS_PUBLIC_KEY } from '@/common'; + +type AdminRequest = Request & { + adminUser?: AdminUser; +}; + +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor( + private readonly authService: AdminAuthService, + private readonly reflector: Reflector, + ) {} + + async canActivate(context: ExecutionContext) { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + + const request = context.switchToHttp().getRequest(); + const authorization = request.headers.authorization; + request.adminUser = await this.authService.currentUser( + Array.isArray(authorization) ? authorization[0] : authorization, + request, + ); + return true; + } +} diff --git a/src/component/component.controller.ts b/src/admin/component/component.controller.ts similarity index 96% rename from src/component/component.controller.ts rename to src/admin/component/component.controller.ts index cc666a7..7e17682 100644 --- a/src/component/component.controller.ts +++ b/src/admin/component/component.controller.ts @@ -1,12 +1,13 @@ import { + Body, Controller, Get, - Post, - Res, - Query, - Body, - HttpStatus, HttpCode, + HttpStatus, + Post, + Query, + Res, + UseGuards, } from '@nestjs/common'; import { ApiExtraModels, @@ -24,11 +25,12 @@ import { ApiSuccessResponse, ToolsService, } from '@/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { ComponentService } from './component.service'; import { Component } from './component.entity'; const componentExample = { - id: '1d8d3dd2-99f0-4d10-9a44-0cf9566b37c9', + id: '2041739550026043392', name: '基础折线图', type: 1, componentType: 1, @@ -60,6 +62,7 @@ class CompPageDto @Controller('component') @ApiTags('component') @ApiExtraModels(PaginatedDto) +@UseGuards(JwtAuthGuard) export class ComponentController { constructor( private readonly toolsService: ToolsService, @@ -99,7 +102,7 @@ export class ComponentController { type: 'string', description: '新增组件ID', }, - example: '1d8d3dd2-99f0-4d10-9a44-0cf9566b37c9', + example: '2041739550026043392', }) async save(@Res() res, @Body() component: Component) { const save = await this.componentService.save(component); diff --git a/src/component/component.entity.ts b/src/admin/component/component.entity.ts similarity index 84% rename from src/component/component.entity.ts rename to src/admin/component/component.entity.ts index df1ce9e..948aeaa 100644 --- a/src/component/component.entity.ts +++ b/src/admin/component/component.entity.ts @@ -1,18 +1,21 @@ import { AfterLoad, + BeforeInsert, Entity, - PrimaryGeneratedColumn, + PrimaryColumn, Column, CreateDateColumn, UpdateDateColumn, } from 'typeorm'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { DecodeDictKey, decodeDictKeys } from '@/common'; +import { DecodeDictKey, decodeDictKeys, ensureSnowflakeId } from '@/common'; -@Entity() +@Entity('admin_component') export class Component { @ApiPropertyOptional() - @PrimaryGeneratedColumn('uuid') + @PrimaryColumn({ + type: 'bigint', + }) id: string; @ApiProperty() @@ -81,4 +84,9 @@ export class Component { // 查询结果初始化完成后再翻译,避免构造/赋值阶段覆盖派生字段。 decodeDictKeys(this); } + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } } diff --git a/src/component/component.service.ts b/src/admin/component/component.service.ts similarity index 96% rename from src/component/component.service.ts rename to src/admin/component/component.service.ts index fed3fb8..1948855 100644 --- a/src/component/component.service.ts +++ b/src/admin/component/component.service.ts @@ -4,7 +4,7 @@ import { Repository } from 'typeorm'; import { Component } from './component.entity'; import { ToolsService } from '@/common'; import { isNumber, omit, pick } from 'lodash'; -import { DictService } from '@/dict/dict.service'; +import { DictService } from '@/admin/dict/dict.service'; @Injectable() export class ComponentService { @@ -105,7 +105,7 @@ export class ComponentService { return link.affected > 0; } - async find(id: number): Promise { + async find(id: string): Promise { await this.dictService.refreshDecodeCache(); const component = await this.userRepository diff --git a/src/admin/dept/admin-dept.controller.ts b/src/admin/dept/admin-dept.controller.ts new file mode 100644 index 0000000..3fe3593 --- /dev/null +++ b/src/admin/dept/admin-dept.controller.ts @@ -0,0 +1,45 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + UseGuards, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { vbenSuccess } from '@/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { AdminDept } from './admin-dept.entity'; +import { AdminDeptService } from './admin-dept.service'; + +@ApiTags('admin-dept') +@Controller('system/dept') +@UseGuards(JwtAuthGuard) +export class AdminDeptController { + constructor(private readonly deptService: AdminDeptService) {} + + @Get('list') + async list() { + return vbenSuccess(await this.deptService.getDeptList()); + } + + @Post() + async create(@Body() body: Partial) { + return vbenSuccess(await this.deptService.createDept(body)); + } + + @Put(':id') + async update( + @Param('id') id: string, + @Body() body: Partial, + ) { + return vbenSuccess(await this.deptService.updateDept(id, body)); + } + + @Delete(':id') + async remove(@Param('id') id: string) { + return vbenSuccess(await this.deptService.deleteDept(id)); + } +} diff --git a/src/admin/dept/admin-dept.entity.ts b/src/admin/dept/admin-dept.entity.ts new file mode 100644 index 0000000..a366a54 --- /dev/null +++ b/src/admin/dept/admin-dept.entity.ts @@ -0,0 +1,57 @@ +import { + BeforeInsert, + Column, + CreateDateColumn, + Entity, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ensureSnowflakeId } from '@/common'; + +@Entity('admin_dept') +export class AdminDept { + @PrimaryColumn({ + type: 'bigint', + }) + id: string; + + @Column({ + default: 0, + type: 'bigint', + }) + pid: string; + + @Column() + name: string; + + @Column({ + default: 1, + }) + status: number; + + @Column({ + default: '', + }) + remark: string; + + @Column({ + default: false, + name: 'is_deleted', + }) + isDeleted: boolean; + + @CreateDateColumn({ + name: 'create_time', + }) + createTime: Date; + + @UpdateDateColumn({ + name: 'update_time', + }) + updateTime: Date; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/admin/dept/admin-dept.service.ts b/src/admin/dept/admin-dept.service.ts new file mode 100644 index 0000000..65d3ab8 --- /dev/null +++ b/src/admin/dept/admin-dept.service.ts @@ -0,0 +1,81 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { throwVbenError, toTree } from '@/common'; +import { AdminDept } from './admin-dept.entity'; + +@Injectable() +export class AdminDeptService { + constructor( + @InjectRepository(AdminDept) + private readonly deptRepository: Repository, + ) {} + + async getDeptList() { + const depts = await this.deptRepository.find({ + where: { + isDeleted: false, + }, + order: { + createTime: 'ASC', + }, + }); + return this.buildDeptTree(depts); + } + + async createDept(data: Partial) { + const entity = this.deptRepository.create({ + name: data.name, + pid: data.pid || '0', + remark: data.remark || '', + status: data.status ?? 1, + }); + await this.deptRepository.save(entity); + return null; + } + + async updateDept(id: string, data: Partial) { + await this.deptRepository.update( + { id }, + { + name: data.name, + pid: data.pid || '0', + remark: data.remark || '', + status: data.status ?? 1, + }, + ); + return null; + } + + async deleteDept(id: string) { + const hasChildren = await this.deptRepository.exist({ + where: { + isDeleted: false, + pid: id, + }, + }); + if (hasChildren) { + throwVbenError('请先删除子部门', HttpStatus.BAD_REQUEST); + } + + await this.deptRepository.update( + { id }, + { + isDeleted: true, + }, + ); + return null; + } + + private buildDeptTree(depts: AdminDept[]) { + const nodes = depts.map((dept) => ({ + createTime: dept.createTime, + id: dept.id, + name: dept.name, + pid: dept.pid || '0', + remark: dept.remark, + status: dept.status, + })); + return toTree(nodes); + } +} diff --git a/src/dict/dict.entity.ts b/src/admin/dict/admin-dict.entity.ts similarity index 58% rename from src/dict/dict.entity.ts rename to src/admin/dict/admin-dict.entity.ts index 8871ea1..97f4f37 100644 --- a/src/dict/dict.entity.ts +++ b/src/admin/dict/admin-dict.entity.ts @@ -1,28 +1,32 @@ import { + BeforeInsert, Column, CreateDateColumn, Entity, - PrimaryGeneratedColumn, + PrimaryColumn, UpdateDateColumn, } from 'typeorm'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ensureSnowflakeId } from '@/common'; -@Entity('dict') -export class DictEntity { +@Entity('admin_dict') +export class AdminDict { @ApiPropertyOptional() - @PrimaryGeneratedColumn('uuid') + @PrimaryColumn({ + type: 'bigint', + }) id: string; @ApiProperty({ - example: 'CHART', + example: 'COMPONENT_TYPE', }) @Column({ - name: 'dict_key', + name: 'dict_code', }) - dictKey: string; + dictCode: string; @ApiProperty({ - example: '折线图', + example: '图表', }) @Column() label: string; @@ -37,10 +41,10 @@ export class DictEntity { example: 'CHART', }) @Column({ - name: 'children_key', + name: 'children_code', nullable: true, }) - childrenKey: string; + childrenCode: string; @ApiPropertyOptional({ example: 1, @@ -51,9 +55,15 @@ export class DictEntity { sort: number; @Column({ - default: 0, + default: 1, }) - is_deleted: boolean; + status: number; + + @Column({ + default: false, + name: 'is_deleted', + }) + isDeleted: boolean; @CreateDateColumn({ name: 'create_time', @@ -64,4 +74,9 @@ export class DictEntity { name: 'update_time', }) updateTime: Date; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } } diff --git a/src/dict/dict.controller.ts b/src/admin/dict/dict.controller.ts similarity index 100% rename from src/dict/dict.controller.ts rename to src/admin/dict/dict.controller.ts diff --git a/src/dict/dict.dto.ts b/src/admin/dict/dict.dto.ts similarity index 66% rename from src/dict/dict.dto.ts rename to src/admin/dict/dict.dto.ts index f5a6b39..5fbff4f 100644 --- a/src/dict/dict.dto.ts +++ b/src/admin/dict/dict.dto.ts @@ -1,6 +1,12 @@ import { ApiProperty } from '@nestjs/swagger'; export class DictDto { + @ApiProperty({ + example: 'COMPONENT_TYPE', + required: false, + }) + dictCode?: string; + @ApiProperty({ example: '图表', }) diff --git a/src/dict/dict.module.ts b/src/admin/dict/dict.module.ts similarity index 78% rename from src/dict/dict.module.ts rename to src/admin/dict/dict.module.ts index 37f000e..5042d83 100644 --- a/src/dict/dict.module.ts +++ b/src/admin/dict/dict.module.ts @@ -3,10 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { DictController } from './dict.controller'; import { DictService } from './dict.service'; import { ToolsService } from '@/common'; -import { DictEntity } from './dict.entity'; +import { AdminDict } from './admin-dict.entity'; @Module({ - imports: [TypeOrmModule.forFeature([DictEntity])], + imports: [TypeOrmModule.forFeature([AdminDict])], controllers: [DictController], providers: [DictService, ToolsService], exports: [DictService], diff --git a/src/dict/dict.service.ts b/src/admin/dict/dict.service.ts similarity index 64% rename from src/dict/dict.service.ts rename to src/admin/dict/dict.service.ts index 34b1871..ce1c85e 100644 --- a/src/dict/dict.service.ts +++ b/src/admin/dict/dict.service.ts @@ -2,15 +2,15 @@ import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { setDictDecodeCache } from '@/common'; -import { DictEntity } from './dict.entity'; +import { AdminDict } from './admin-dict.entity'; const COMPONENT_TYPE_DICT_KEY = 'COMPONENT_TYPE'; @Injectable() export class DictService implements OnApplicationBootstrap { constructor( - @InjectRepository(DictEntity) - private readonly dictRepository: Repository, + @InjectRepository(AdminDict) + private readonly dictRepository: Repository, ) {} async onApplicationBootstrap() { @@ -20,8 +20,9 @@ export class DictService implements OnApplicationBootstrap { async getDictByKey(dictKey: string): Promise { const list = await this.dictRepository.find({ where: { - dictKey, - is_deleted: false, + dictCode: dictKey, + isDeleted: false, + status: 1, }, order: { sort: 'ASC', @@ -36,25 +37,27 @@ export class DictService implements OnApplicationBootstrap { } async getComponentDictByType(type: number): Promise { - // 一级类型的 childrenKey 决定二级字典来源,避免在代码里维护 1 -> CHART 这类关系。 + // 一级类型的 childrenCode 决定二级字典来源,避免在代码里维护 1 -> CHART 这类关系。 const componentType = await this.dictRepository.findOne({ where: { - dictKey: COMPONENT_TYPE_DICT_KEY, + dictCode: COMPONENT_TYPE_DICT_KEY, + isDeleted: false, + status: 1, value: String(type), - is_deleted: false, }, }); - if (!componentType?.childrenKey) return []; + if (!componentType?.childrenCode) return []; - return this.getDictByKey(componentType.childrenKey); + return this.getDictByKey(componentType.childrenCode); } async refreshDecodeCache() { // AfterLoad 字典翻译必须同步完成,所以这里先把数据库字典刷新到进程缓存。 const list = await this.dictRepository.find({ where: { - is_deleted: false, + isDeleted: false, + status: 1, }, order: { sort: 'ASC', @@ -62,6 +65,12 @@ export class DictService implements OnApplicationBootstrap { }, }); - setDictDecodeCache(list); + setDictDecodeCache( + list.map(({ dictCode, label, value }) => ({ + dictKey: dictCode, + label, + value, + })), + ); } } diff --git a/src/admin/example/admin-example.controller.ts b/src/admin/example/admin-example.controller.ts new file mode 100644 index 0000000..9774369 --- /dev/null +++ b/src/admin/example/admin-example.controller.ts @@ -0,0 +1,158 @@ +import { + Controller, + Get, + Post, + Query, + Res, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiTags } from '@nestjs/swagger'; +import type { Response } from 'express'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { Public, vbenPage, vbenSuccess } from '@/common'; +import { MinioClientService } from '@/minio/minio.service'; +import type { MinioUploadFile } from '@/minio/minio.service'; + +type DemoTableRow = { + available: boolean; + category: string; + color: string; + currency: string; + description: string; + id: string; + imageUrl: string; + imageUrl2: string; + inProduction: boolean; + open: boolean; + price: string; + productName: string; + quantity: number; + rating: number; + releaseDate: string; + status: string; + tags: string[]; + weight: number; +}; + +const DEMO_ROWS: DemoTableRow[] = Array.from({ length: 100 }, (_, index) => { + const sequence = index + 1; + const categories = ['Dashboard', 'Form', 'Table', 'Chart', 'Workflow']; + const colors = ['Blue', 'Green', 'Purple', 'Orange', 'Slate']; + const statuses = ['success', 'warning', 'error']; + + return { + available: sequence % 3 !== 0, + category: categories[index % categories.length], + color: colors[index % colors.length], + currency: 'CNY', + description: `真实 API 示例数据 ${sequence}`, + id: `demo-${String(sequence).padStart(3, '0')}`, + imageUrl: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp', + imageUrl2: + 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/avatar-v1.webp', + inProduction: sequence % 2 === 0, + open: sequence % 4 === 0, + price: `${(sequence * 3.6 + 19).toFixed(2)}`, + productName: `KT Admin 模板能力 ${sequence}`, + quantity: 10 + sequence, + rating: Number((3 + (sequence % 20) / 10).toFixed(1)), + releaseDate: new Date(2026, index % 12, (index % 28) + 1).toISOString(), + status: statuses[index % statuses.length], + tags: ['kt', 'admin', categories[index % categories.length].toLowerCase()], + weight: Number((1 + sequence / 10).toFixed(2)), + }; +}); + +@ApiTags('admin-example') +@Controller() +@UseGuards(JwtAuthGuard) +export class AdminExampleController { + constructor(private readonly minioClientService: MinioClientService) {} + + @Post('upload') + @UseInterceptors(FileInterceptor('file')) + async upload(@UploadedFile() file: MinioUploadFile) { + const result = await this.minioClientService.uploadObject({ file }); + + return vbenSuccess({ + url: result.url, + }); + } + + @Get('table/list') + async tableList( + @Query() query: Record, + ) { + const page = Math.max(Number(query.page || 1), 1); + const pageSize = Math.max(Number(query.pageSize || 10), 1); + const sorted = this.sortRows([...DEMO_ROWS], query.sortBy, query.sortOrder); + const items = sorted.slice((page - 1) * pageSize, page * pageSize); + + return vbenPage(items, sorted.length); + } + + @Get('status') + @Public() + status(@Query('status') status: string, @Res() res: Response) { + const code = Number(status) || 200; + res.status(code).send({ + code: -1, + data: null, + error: `${code}`, + message: `${code}`, + }); + } + + @Get('demo/bigint') + async bigint(@Res() res: Response) { + res.setHeader('Content-Type', 'application/json'); + res.send(`{ + "code": 0, + "message": "success", + "data": [ + { + "id": 123456789012345678901234567890123456789012345678901234567890, + "name": "John Doe", + "age": 30, + "email": "john-doe@demo.com" + }, + { + "id": 987654321098765432109876543210987654321098765432109876543210, + "name": "Jane Smith", + "age": 25, + "email": "jane@demo.com" + } + ] +}`); + } + + @Get('test') + @Public() + testGet() { + return 'Test get handler'; + } + + @Post('test') + @Public() + testPost() { + return 'Test post handler'; + } + + private sortRows(rows: DemoTableRow[], sortBy?: string, sortOrder?: string) { + if (!sortBy || !Object.hasOwn(rows[0], sortBy)) return rows; + + return rows.sort((prev, next) => { + const prevValue = prev[sortBy]; + const nextValue = next[sortBy]; + const result = String(prevValue).localeCompare(String(nextValue), 'zh-CN', { + numeric: true, + sensitivity: 'base', + }); + + return sortOrder === 'desc' ? -result : result; + }); + } +} diff --git a/src/admin/menu/admin-menu.controller.ts b/src/admin/menu/admin-menu.controller.ts new file mode 100644 index 0000000..2fd66b6 --- /dev/null +++ b/src/admin/menu/admin-menu.controller.ts @@ -0,0 +1,68 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { CurrentAdminUser, vbenSuccess } from '@/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { AdminUser } from '../user/admin-user.entity'; +import { AdminMenu } from './admin-menu.entity'; +import { AdminMenuService } from './admin-menu.service'; + +@ApiTags('admin-menu') +@Controller() +@UseGuards(JwtAuthGuard) +export class AdminMenuController { + constructor(private readonly menuService: AdminMenuService) {} + + @Get('menu/all') + async all(@CurrentAdminUser() user: AdminUser) { + return vbenSuccess(await this.menuService.getRouteMenus(user)); + } + + @Get('system/menu/list') + async list() { + return vbenSuccess(await this.menuService.getMenuList()); + } + + @Get('system/menu/name-exists') + async nameExists( + @Query('name') name: string, + @Query('id') id?: string, + ) { + return vbenSuccess(await this.menuService.isMenuNameExists(name, id)); + } + + @Get('system/menu/path-exists') + async pathExists( + @Query('path') path: string, + @Query('id') id?: string, + ) { + return vbenSuccess(await this.menuService.isMenuPathExists(path, id)); + } + + @Post('system/menu') + async create(@Body() body: Partial) { + return vbenSuccess(await this.menuService.createMenu(body)); + } + + @Put('system/menu/:id') + async update( + @Param('id') id: string, + @Body() body: Partial, + ) { + return vbenSuccess(await this.menuService.updateMenu(id, body)); + } + + @Delete('system/menu/:id') + async remove(@Param('id') id: string) { + return vbenSuccess(await this.menuService.deleteMenu(id)); + } +} diff --git a/src/admin/menu/admin-menu.entity.ts b/src/admin/menu/admin-menu.entity.ts new file mode 100644 index 0000000..360a6f2 --- /dev/null +++ b/src/admin/menu/admin-menu.entity.ts @@ -0,0 +1,106 @@ +import { + BeforeInsert, + Column, + CreateDateColumn, + Entity, + ManyToMany, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ensureSnowflakeId } from '@/common'; +import { AdminRole } from '../role/admin-role.entity'; + +export type AdminMenuType = 'button' | 'catalog' | 'embedded' | 'link' | 'menu'; + +export type AdminMenuMeta = Record; + +@Entity('admin_menu') +export class AdminMenu { + @PrimaryColumn({ + type: 'bigint', + }) + id: string; + + @Column({ + default: 0, + type: 'bigint', + }) + pid: string; + + @Column({ + length: 120, + unique: true, + }) + name: string; + + @Column({ + length: 255, + nullable: true, + }) + path: string; + + @Column({ + length: 255, + nullable: true, + }) + component: string; + + @Column({ + length: 255, + nullable: true, + }) + redirect: string; + + @Column({ + name: 'auth_code', + length: 120, + nullable: true, + }) + authCode: string; + + @Column({ + default: 'menu', + length: 32, + }) + type: AdminMenuType; + + @Column({ + nullable: true, + type: 'simple-json', + }) + meta: AdminMenuMeta; + + @Column({ + default: 1, + }) + status: number; + + @Column({ + default: 0, + }) + sort: number; + + @Column({ + default: false, + name: 'is_deleted', + }) + isDeleted: boolean; + + @CreateDateColumn({ + name: 'create_time', + }) + createTime: Date; + + @UpdateDateColumn({ + name: 'update_time', + }) + updateTime: Date; + + @ManyToMany(() => AdminRole, (role) => role.menus) + roles: AdminRole[]; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/admin/menu/admin-menu.service.ts b/src/admin/menu/admin-menu.service.ts new file mode 100644 index 0000000..9cd3993 --- /dev/null +++ b/src/admin/menu/admin-menu.service.ts @@ -0,0 +1,229 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { toTree } from '@/common'; +import { AdminUser } from '../user/admin-user.entity'; +import { AdminMenu, AdminMenuMeta } from './admin-menu.entity'; + +type MenuInput = Partial & { + activePath?: string; + linkSrc?: string; +}; + +@Injectable() +export class AdminMenuService { + constructor( + @InjectRepository(AdminMenu) + private readonly menuRepository: Repository, + ) {} + + async getAccessCodes(user: AdminUser) { + const menus = await this.getAllowedMenus(user); + return menus + .map((menu) => menu.authCode) + .filter((authCode) => !!authCode); + } + + async getRouteMenus(user: AdminUser) { + const menus = await this.getAllowedMenus(user); + return this.buildMenuTree(menus.filter((menu) => menu.type !== 'button')); + } + + async getMenuList() { + const menus = await this.menuRepository.find({ + where: { + isDeleted: false, + }, + }); + return this.buildMenuTree(menus); + } + + async isMenuNameExists(name: string, id?: string) { + const menu = await this.menuRepository.findOne({ + where: { + isDeleted: false, + name, + }, + }); + return !!menu && (!id || menu.id !== id); + } + + async isMenuPathExists(path: string, id?: string) { + if (path === '/') return !id; + + const menu = await this.menuRepository.findOne({ + where: { + isDeleted: false, + path, + }, + }); + return !!menu && (!id || menu.id !== id); + } + + async createMenu(data: MenuInput) { + const entity = this.menuRepository.create({ + ...this.normalizeMenuInput(data, true), + }); + await this.menuRepository.save(entity); + return null; + } + + async updateMenu(id: string, data: MenuInput) { + await this.menuRepository.update( + { id }, + { + ...this.normalizeMenuInput(data, false), + }, + ); + return null; + } + + async deleteMenu(id: string) { + const ids = await this.collectChildMenuIds(id); + await this.menuRepository.update( + { + id: In(ids), + }, + { + isDeleted: true, + }, + ); + return null; + } + + private async getAllowedMenus(user: AdminUser) { + const activeRoles = (user.roles || []).filter( + (role) => !role.isDeleted && role.status === 1, + ); + + if (activeRoles.some((role) => role.roleCode === 'super')) { + return this.menuRepository.find({ + where: { + isDeleted: false, + status: 1, + }, + }); + } + + const menuMap = new Map(); + activeRoles.forEach((role) => { + (role.menus || []) + .filter((menu) => !menu.isDeleted && menu.status === 1) + .forEach((menu) => menuMap.set(menu.id, menu)); + }); + return [...menuMap.values()]; + } + + private normalizeMenuInput( + data: MenuInput, + includeEmptyMeta: boolean, + ): Partial { + const meta = this.normalizeMetaInput(data); + const menu: Partial = { + authCode: data.authCode || null, + component: data.component || null, + name: data.name, + path: data.path || null, + pid: data.pid || '0', + redirect: data.redirect || null, + status: data.status ?? 1, + type: data.type || 'menu', + }; + if (includeEmptyMeta || Object.keys(meta).length > 0) { + menu.meta = meta; + } + return menu; + } + + private normalizeMetaInput(data: MenuInput): AdminMenuMeta { + const meta = this.normalizeMetaValue(data.meta); + + // 兼容表单库返回字面量 `meta.title` 的场景,避免更新菜单时把 meta 覆盖为空对象。 + Object.entries(data).forEach(([key, value]) => { + if (!key.startsWith('meta.')) return; + const metaKey = key.slice('meta.'.length); + if (metaKey) meta[metaKey] = value; + }); + + if (data.activePath) meta.activePath = data.activePath; + if (data.linkSrc && data.type === 'embedded') meta.iframeSrc = data.linkSrc; + if (data.linkSrc && data.type === 'link') meta.link = data.linkSrc; + + Object.keys(meta).forEach((key) => { + if (meta[key] === null || meta[key] === undefined || meta[key] === '') { + delete meta[key]; + } + }); + return meta; + } + + private normalizeMetaValue( + meta: AdminMenuMeta | null | string | undefined, + ): AdminMenuMeta { + if (!meta) return {}; + if (typeof meta !== 'string') return { ...meta }; + + try { + const parsed = JSON.parse(meta); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } + } + + private async collectChildMenuIds(id: string) { + const menus = await this.menuRepository.find({ + where: { + isDeleted: false, + }, + }); + const ids = new Set([id]); + let changed = true; + while (changed) { + changed = false; + menus.forEach((menu) => { + if (ids.has(menu.pid) && !ids.has(menu.id)) { + ids.add(menu.id); + changed = true; + } + }); + } + return [...ids]; + } + + private buildMenuTree(menus: AdminMenu[]) { + const nodes = menus + .map((menu) => this.serializeMenu(menu)) + .sort((prev, next) => { + const prevOrder = prev.meta?.order ?? prev.sort ?? 0; + const nextOrder = next.meta?.order ?? next.sort ?? 0; + return prevOrder - nextOrder; + }); + return toTree(nodes); + } + + private serializeMenu(menu: AdminMenu) { + const meta = this.normalizeMetaValue(menu.meta); + if (!meta.title) meta.title = menu.name; + const node = { + authCode: menu.authCode, + component: menu.component, + createTime: menu.createTime, + id: menu.id, + meta, + name: menu.name, + path: menu.path, + pid: menu.pid || '0', + redirect: menu.redirect, + status: menu.status, + type: menu.type, + } as any; + + Object.keys(node).forEach((key) => { + if (node[key] === null || node[key] === undefined || node[key] === '') { + delete node[key]; + } + }); + return node; + } +} diff --git a/src/admin/role/admin-role.controller.ts b/src/admin/role/admin-role.controller.ts new file mode 100644 index 0000000..4f887ee --- /dev/null +++ b/src/admin/role/admin-role.controller.ts @@ -0,0 +1,46 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { vbenPage, vbenSuccess } from '@/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { AdminRoleService } from './admin-role.service'; + +@ApiTags('admin-role') +@Controller('system/role') +@UseGuards(JwtAuthGuard) +export class AdminRoleController { + constructor(private readonly roleService: AdminRoleService) {} + + @Get('list') + async list(@Query() query: Record) { + const page = await this.roleService.getRoleList(query); + return vbenPage(page.items, page.total); + } + + @Post() + async create(@Body() body: Record) { + return vbenSuccess(await this.roleService.createRole(body)); + } + + @Put(':id') + async update( + @Param('id') id: string, + @Body() body: Record, + ) { + return vbenSuccess(await this.roleService.updateRole(id, body)); + } + + @Delete(':id') + async remove(@Param('id') id: string) { + return vbenSuccess(await this.roleService.deleteRole(id)); + } +} diff --git a/src/admin/role/admin-role.entity.ts b/src/admin/role/admin-role.entity.ts new file mode 100644 index 0000000..87ea023 --- /dev/null +++ b/src/admin/role/admin-role.entity.ts @@ -0,0 +1,78 @@ +import { + BeforeInsert, + Column, + CreateDateColumn, + Entity, + JoinTable, + ManyToMany, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ensureSnowflakeId } from '@/common'; +import { AdminMenu } from '../menu/admin-menu.entity'; +import { AdminUser } from '../user/admin-user.entity'; + +@Entity('admin_role') +export class AdminRole { + @PrimaryColumn({ + type: 'bigint', + }) + id: string; + + @Column({ + name: 'role_code', + unique: true, + }) + roleCode: string; + + @Column() + name: string; + + @Column({ + default: '', + }) + remark: string; + + @Column({ + default: 1, + }) + status: number; + + @Column({ + default: false, + name: 'is_deleted', + }) + isDeleted: boolean; + + @CreateDateColumn({ + name: 'create_time', + }) + createTime: Date; + + @UpdateDateColumn({ + name: 'update_time', + }) + updateTime: Date; + + @ManyToMany(() => AdminMenu, (menu) => menu.roles) + @JoinTable({ + inverseJoinColumn: { + name: 'menu_id', + referencedColumnName: 'id', + }, + joinColumn: { + name: 'role_id', + referencedColumnName: 'id', + }, + name: 'admin_role_menu', + }) + menus: AdminMenu[]; + + @ManyToMany(() => AdminUser, (user) => user.roles) + users: AdminUser[]; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/admin/role/admin-role.service.ts b/src/admin/role/admin-role.service.ts new file mode 100644 index 0000000..146338f --- /dev/null +++ b/src/admin/role/admin-role.service.ts @@ -0,0 +1,138 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { throwVbenError } from '@/common'; +import { AdminMenu } from '../menu/admin-menu.entity'; +import { AdminRole } from './admin-role.entity'; + +type RoleInput = Partial & { + permissions?: string[]; +}; + +type ListQuery = Record; + +@Injectable() +export class AdminRoleService { + constructor( + @InjectRepository(AdminRole) + private readonly roleRepository: Repository, + @InjectRepository(AdminMenu) + private readonly menuRepository: Repository, + ) {} + + async getRoleList(query: ListQuery) { + const page = Number(query.page || 1); + const pageSize = Number(query.pageSize || 20); + const builder = this.roleRepository + .createQueryBuilder('role') + .leftJoinAndSelect('role.menus', 'menu') + .where('role.isDeleted = :isDeleted', { isDeleted: false }); + + if (query.id) builder.andWhere('role.id LIKE :id', { id: `%${query.id}%` }); + if (query.name) { + builder.andWhere('role.name LIKE :name', { name: `%${query.name}%` }); + } + if (query.remark) { + builder.andWhere('role.remark LIKE :remark', { + remark: `%${query.remark}%`, + }); + } + if (['0', '1'].includes(String(query.status))) { + builder.andWhere('role.status = :status', { + status: Number(query.status), + }); + } + if (query.startTime) { + builder.andWhere('role.createTime >= :startTime', { + startTime: query.startTime, + }); + } + if (query.endTime) { + builder.andWhere('role.createTime <= :endTime', { + endTime: query.endTime, + }); + } + + const [roles, total] = await builder + .orderBy('role.createTime', 'ASC') + .skip((page - 1) * pageSize) + .take(pageSize) + .getManyAndCount(); + + return { + items: roles.map((role) => this.serializeRole(role)), + total, + }; + } + + async createRole(data: RoleInput) { + const role = this.roleRepository.create({ + name: data.name, + remark: data.remark || '', + roleCode: this.createRoleCode(data.name), + status: data.status ?? 1, + }); + role.menus = await this.findMenusByIds(data.permissions || []); + await this.roleRepository.save(role); + return null; + } + + async updateRole(id: string, data: RoleInput) { + const role = await this.roleRepository.findOne({ + relations: ['menus'], + where: { + id, + isDeleted: false, + }, + }); + if (!role) throwVbenError('角色不存在', HttpStatus.BAD_REQUEST); + + if (data.name !== undefined) role.name = data.name; + if (data.remark !== undefined) role.remark = data.remark; + if (data.status !== undefined) role.status = data.status; + if (data.permissions) role.menus = await this.findMenusByIds(data.permissions); + + await this.roleRepository.save(role); + return null; + } + + async deleteRole(id: string) { + await this.roleRepository.update( + { id }, + { + isDeleted: true, + }, + ); + return null; + } + + private serializeRole(role: AdminRole) { + return { + createTime: role.createTime, + id: role.id, + name: role.name, + permissions: (role.menus || []).map((menu) => menu.id), + remark: role.remark, + status: role.status, + }; + } + + private async findMenusByIds(ids: string[]) { + const normalizedIds = ids.map((id) => String(id)).filter(Boolean); + if (!normalizedIds.length) return []; + return this.menuRepository.find({ + where: normalizedIds.map((id) => ({ + id, + isDeleted: false, + })), + }); + } + + private createRoleCode(name?: string) { + const slug = (name || 'role') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); + return `${slug || 'role'}_${Date.now()}`; + } +} diff --git a/src/admin/timezone/admin-timezone.controller.ts b/src/admin/timezone/admin-timezone.controller.ts new file mode 100644 index 0000000..8376838 --- /dev/null +++ b/src/admin/timezone/admin-timezone.controller.ts @@ -0,0 +1,45 @@ +import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { CurrentAdminUser, vbenSuccess } from '@/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { AdminUser } from '../user/admin-user.entity'; +import { AdminTimezoneService } from './admin-timezone.service'; + +const TIMEZONE_OPTIONS = [ + { label: 'America/New_York (GMT-5)', value: 'America/New_York' }, + { label: 'Europe/London (GMT+0)', value: 'Europe/London' }, + { label: 'Asia/Shanghai (GMT+8)', value: 'Asia/Shanghai' }, + { label: 'Asia/Tokyo (GMT+9)', value: 'Asia/Tokyo' }, + { label: 'Asia/Seoul (GMT+9)', value: 'Asia/Seoul' }, +]; + +@ApiTags('admin-timezone') +@Controller('timezone') +@UseGuards(JwtAuthGuard) +export class AdminTimezoneController { + constructor(private readonly timezoneService: AdminTimezoneService) {} + + @Get('getTimezoneOptions') + getOptions() { + return vbenSuccess(TIMEZONE_OPTIONS); + } + + @Get('getTimezone') + async getTimezone(@CurrentAdminUser() user: AdminUser) { + return vbenSuccess(await this.timezoneService.getTimezone(user)); + } + + @Post('setTimezone') + async setTimezone( + @CurrentAdminUser() user: AdminUser, + @Body() body: { timezone?: string }, + ) { + return vbenSuccess( + await this.timezoneService.setTimezone( + user, + body.timezone, + TIMEZONE_OPTIONS.map((option) => option.value), + ), + ); + } +} diff --git a/src/admin/timezone/admin-timezone.service.ts b/src/admin/timezone/admin-timezone.service.ts new file mode 100644 index 0000000..c64c821 --- /dev/null +++ b/src/admin/timezone/admin-timezone.service.ts @@ -0,0 +1,28 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { throwVbenError } from '@/common'; +import { AdminUser } from '../user/admin-user.entity'; + +@Injectable() +export class AdminTimezoneService { + constructor( + @InjectRepository(AdminUser) + private readonly userRepository: Repository, + ) {} + + async getTimezone(user: AdminUser) { + return user.timezone || 'Asia/Shanghai'; + } + + async setTimezone(user: AdminUser, timezone: string, allowed: string[]) { + if (!timezone || !allowed.includes(timezone)) { + throwVbenError('Invalid timezone', HttpStatus.BAD_REQUEST, 'Bad Request'); + } + + await this.userRepository.update(user.id, { + timezone, + }); + return {}; + } +} diff --git a/src/admin/user/admin-user.controller.ts b/src/admin/user/admin-user.controller.ts new file mode 100644 index 0000000..b473016 --- /dev/null +++ b/src/admin/user/admin-user.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { CurrentAdminUser, vbenSuccess } from '@/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { AdminUser } from './admin-user.entity'; +import { AdminUserService } from './admin-user.service'; + +@ApiTags('admin-user') +@Controller('user') +@UseGuards(JwtAuthGuard) +export class AdminUserController { + constructor(private readonly userService: AdminUserService) {} + + @Get('info') + async info(@CurrentAdminUser() user: AdminUser) { + return vbenSuccess(this.userService.serializeUser(user)); + } +} diff --git a/src/admin/user/admin-user.entity.ts b/src/admin/user/admin-user.entity.ts new file mode 100644 index 0000000..f37cd66 --- /dev/null +++ b/src/admin/user/admin-user.entity.ts @@ -0,0 +1,86 @@ +import { + BeforeInsert, + Column, + CreateDateColumn, + Entity, + JoinTable, + ManyToMany, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ensureSnowflakeId } from '@/common'; +import { AdminRole } from '../role/admin-role.entity'; + +@Entity('admin_user') +export class AdminUser { + @PrimaryColumn({ + type: 'bigint', + }) + id: string; + + @Column({ + unique: true, + }) + username: string; + + @Column() + password: string; + + @Column({ + name: 'real_name', + }) + realName: string; + + @Column({ + default: '', + name: 'home_path', + }) + homePath: string; + + @Column({ + default: 'Asia/Shanghai', + }) + timezone: string; + + @Column({ + default: 1, + }) + status: number; + + @Column({ + default: false, + name: 'is_deleted', + }) + isDeleted: boolean; + + @CreateDateColumn({ + name: 'create_time', + }) + createTime: Date; + + @UpdateDateColumn({ + name: 'update_time', + }) + updateTime: Date; + + @ManyToMany(() => AdminRole, (role) => role.users, { + eager: true, + }) + @JoinTable({ + inverseJoinColumn: { + name: 'role_id', + referencedColumnName: 'id', + }, + joinColumn: { + name: 'user_id', + referencedColumnName: 'id', + }, + name: 'admin_user_role', + }) + roles: AdminRole[]; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/admin/user/admin-user.service.ts b/src/admin/user/admin-user.service.ts new file mode 100644 index 0000000..c59ed24 --- /dev/null +++ b/src/admin/user/admin-user.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { AdminUser } from './admin-user.entity'; + +@Injectable() +export class AdminUserService { + serializeUser(user: AdminUser) { + return { + homePath: user.homePath, + id: user.id, + realName: user.realName, + roles: (user.roles || []) + .filter((role) => !role.isDeleted && role.status === 1) + .map((role) => role.roleCode), + username: user.username, + }; + } +} diff --git a/src/app.module.ts b/src/app.module.ts index fe896f5..cd8918a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -5,10 +5,9 @@ import { APP_INTERCEPTOR } from '@nestjs/core'; import { AppService } from './app.service'; import { TypeOrmModule } from '@nestjs/typeorm'; import { MinioModule } from 'nestjs-minio-client'; -import { ComponentModule } from './component/component.module'; -import { DictModule } from './dict/dict.module'; import { MinioClientModule } from './minio/minio.module'; import { SaveBodyInterceptor } from './common'; +import { AdminModule } from './admin/admin.module'; @Module({ imports: [ @@ -26,8 +25,9 @@ import { SaveBodyInterceptor } from './common'; username: configService.get('DB_USERNAME'), password: configService.get('DB_PASSWORD'), database: configService.get('DB_DATABASE'), - synchronize: configService.get('DB_SYNC'), - entities: [__dirname + '/**/*.entity.js'], + synchronize: configService.get('DB_SYNC') === 'true', + entities: [__dirname + '/**/*.entity{.ts,.js}'], + subscribers: [__dirname + '/**/*.subscriber{.ts,.js}'], }; }, inject: [ConfigService], @@ -46,9 +46,8 @@ import { SaveBodyInterceptor } from './common'; }, inject: [ConfigService], }), - ComponentModule, - DictModule, MinioClientModule, + AdminModule, ], providers: [ AppService, diff --git a/src/common/admin-response.ts b/src/common/admin-response.ts new file mode 100644 index 0000000..930b66e --- /dev/null +++ b/src/common/admin-response.ts @@ -0,0 +1,37 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export type VbenResponse = { + code: number; + data: T; + error: any; + message: string; +}; + +export const vbenSuccess = (data: T): VbenResponse => ({ + code: 0, + data, + error: null, + message: 'ok', +}); + +export const vbenPage = (items: T[], total: number) => + vbenSuccess({ + items, + total, + }); + +export const throwVbenError = ( + message: string, + status = HttpStatus.BAD_REQUEST, + error: any = message, +): never => { + throw new HttpException( + { + code: -1, + data: null, + error, + message, + }, + status, + ); +}; diff --git a/src/common/admin-tree.ts b/src/common/admin-tree.ts new file mode 100644 index 0000000..be7c49e --- /dev/null +++ b/src/common/admin-tree.ts @@ -0,0 +1,19 @@ +export function toTree( + nodes: T[], +) { + const map = new Map(); + nodes.forEach((node) => map.set(node.id, { ...node })); + + const roots: Array = []; + map.forEach((node) => { + const parent = node.pid ? map.get(node.pid) : null; + if (parent) { + parent.children = parent.children || []; + parent.children.push(node); + } else { + roots.push(node); + } + }); + + return roots; +} diff --git a/src/common/decorators/current-admin-user.decorator.ts b/src/common/decorators/current-admin-user.decorator.ts new file mode 100644 index 0000000..cb56e2b --- /dev/null +++ b/src/common/decorators/current-admin-user.decorator.ts @@ -0,0 +1,8 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const CurrentAdminUser = createParamDecorator( + (_data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.adminUser; + }, +); diff --git a/src/common/decorators/public.decorator.ts b/src/common/decorators/public.decorator.ts new file mode 100644 index 0000000..767ac49 --- /dev/null +++ b/src/common/decorators/public.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; + +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/common/index.ts b/src/common/index.ts index 0777303..e13ff85 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -1,4 +1,9 @@ +export * from './admin-response'; +export * from './admin-tree'; +export * from './decorators/current-admin-user.decorator'; export * from './decorators/decode-dict.decorator'; +export * from './decorators/public.decorator'; export * from './interceptors/save-body.interceptor'; +export * from './snowflake-id'; export * from './services/tool.service'; export * from './swagger/swagger-response'; diff --git a/src/common/snowflake-id.subscriber.ts b/src/common/snowflake-id.subscriber.ts new file mode 100644 index 0000000..be0c887 --- /dev/null +++ b/src/common/snowflake-id.subscriber.ts @@ -0,0 +1,20 @@ +import { + EntitySubscriberInterface, + EventSubscriber, + InsertEvent, +} from 'typeorm'; +import { ensureSnowflakeId } from './snowflake-id'; + +@EventSubscriber() +export class SnowflakeIdSubscriber implements EntitySubscriberInterface { + beforeInsert(event: InsertEvent) { + if (!event.entity) return; + + const idColumn = event.metadata.primaryColumns.find( + (column) => column.propertyName === 'id', + ); + if (idColumn?.type !== 'bigint') return; + + ensureSnowflakeId(event.entity); + } +} diff --git a/src/common/snowflake-id.ts b/src/common/snowflake-id.ts new file mode 100644 index 0000000..0d16df2 --- /dev/null +++ b/src/common/snowflake-id.ts @@ -0,0 +1,93 @@ +const TWEPOCH = 1288834974657n; +const WORKER_ID_BITS = 5n; +const DATACENTER_ID_BITS = 5n; +const SEQUENCE_BITS = 12n; + +const MAX_WORKER_ID = (1n << WORKER_ID_BITS) - 1n; +const MAX_DATACENTER_ID = (1n << DATACENTER_ID_BITS) - 1n; +const SEQUENCE_MASK = (1n << SEQUENCE_BITS) - 1n; +const WORKER_ID_SHIFT = SEQUENCE_BITS; +const DATACENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS; +const TIMESTAMP_LEFT_SHIFT = + SEQUENCE_BITS + WORKER_ID_BITS + DATACENTER_ID_BITS; + +class SnowflakeIdGenerator { + private readonly workerId = this.readNodeId( + 'SNOWFLAKE_WORKER_ID', + MAX_WORKER_ID, + ); + private readonly datacenterId = this.readNodeId( + 'SNOWFLAKE_DATACENTER_ID', + MAX_DATACENTER_ID, + ); + private lastTimestamp = -1n; + private sequence = 0n; + + nextId() { + let timestamp = this.currentTime(); + + if (timestamp < this.lastTimestamp) { + timestamp = this.waitUntil(this.lastTimestamp); + } + + if (timestamp === this.lastTimestamp) { + this.sequence = (this.sequence + 1n) & SEQUENCE_MASK; + if (this.sequence === 0n) { + timestamp = this.waitUntil(this.lastTimestamp); + } + } else { + this.sequence = 0n; + } + + this.lastTimestamp = timestamp; + + return ( + ((timestamp - TWEPOCH) << TIMESTAMP_LEFT_SHIFT) | + (this.datacenterId << DATACENTER_ID_SHIFT) | + (this.workerId << WORKER_ID_SHIFT) | + this.sequence + ).toString(); + } + + private currentTime() { + return BigInt(Date.now()); + } + + private waitUntil(lastTimestamp: bigint) { + // Snowflake requires monotonic timestamps; waiting avoids duplicate IDs + // when the system clock briefly moves backwards or a millisecond is full. + let timestamp = this.currentTime(); + while (timestamp <= lastTimestamp) { + timestamp = this.currentTime(); + } + return timestamp; + } + + private readNodeId(envName: string, max: bigint) { + const value = Number(process.env[envName] || 1); + if (!Number.isInteger(value) || value < 0 || value > Number(max)) { + return 1n; + } + return BigInt(value); + } +} + +const snowflakeIdGenerator = new SnowflakeIdGenerator(); + +export const createSnowflakeId = () => snowflakeIdGenerator.nextId(); + +export type SnowflakeEntity = { + id?: number | string | null; +}; + +export const isEmptySnowflakeId = (id: SnowflakeEntity['id']) => + id === undefined || id === null || id === '' || id === 0 || id === '0'; + +export const ensureSnowflakeId = (entity: T) => { + if (isEmptySnowflakeId(entity.id)) { + entity.id = createSnowflakeId(); + } else { + entity.id = String(entity.id); + } + return entity.id; +}; diff --git a/src/component/component.module.ts b/src/component/component.module.ts deleted file mode 100644 index 9a70586..0000000 --- a/src/component/component.module.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ComponentController } from './component.controller'; -import { ComponentService } from './component.service'; -import { Component } from './component.entity'; -import { ToolsService } from '@/common'; -import { DictModule } from '@/dict/dict.module'; - -@Module({ - imports: [TypeOrmModule.forFeature([Component]), DictModule], - controllers: [ComponentController], - providers: [ComponentService, ToolsService], - exports: [ComponentService], -}) -export class ComponentModule {} diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 41db174..e88ee6f 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -5,11 +5,13 @@ import request = require('supertest'); import { Readable } from 'stream'; import { AppController } from '../src/app.controller'; import { AppService } from '../src/app.service'; +import { AdminAuthService } from '../src/admin/auth/admin-auth.service'; +import { JwtAuthGuard } from '../src/admin/auth/jwt-auth.guard'; +import { ComponentController } from '../src/admin/component/component.controller'; +import { ComponentService } from '../src/admin/component/component.service'; +import { DictController } from '../src/admin/dict/dict.controller'; +import { DictService } from '../src/admin/dict/dict.service'; import { SaveBodyInterceptor, ToolsService } from '../src/common'; -import { ComponentController } from '../src/component/component.controller'; -import { ComponentService } from '../src/component/component.service'; -import { DictController } from '../src/dict/dict.controller'; -import { DictService } from '../src/dict/dict.service'; import { MinioClientController } from '../src/minio/minio.controller'; import { MinioClientService } from '../src/minio/minio.service'; import { @@ -18,7 +20,7 @@ import { } from './helpers/controller-route.helper'; const component = { - id: 'component-id', + id: '2041739550026043392', name: '基础折线图', type: 1, componentType: 1, @@ -70,6 +72,10 @@ const componentServiceMock = { find: jest.fn(), }; +const authServiceMock = { + currentUser: jest.fn(), +}; + const dictServiceMock = { getDictByKey: jest.fn(), getComponentDictByType: jest.fn(), @@ -154,7 +160,7 @@ const routeTestCases: Record = { const response = await request(server) .post('/component/save') .send({ - id: 'frontend-id', + id: '2041739550026043999', name: component.name, type: component.type, componentType: component.componentType, @@ -382,6 +388,42 @@ const routeTestCases: Record = { }); }, + 'GET /minio/resource-proxy': async (server) => { + const body = Buffer.from('proxy-content'); + const originalFetch = global.fetch; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + headers: { + get: jest.fn().mockReturnValue('text/css; charset=utf-8'), + }, + arrayBuffer: jest + .fn() + .mockResolvedValue( + body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength), + ), + } as any); + + try { + const response = await request(server) + .get('/minio/resource-proxy') + .query({ url: 'https://example.com/assets/style.css' }) + .expect(200); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://example.com/assets/style.css', + expect.objectContaining({ + redirect: 'follow', + signal: expect.any(AbortSignal), + }), + ); + expect(response.headers['content-type']).toContain('text/css'); + expect(response.text).toBe('proxy-content'); + } finally { + global.fetch = originalFetch; + } + }, + 'GET /minio/download': async (server) => { minioServiceMock.getObject.mockResolvedValue({ stream: Readable.from(['file-content']), @@ -450,6 +492,11 @@ describe('KT Template Online API (e2e)', () => { provide: ComponentService, useValue: componentServiceMock, }, + { + provide: AdminAuthService, + useValue: authServiceMock, + }, + JwtAuthGuard, { provide: DictService, useValue: dictServiceMock,