feat(api): 完善后台管理能力

This commit is contained in:
sunlei 2026-05-16 20:14:02 +08:00
parent 3364465267
commit 887ab392b6
51 changed files with 2519 additions and 87 deletions

View File

@ -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

1
.husky/commit-msg Normal file
View File

@ -0,0 +1 @@
node scripts/validate-commit-msg.mjs "$1"

1
.husky/pre-commit Normal file
View File

@ -0,0 +1 @@
pnpm run verify:commit

97
API.md
View File

@ -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>` 传递 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`

View File

@ -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/`
## 轻量验证

View File

@ -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",

View File

@ -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

View File

@ -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);
}

View File

@ -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'
);

View File

@ -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;

View File

@ -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;

View File

@ -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`);

250
sql/vben-admin-init.sql Normal file
View File

@ -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;

64
src/admin/admin.module.ts Normal file
View File

@ -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 {}

View File

@ -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));
}
}

View File

@ -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<AdminUser>,
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<Record<string, string>>((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' });
}
}

View File

@ -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<string>('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)
);
}
}

View File

@ -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<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest<AdminRequest>();
const authorization = request.headers.authorization;
request.adminUser = await this.authService.currentUser(
Array.isArray(authorization) ? authorization[0] : authorization,
request,
);
return true;
}
}

View File

@ -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);

View File

@ -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);
}
}

View File

@ -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<Component> {
async find(id: string): Promise<Component> {
await this.dictService.refreshDecodeCache();
const component = await this.userRepository

View File

@ -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<AdminDept>) {
return vbenSuccess(await this.deptService.createDept(body));
}
@Put(':id')
async update(
@Param('id') id: string,
@Body() body: Partial<AdminDept>,
) {
return vbenSuccess(await this.deptService.updateDept(id, body));
}
@Delete(':id')
async remove(@Param('id') id: string) {
return vbenSuccess(await this.deptService.deleteDept(id));
}
}

View File

@ -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);
}
}

View File

@ -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<AdminDept>,
) {}
async getDeptList() {
const depts = await this.deptRepository.find({
where: {
isDeleted: false,
},
order: {
createTime: 'ASC',
},
});
return this.buildDeptTree(depts);
}
async createDept(data: Partial<AdminDept>) {
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<AdminDept>) {
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);
}
}

View File

@ -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);
}
}

View File

@ -1,6 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
export class DictDto {
@ApiProperty({
example: 'COMPONENT_TYPE',
required: false,
})
dictCode?: string;
@ApiProperty({
example: '图表',
})

View File

@ -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],

View File

@ -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<DictEntity>,
@InjectRepository(AdminDict)
private readonly dictRepository: Repository<AdminDict>,
) {}
async onApplicationBootstrap() {
@ -20,8 +20,9 @@ export class DictService implements OnApplicationBootstrap {
async getDictByKey(dictKey: string): Promise<Dict[]> {
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<Dict[]> {
// 一级类型的 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,
})),
);
}
}

View File

@ -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<string, any>,
) {
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;
});
}
}

View File

@ -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<AdminMenu>) {
return vbenSuccess(await this.menuService.createMenu(body));
}
@Put('system/menu/:id')
async update(
@Param('id') id: string,
@Body() body: Partial<AdminMenu>,
) {
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));
}
}

View File

@ -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<string, any>;
@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);
}
}

View File

@ -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<AdminMenu> & {
activePath?: string;
linkSrc?: string;
};
@Injectable()
export class AdminMenuService {
constructor(
@InjectRepository(AdminMenu)
private readonly menuRepository: Repository<AdminMenu>,
) {}
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<string, AdminMenu>();
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<AdminMenu> {
const meta = this.normalizeMetaInput(data);
const menu: Partial<AdminMenu> = {
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<string>([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;
}
}

View File

@ -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<string, any>) {
const page = await this.roleService.getRoleList(query);
return vbenPage(page.items, page.total);
}
@Post()
async create(@Body() body: Record<string, any>) {
return vbenSuccess(await this.roleService.createRole(body));
}
@Put(':id')
async update(
@Param('id') id: string,
@Body() body: Record<string, any>,
) {
return vbenSuccess(await this.roleService.updateRole(id, body));
}
@Delete(':id')
async remove(@Param('id') id: string) {
return vbenSuccess(await this.roleService.deleteRole(id));
}
}

View File

@ -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);
}
}

View File

@ -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<AdminRole> & {
permissions?: string[];
};
type ListQuery = Record<string, any>;
@Injectable()
export class AdminRoleService {
constructor(
@InjectRepository(AdminRole)
private readonly roleRepository: Repository<AdminRole>,
@InjectRepository(AdminMenu)
private readonly menuRepository: Repository<AdminMenu>,
) {}
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()}`;
}
}

View File

@ -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),
),
);
}
}

View File

@ -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<AdminUser>,
) {}
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 {};
}
}

View File

@ -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));
}
}

View File

@ -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);
}
}

View File

@ -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,
};
}
}

View File

@ -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<string>('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,

View File

@ -0,0 +1,37 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export type VbenResponse<T = any> = {
code: number;
data: T;
error: any;
message: string;
};
export const vbenSuccess = <T = any>(data: T): VbenResponse<T> => ({
code: 0,
data,
error: null,
message: 'ok',
});
export const vbenPage = <T = any>(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,
);
};

19
src/common/admin-tree.ts Normal file
View File

@ -0,0 +1,19 @@
export function toTree<T extends { id: string; pid?: string | null }>(
nodes: T[],
) {
const map = new Map<string, T & { children?: T[] }>();
nodes.forEach((node) => map.set(node.id, { ...node }));
const roots: Array<T & { children?: T[] }> = [];
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;
}

View File

@ -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;
},
);

View File

@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@ -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';

View File

@ -0,0 +1,20 @@
import {
EntitySubscriberInterface,
EventSubscriber,
InsertEvent,
} from 'typeorm';
import { ensureSnowflakeId } from './snowflake-id';
@EventSubscriber()
export class SnowflakeIdSubscriber implements EntitySubscriberInterface {
beforeInsert(event: InsertEvent<any>) {
if (!event.entity) return;
const idColumn = event.metadata.primaryColumns.find(
(column) => column.propertyName === 'id',
);
if (idColumn?.type !== 'bigint') return;
ensureSnowflakeId(event.entity);
}
}

View File

@ -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 = <T extends SnowflakeEntity>(entity: T) => {
if (isEmptySnowflakeId(entity.id)) {
entity.id = createSnowflakeId();
} else {
entity.id = String(entity.id);
}
return entity.id;
};

View File

@ -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 {}

View File

@ -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<string, RouteTestCase> = {
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<string, RouteTestCase> = {
});
},
'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,