feat: backend foundation (Prisma 6 + MySQL, auth, tenant/soft-delete/data-scope filters, core CRUD)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
dc40ca3343
commit
d8d3055c73
@ -28,7 +28,12 @@
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^12.0.1",
|
||||
"@nestjs/core": "^12.0.1",
|
||||
"@nestjs/jwt": "^12.0.2",
|
||||
"@nestjs/platform-express": "^12.0.1",
|
||||
"@prisma/client": "^6.19.3",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
@ -37,13 +42,16 @@
|
||||
"@nestjs/mau": "^0.2.6",
|
||||
"@nestjs/schematics": "^12.0.0",
|
||||
"@nestjs/testing": "^12.0.1",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"@vitest/coverage-v8": "^4.1.2",
|
||||
"dotenv": "^18.0.6",
|
||||
"oxlint": "^1.58.0",
|
||||
"oxlint-tsgolint": "^7.0.2001",
|
||||
"prettier": "^3.4.2",
|
||||
"prisma": "^6.19.3",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"typescript": "^6.0.2",
|
||||
|
||||
938
pnpm-lock.yaml
generated
938
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
5
pnpm-workspace.yaml
Normal file
5
pnpm-workspace.yaml
Normal file
@ -0,0 +1,5 @@
|
||||
allowBuilds:
|
||||
'@prisma/client': true
|
||||
'@prisma/engines': true
|
||||
esbuild: true
|
||||
prisma: true
|
||||
247
prisma/migrations/20261010073709_init/migration.sql
Normal file
247
prisma/migrations/20261010073709_init/migration.sql
Normal file
@ -0,0 +1,247 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `sys_tenant` (
|
||||
`id` VARCHAR(36) NOT NULL,
|
||||
`code` VARCHAR(64) NOT NULL,
|
||||
`name` VARCHAR(128) NOT NULL,
|
||||
`status` VARCHAR(16) NOT NULL DEFAULT 'enabled',
|
||||
`remark` VARCHAR(255) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`created_by` VARCHAR(36) NULL,
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `sys_tenant_code_key`(`code`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sys_user` (
|
||||
`id` VARCHAR(36) NOT NULL,
|
||||
`tenant_code` VARCHAR(64) NOT NULL,
|
||||
`username` VARCHAR(64) NOT NULL,
|
||||
`password_hash` VARCHAR(255) NOT NULL,
|
||||
`nickname` VARCHAR(64) NULL,
|
||||
`email` VARCHAR(128) NULL,
|
||||
`phone` VARCHAR(32) NULL,
|
||||
`avatar` VARCHAR(255) NULL,
|
||||
`status` VARCHAR(16) NOT NULL DEFAULT 'enabled',
|
||||
`org_code` VARCHAR(64) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`created_by` VARCHAR(36) NULL,
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
`deleted_at` DATETIME(3) NULL,
|
||||
|
||||
INDEX `sys_user_tenant_code_idx`(`tenant_code`),
|
||||
INDEX `sys_user_org_code_idx`(`org_code`),
|
||||
UNIQUE INDEX `sys_user_tenant_code_username_key`(`tenant_code`, `username`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sys_role` (
|
||||
`id` VARCHAR(36) NOT NULL,
|
||||
`tenant_code` VARCHAR(64) NOT NULL,
|
||||
`code` VARCHAR(64) NOT NULL,
|
||||
`name` VARCHAR(64) NOT NULL,
|
||||
`data_scope` VARCHAR(16) NOT NULL DEFAULT 'all',
|
||||
`status` VARCHAR(16) NOT NULL DEFAULT 'enabled',
|
||||
`remark` VARCHAR(255) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`created_by` VARCHAR(36) NULL,
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
`deleted_at` DATETIME(3) NULL,
|
||||
|
||||
INDEX `sys_role_tenant_code_idx`(`tenant_code`),
|
||||
UNIQUE INDEX `sys_role_tenant_code_code_key`(`tenant_code`, `code`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sys_resource` (
|
||||
`id` VARCHAR(36) NOT NULL,
|
||||
`tenant_code` VARCHAR(64) NOT NULL,
|
||||
`parent_code` VARCHAR(64) NULL,
|
||||
`type` VARCHAR(16) NOT NULL,
|
||||
`code` VARCHAR(128) NOT NULL,
|
||||
`name` VARCHAR(64) NOT NULL,
|
||||
`path` VARCHAR(255) NULL,
|
||||
`component` VARCHAR(255) NULL,
|
||||
`method` VARCHAR(8) NULL,
|
||||
`api_path` VARCHAR(255) NULL,
|
||||
`icon` VARCHAR(64) NULL,
|
||||
`sort` INTEGER NOT NULL DEFAULT 0,
|
||||
`status` VARCHAR(16) NOT NULL DEFAULT 'enabled',
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`created_by` VARCHAR(36) NULL,
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
`deleted_at` DATETIME(3) NULL,
|
||||
|
||||
INDEX `sys_resource_tenant_code_idx`(`tenant_code`),
|
||||
INDEX `sys_resource_parent_code_idx`(`parent_code`),
|
||||
UNIQUE INDEX `sys_resource_tenant_code_code_key`(`tenant_code`, `code`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sys_org` (
|
||||
`id` VARCHAR(36) NOT NULL,
|
||||
`tenant_code` VARCHAR(64) NOT NULL,
|
||||
`parent_code` VARCHAR(64) NULL,
|
||||
`code` VARCHAR(64) NOT NULL,
|
||||
`name` VARCHAR(64) NOT NULL,
|
||||
`leader` VARCHAR(64) NULL,
|
||||
`sort` INTEGER NOT NULL DEFAULT 0,
|
||||
`status` VARCHAR(16) NOT NULL DEFAULT 'enabled',
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`created_by` VARCHAR(36) NULL,
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
`deleted_at` DATETIME(3) NULL,
|
||||
|
||||
INDEX `sys_org_tenant_code_idx`(`tenant_code`),
|
||||
INDEX `sys_org_parent_code_idx`(`parent_code`),
|
||||
UNIQUE INDEX `sys_org_tenant_code_code_key`(`tenant_code`, `code`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sys_dict_type` (
|
||||
`id` VARCHAR(36) NOT NULL,
|
||||
`tenant_code` VARCHAR(64) NOT NULL,
|
||||
`code` VARCHAR(64) NOT NULL,
|
||||
`name` VARCHAR(64) NOT NULL,
|
||||
`status` VARCHAR(16) NOT NULL DEFAULT 'enabled',
|
||||
`remark` VARCHAR(255) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`created_by` VARCHAR(36) NULL,
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
`deleted_at` DATETIME(3) NULL,
|
||||
|
||||
INDEX `sys_dict_type_tenant_code_idx`(`tenant_code`),
|
||||
UNIQUE INDEX `sys_dict_type_tenant_code_code_key`(`tenant_code`, `code`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sys_dict_item` (
|
||||
`id` VARCHAR(36) NOT NULL,
|
||||
`tenant_code` VARCHAR(64) NOT NULL,
|
||||
`dict_type_code` VARCHAR(64) NOT NULL,
|
||||
`label` VARCHAR(64) NOT NULL,
|
||||
`value` VARCHAR(64) NOT NULL,
|
||||
`sort` INTEGER NOT NULL DEFAULT 0,
|
||||
`status` VARCHAR(16) NOT NULL DEFAULT 'enabled',
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`created_by` VARCHAR(36) NULL,
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
`deleted_at` DATETIME(3) NULL,
|
||||
|
||||
INDEX `sys_dict_item_tenant_code_dict_type_code_idx`(`tenant_code`, `dict_type_code`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sys_config` (
|
||||
`id` VARCHAR(36) NOT NULL,
|
||||
`tenant_code` VARCHAR(64) NOT NULL,
|
||||
`key` VARCHAR(128) NOT NULL,
|
||||
`value` TEXT NULL,
|
||||
`remark` VARCHAR(255) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`created_by` VARCHAR(36) NULL,
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
`deleted_at` DATETIME(3) NULL,
|
||||
|
||||
UNIQUE INDEX `sys_config_tenant_code_key_key`(`tenant_code`, `key`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sys_audit_log` (
|
||||
`id` VARCHAR(36) NOT NULL,
|
||||
`tenant_code` VARCHAR(64) NOT NULL,
|
||||
`user_id` VARCHAR(36) NULL,
|
||||
`username` VARCHAR(64) NULL,
|
||||
`module` VARCHAR(64) NULL,
|
||||
`action` VARCHAR(64) NULL,
|
||||
`method` VARCHAR(8) NULL,
|
||||
`path` VARCHAR(255) NULL,
|
||||
`params` TEXT NULL,
|
||||
`result` TEXT NULL,
|
||||
`status` VARCHAR(16) NULL,
|
||||
`error_message` TEXT NULL,
|
||||
`ip` VARCHAR(64) NULL,
|
||||
`user_agent` VARCHAR(255) NULL,
|
||||
`duration_ms` INTEGER NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `sys_audit_log_tenant_code_created_at_idx`(`tenant_code`, `created_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sys_login_log` (
|
||||
`id` VARCHAR(36) NOT NULL,
|
||||
`tenant_code` VARCHAR(64) NOT NULL,
|
||||
`username` VARCHAR(64) NULL,
|
||||
`ip` VARCHAR(64) NULL,
|
||||
`user_agent` VARCHAR(255) NULL,
|
||||
`status` VARCHAR(16) NOT NULL DEFAULT 'success',
|
||||
`message` VARCHAR(255) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `sys_login_log_tenant_code_created_at_idx`(`tenant_code`, `created_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sys_refresh_token` (
|
||||
`id` VARCHAR(36) NOT NULL,
|
||||
`tenant_code` VARCHAR(64) NOT NULL,
|
||||
`user_id` VARCHAR(36) NOT NULL,
|
||||
`jti` VARCHAR(64) NOT NULL,
|
||||
`token_hash` VARCHAR(255) NOT NULL,
|
||||
`ip` VARCHAR(64) NULL,
|
||||
`user_agent` VARCHAR(255) NULL,
|
||||
`login_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`expire_at` DATETIME(3) NOT NULL,
|
||||
`revoked_at` DATETIME(3) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
UNIQUE INDEX `sys_refresh_token_jti_key`(`jti`),
|
||||
INDEX `sys_refresh_token_tenant_code_user_id_idx`(`tenant_code`, `user_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sys_user_role` (
|
||||
`id` VARCHAR(36) NOT NULL,
|
||||
`tenant_code` VARCHAR(64) NOT NULL,
|
||||
`user_id` VARCHAR(36) NOT NULL,
|
||||
`role_code` VARCHAR(64) NOT NULL,
|
||||
|
||||
INDEX `sys_user_role_tenant_code_user_id_idx`(`tenant_code`, `user_id`),
|
||||
UNIQUE INDEX `sys_user_role_tenant_code_user_id_role_code_key`(`tenant_code`, `user_id`, `role_code`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sys_role_resource` (
|
||||
`id` VARCHAR(36) NOT NULL,
|
||||
`tenant_code` VARCHAR(64) NOT NULL,
|
||||
`role_code` VARCHAR(64) NOT NULL,
|
||||
`resource_code` VARCHAR(128) NOT NULL,
|
||||
|
||||
INDEX `sys_role_resource_tenant_code_role_code_idx`(`tenant_code`, `role_code`),
|
||||
UNIQUE INDEX `sys_role_resource_tenant_code_role_code_resource_code_key`(`tenant_code`, `role_code`, `resource_code`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `sys_role_org` (
|
||||
`id` VARCHAR(36) NOT NULL,
|
||||
`tenant_code` VARCHAR(64) NOT NULL,
|
||||
`role_code` VARCHAR(64) NOT NULL,
|
||||
`org_code` VARCHAR(64) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `sys_role_org_tenant_code_role_code_org_code_key`(`tenant_code`, `role_code`, `org_code`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "mysql"
|
||||
257
prisma/schema.prisma
Normal file
257
prisma/schema.prisma
Normal file
@ -0,0 +1,257 @@
|
||||
// 通用系统管理脚手架 (sysmgr) 数据模型
|
||||
// 约定:表名 sys_ 前缀;列 snake_case(模型 camelCase + @map);业务键关联(角色/资源/组织/字典/租户用 code,用户用 id)。
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
/// 租户
|
||||
model Tenant {
|
||||
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||
code String @unique @db.VarChar(64)
|
||||
name String @db.VarChar(128)
|
||||
status String @default("enabled") @db.VarChar(16)
|
||||
remark String? @db.VarChar(255)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String? @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("sys_tenant")
|
||||
}
|
||||
|
||||
/// 用户
|
||||
model User {
|
||||
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||
username String @db.VarChar(64)
|
||||
passwordHash String @map("password_hash") @db.VarChar(255)
|
||||
nickname String? @db.VarChar(64)
|
||||
email String? @db.VarChar(128)
|
||||
phone String? @db.VarChar(32)
|
||||
avatar String? @db.VarChar(255)
|
||||
status String @default("enabled") @db.VarChar(16)
|
||||
orgCode String? @map("org_code") @db.VarChar(64)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String? @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
@@unique([tenantCode, username])
|
||||
@@index([tenantCode])
|
||||
@@index([orgCode])
|
||||
@@map("sys_user")
|
||||
}
|
||||
|
||||
/// 角色
|
||||
model Role {
|
||||
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||
code String @db.VarChar(64)
|
||||
name String @db.VarChar(64)
|
||||
dataScope String @default("all") @map("data_scope") @db.VarChar(16)
|
||||
status String @default("enabled") @db.VarChar(16)
|
||||
remark String? @db.VarChar(255)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String? @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
@@unique([tenantCode, code])
|
||||
@@index([tenantCode])
|
||||
@@map("sys_role")
|
||||
}
|
||||
|
||||
/// 资源(菜单/按钮/接口)
|
||||
model Resource {
|
||||
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||
parentCode String? @map("parent_code") @db.VarChar(64)
|
||||
type String @db.VarChar(16)
|
||||
code String @db.VarChar(128)
|
||||
name String @db.VarChar(64)
|
||||
path String? @db.VarChar(255)
|
||||
component String? @db.VarChar(255)
|
||||
method String? @db.VarChar(8)
|
||||
apiPath String? @map("api_path") @db.VarChar(255)
|
||||
icon String? @db.VarChar(64)
|
||||
sort Int @default(0)
|
||||
status String @default("enabled") @db.VarChar(16)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String? @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
@@unique([tenantCode, code])
|
||||
@@index([tenantCode])
|
||||
@@index([parentCode])
|
||||
@@map("sys_resource")
|
||||
}
|
||||
|
||||
/// 组织
|
||||
model Org {
|
||||
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||
parentCode String? @map("parent_code") @db.VarChar(64)
|
||||
code String @db.VarChar(64)
|
||||
name String @db.VarChar(64)
|
||||
leader String? @db.VarChar(64)
|
||||
sort Int @default(0)
|
||||
status String @default("enabled") @db.VarChar(16)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String? @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
@@unique([tenantCode, code])
|
||||
@@index([tenantCode])
|
||||
@@index([parentCode])
|
||||
@@map("sys_org")
|
||||
}
|
||||
|
||||
/// 字典类型
|
||||
model DictType {
|
||||
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||
code String @db.VarChar(64)
|
||||
name String @db.VarChar(64)
|
||||
status String @default("enabled") @db.VarChar(16)
|
||||
remark String? @db.VarChar(255)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String? @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
@@unique([tenantCode, code])
|
||||
@@index([tenantCode])
|
||||
@@map("sys_dict_type")
|
||||
}
|
||||
|
||||
/// 字典项
|
||||
model DictItem {
|
||||
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||
dictTypeCode String @map("dict_type_code") @db.VarChar(64)
|
||||
label String @db.VarChar(64)
|
||||
value String @db.VarChar(64)
|
||||
sort Int @default(0)
|
||||
status String @default("enabled") @db.VarChar(16)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String? @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
@@index([tenantCode, dictTypeCode])
|
||||
@@map("sys_dict_item")
|
||||
}
|
||||
|
||||
/// 参数配置
|
||||
model Config {
|
||||
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||
key String @db.VarChar(128)
|
||||
value String? @db.Text
|
||||
remark String? @db.VarChar(255)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String? @map("created_by") @db.VarChar(36)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
@@unique([tenantCode, key])
|
||||
@@map("sys_config")
|
||||
}
|
||||
|
||||
/// 操作日志(只增,硬删)
|
||||
model AuditLog {
|
||||
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||
userId String? @map("user_id") @db.VarChar(36)
|
||||
username String? @db.VarChar(64)
|
||||
module String? @db.VarChar(64)
|
||||
action String? @db.VarChar(64)
|
||||
method String? @db.VarChar(8)
|
||||
path String? @db.VarChar(255)
|
||||
params String? @db.Text
|
||||
result String? @db.Text
|
||||
status String? @db.VarChar(16)
|
||||
errorMessage String? @map("error_message") @db.Text
|
||||
ip String? @db.VarChar(64)
|
||||
userAgent String? @map("user_agent") @db.VarChar(255)
|
||||
durationMs Int? @map("duration_ms")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([tenantCode, createdAt])
|
||||
@@map("sys_audit_log")
|
||||
}
|
||||
|
||||
/// 登录日志(只增,硬删)
|
||||
model LoginLog {
|
||||
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||
username String? @db.VarChar(64)
|
||||
ip String? @db.VarChar(64)
|
||||
userAgent String? @map("user_agent") @db.VarChar(255)
|
||||
status String @default("success") @db.VarChar(16)
|
||||
message String? @db.VarChar(255)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([tenantCode, createdAt])
|
||||
@@map("sys_login_log")
|
||||
}
|
||||
|
||||
/// 刷新令牌(会话载体,硬删)
|
||||
model RefreshToken {
|
||||
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
jti String @unique @db.VarChar(64)
|
||||
tokenHash String @map("token_hash") @db.VarChar(255)
|
||||
ip String? @db.VarChar(64)
|
||||
userAgent String? @map("user_agent") @db.VarChar(255)
|
||||
loginAt DateTime @default(now()) @map("login_at")
|
||||
expireAt DateTime @map("expire_at")
|
||||
revokedAt DateTime? @map("revoked_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([tenantCode, userId])
|
||||
@@map("sys_refresh_token")
|
||||
}
|
||||
|
||||
/// 用户-角色关联
|
||||
model UserRole {
|
||||
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
roleCode String @map("role_code") @db.VarChar(64)
|
||||
|
||||
@@unique([tenantCode, userId, roleCode])
|
||||
@@index([tenantCode, userId])
|
||||
@@map("sys_user_role")
|
||||
}
|
||||
|
||||
/// 角色-资源关联
|
||||
model RoleResource {
|
||||
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||
roleCode String @map("role_code") @db.VarChar(64)
|
||||
resourceCode String @map("resource_code") @db.VarChar(128)
|
||||
|
||||
@@unique([tenantCode, roleCode, resourceCode])
|
||||
@@index([tenantCode, roleCode])
|
||||
@@map("sys_role_resource")
|
||||
}
|
||||
|
||||
/// 角色-组织关联(数据权限「自定义」)
|
||||
model RoleOrg {
|
||||
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||
roleCode String @map("role_code") @db.VarChar(64)
|
||||
orgCode String @map("org_code") @db.VarChar(64)
|
||||
|
||||
@@unique([tenantCode, roleCode, orgCode])
|
||||
@@map("sys_role_org")
|
||||
}
|
||||
@ -1,10 +1,44 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { PrismaModule } from './prisma/prisma.module.js';
|
||||
import { AuthModule } from './auth/auth.module.js';
|
||||
import { JwtAuthGuard } from './auth/jwt-auth.guard.js';
|
||||
import { PermissionsGuard } from './auth/permissions.guard.js';
|
||||
import { RequestContextInterceptor } from './auth/request-context.interceptor.js';
|
||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor.js';
|
||||
import { AllExceptionsFilter } from './common/filters/http-exception.filter.js';
|
||||
import { TenantsModule } from './tenants/tenants.module.js';
|
||||
import { UsersModule } from './users/users.module.js';
|
||||
import { RolesModule } from './roles/roles.module.js';
|
||||
import { ResourcesModule } from './resources/resources.module.js';
|
||||
import { OrgsModule } from './orgs/orgs.module.js';
|
||||
import { DictsModule } from './dicts/dicts.module.js';
|
||||
import { ConfigsModule } from './configs/configs.module.js';
|
||||
import { LogsModule } from './logs/logs.module.js';
|
||||
import { AppController } from './app.controller.js';
|
||||
import { AppService } from './app.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
imports: [
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
TenantsModule,
|
||||
UsersModule,
|
||||
RolesModule,
|
||||
ResourcesModule,
|
||||
OrgsModule,
|
||||
DictsModule,
|
||||
ConfigsModule,
|
||||
LogsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
providers: [
|
||||
AppService,
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||
{ provide: APP_INTERCEPTOR, useClass: RequestContextInterceptor },
|
||||
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
|
||||
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
27
src/auth/auth.controller.ts
Normal file
27
src/auth/auth.controller.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { LoginDto, RefreshDto } from './dto.js';
|
||||
import { Public } from './decorators.js';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.auth.login(dto);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('refresh')
|
||||
refresh(@Body() dto: RefreshDto) {
|
||||
return this.auth.refresh(dto);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('logout')
|
||||
logout(@Body() dto: RefreshDto) {
|
||||
return this.auth.logout(dto);
|
||||
}
|
||||
}
|
||||
18
src/auth/auth.module.ts
Normal file
18
src/auth/auth.module.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { AuthController } from './auth.controller.js';
|
||||
import { AuthService } from './auth.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.register({
|
||||
global: true,
|
||||
secret: process.env.JWT_SECRET ?? 'dev-secret-change-me',
|
||||
signOptions: { expiresIn: Number(process.env.JWT_ACCESS_TTL ?? 7200) },
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
115
src/auth/auth.service.ts
Normal file
115
src/auth/auth.service.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service.js';
|
||||
import { LoginDto, RefreshDto } from './dto.js';
|
||||
import { resolveDataScope, resolveVisibleOrgs } from '../common/data-scope.js';
|
||||
import type { DataScope } from '../common/enums.js';
|
||||
import type { RequestContext } from '../common/context/request-context.js';
|
||||
|
||||
const REFRESH_TTL = Number(process.env.JWT_REFRESH_TTL ?? 604800);
|
||||
const ACCESS_TTL = Number(process.env.JWT_ACCESS_TTL ?? 7200);
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
) {}
|
||||
|
||||
async login(dto: LoginDto): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
|
||||
const tenant = await this.prisma.client.tenant.findUnique({ where: { code: dto.tenantCode } });
|
||||
if (!tenant || tenant.status !== 'enabled') {
|
||||
throw new UnauthorizedException('租户不存在或已停用');
|
||||
}
|
||||
const user = await this.prisma.client.user.findFirst({
|
||||
where: { tenantCode: dto.tenantCode, username: dto.username, deletedAt: null },
|
||||
});
|
||||
if (!user) throw new UnauthorizedException('账号或密码错误');
|
||||
if (user.status !== 'enabled') throw new UnauthorizedException('账号已停用或锁定');
|
||||
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
||||
if (!ok) throw new UnauthorizedException('账号或密码错误');
|
||||
|
||||
return this.issue(dto.tenantCode, user.id, user.username);
|
||||
}
|
||||
|
||||
async refresh(dto: RefreshDto): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
|
||||
const record = await this.prisma.client.refreshToken.findUnique({ where: { jti: dto.refreshToken } });
|
||||
if (!record || record.revokedAt || record.expireAt.getTime() < Date.now()) {
|
||||
throw new UnauthorizedException('refresh token 无效或已过期');
|
||||
}
|
||||
const user = await this.prisma.client.user.findFirst({
|
||||
where: { tenantCode: record.tenantCode, id: record.userId, deletedAt: null },
|
||||
});
|
||||
if (!user || user.status !== 'enabled') throw new UnauthorizedException('账号已失效');
|
||||
|
||||
// 旋转:作废旧 refresh
|
||||
await this.prisma.client.refreshToken.update({
|
||||
where: { jti: dto.refreshToken },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return this.issue(record.tenantCode, user.id, user.username);
|
||||
}
|
||||
|
||||
async logout(dto: RefreshDto): Promise<null> {
|
||||
const record = await this.prisma.client.refreshToken.findUnique({ where: { jti: dto.refreshToken } });
|
||||
if (record) {
|
||||
await this.prisma.client.refreshToken.update({
|
||||
where: { jti: dto.refreshToken },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 由 RequestContextInterceptor 调用,构建当前请求的租户/用户/数据权限上下文。 */
|
||||
async buildContext(user: { userId: string; tenantCode: string; username: string }): Promise<RequestContext> {
|
||||
const u = await this.prisma.client.user.findFirst({
|
||||
where: { tenantCode: user.tenantCode, id: user.userId, deletedAt: null },
|
||||
select: { orgCode: true },
|
||||
});
|
||||
const orgCode = u?.orgCode ?? null;
|
||||
|
||||
const roleRows = await this.prisma.client.userRole.findMany({
|
||||
where: { tenantCode: user.tenantCode, userId: user.userId },
|
||||
select: { roleCode: true },
|
||||
});
|
||||
const roleCodes = roleRows.map((r) => r.roleCode);
|
||||
const roles = roleCodes.length
|
||||
? await this.prisma.client.role.findMany({
|
||||
where: { tenantCode: user.tenantCode, code: { in: roleCodes }, deletedAt: null },
|
||||
select: { dataScope: true },
|
||||
})
|
||||
: [];
|
||||
const dataScope = resolveDataScope(roles.map((r) => r.dataScope as DataScope));
|
||||
const orgCodes = await resolveVisibleOrgs({
|
||||
prisma: this.prisma.client,
|
||||
tenantCode: user.tenantCode,
|
||||
dataScope,
|
||||
orgCode,
|
||||
roleCodes,
|
||||
});
|
||||
|
||||
return { tenantCode: user.tenantCode, userId: user.userId, username: user.username, dataScope, orgCode, orgCodes };
|
||||
}
|
||||
|
||||
private async issue(
|
||||
tenantCode: string,
|
||||
userId: string,
|
||||
username: string,
|
||||
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
|
||||
const accessToken = this.jwt.sign({ sub: userId, tenantCode, username });
|
||||
const jti = randomUUID();
|
||||
await this.prisma.client.refreshToken.create({
|
||||
data: {
|
||||
tenantCode,
|
||||
userId,
|
||||
jti,
|
||||
tokenHash: createHash('sha256').update(jti).digest('hex'),
|
||||
expireAt: new Date(Date.now() + REFRESH_TTL * 1000),
|
||||
},
|
||||
});
|
||||
return { accessToken, refreshToken: jti, expiresIn: ACCESS_TTL };
|
||||
}
|
||||
}
|
||||
13
src/auth/decorators.ts
Normal file
13
src/auth/decorators.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { createParamDecorator, ExecutionContext, SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
/** 标记接口为公开接口(跳过登录校验)。 */
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
|
||||
export const PERMISSIONS_KEY = 'permissions';
|
||||
/** 要求当前用户具备给定权限标识(resource code)。 */
|
||||
export const RequirePermissions = (...permissions: string[]) => SetMetadata(PERMISSIONS_KEY, permissions);
|
||||
|
||||
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
|
||||
return ctx.switchToHttp().getRequest().user;
|
||||
});
|
||||
21
src/auth/dto.ts
Normal file
21
src/auth/dto.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
tenantCode!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
username!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class RefreshDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
refreshToken!: string;
|
||||
}
|
||||
44
src/auth/jwt-auth.guard.ts
Normal file
44
src/auth/jwt-auth.guard.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { IS_PUBLIC_KEY } from './decorators.js';
|
||||
|
||||
export interface AuthUser {
|
||||
userId: string;
|
||||
tenantCode: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwt: JwtService,
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractToken(request);
|
||||
if (!token) throw new UnauthorizedException('未登录');
|
||||
try {
|
||||
const payload = await this.jwt.verifyAsync<{ sub: string; tenantCode: string; username: string }>(token);
|
||||
request.user = { userId: payload.sub, tenantCode: payload.tenantCode, username: payload.username } satisfies AuthUser;
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException('token 无效或已过期');
|
||||
}
|
||||
}
|
||||
|
||||
private extractToken(request: { headers?: Record<string, string | undefined> }): string | undefined {
|
||||
const auth = request.headers?.authorization;
|
||||
if (!auth) return undefined;
|
||||
const [type, token] = auth.split(' ');
|
||||
return type === 'Bearer' && token ? token : undefined;
|
||||
}
|
||||
}
|
||||
37
src/auth/permissions.guard.ts
Normal file
37
src/auth/permissions.guard.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { PrismaService } from '../prisma/prisma.service.js';
|
||||
import { PERMISSIONS_KEY } from './decorators.js';
|
||||
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const required = this.reflector.getAllAndOverride<string[]>(PERMISSIONS_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!required || required.length === 0) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user as { userId: string; tenantCode: string } | undefined;
|
||||
if (!user) throw new ForbiddenException('无权限');
|
||||
|
||||
const roleRows = await this.prisma.client.userRole.findMany({
|
||||
where: { tenantCode: user.tenantCode, userId: user.userId },
|
||||
select: { roleCode: true },
|
||||
});
|
||||
const roleCodes = roleRows.map((r) => r.roleCode);
|
||||
if (roleCodes.length === 0) throw new ForbiddenException('无权限');
|
||||
|
||||
const count = await this.prisma.client.roleResource.count({
|
||||
where: { tenantCode: user.tenantCode, roleCode: { in: roleCodes }, resourceCode: { in: required } },
|
||||
});
|
||||
if (count === 0) throw new ForbiddenException('无权限');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
27
src/auth/request-context.interceptor.ts
Normal file
27
src/auth/request-context.interceptor.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||
import { from, mergeMap, Observable } from 'rxjs';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { runWithRequestContext } from '../common/context/request-context.js';
|
||||
|
||||
/**
|
||||
* 在请求处理前构建租户/用户/数据权限上下文,并写入 AsyncLocalStorage,
|
||||
* 使 PrismaService 的过滤与业务查询能拿到当前请求上下文。
|
||||
*/
|
||||
@Injectable()
|
||||
export class RequestContextInterceptor implements NestInterceptor {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user as { userId: string; tenantCode: string; username: string } | undefined;
|
||||
if (!user) return next.handle();
|
||||
|
||||
return from(this.auth.buildContext(user)).pipe(
|
||||
mergeMap((ctx) =>
|
||||
new Observable((subscriber) => {
|
||||
runWithRequestContext(ctx, () => next.handle().subscribe(subscriber));
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
52
src/common/base-crud.service.ts
Normal file
52
src/common/base-crud.service.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { PrismaService } from '../prisma/prisma.service.js';
|
||||
import { getRequestContext } from './context/request-context.js';
|
||||
|
||||
/**
|
||||
* 通用 CRUD 基类:封装分页/创建/更新/软删。
|
||||
* 创建时自动注入 tenantCode(Tenant 模型除外);软删由子类按需调用 softRemove。
|
||||
*/
|
||||
export class BaseCrudService {
|
||||
constructor(
|
||||
protected readonly prisma: PrismaService,
|
||||
private readonly modelName: string,
|
||||
) {}
|
||||
|
||||
protected db(): any {
|
||||
return (this.prisma.client as any)[this.modelName];
|
||||
}
|
||||
|
||||
async paginate(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
where: Record<string, unknown> = {},
|
||||
orderBy: Record<string, unknown> = { createdAt: 'desc' },
|
||||
) {
|
||||
const [list, total] = await Promise.all([
|
||||
this.db().findMany({ where, skip: (page - 1) * pageSize, take: pageSize, orderBy }),
|
||||
this.db().count({ where }),
|
||||
]);
|
||||
return { list, total, page, pageSize };
|
||||
}
|
||||
|
||||
async list(where: Record<string, unknown> = {}, orderBy?: Record<string, unknown>) {
|
||||
return this.db().findMany({ where, orderBy });
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
return this.db().findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async create(data: Record<string, unknown>) {
|
||||
const ctx = getRequestContext();
|
||||
if (ctx && this.modelName !== 'Tenant') data.tenantCode = ctx.tenantCode;
|
||||
return this.db().create({ data });
|
||||
}
|
||||
|
||||
async update(id: string, data: Record<string, unknown>) {
|
||||
return this.db().update({ where: { id }, data });
|
||||
}
|
||||
|
||||
async softRemove(id: string) {
|
||||
return this.db().update({ where: { id }, data: { deletedAt: new Date() } });
|
||||
}
|
||||
}
|
||||
23
src/common/context/request-context.ts
Normal file
23
src/common/context/request-context.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import type { DataScope } from '../enums.js';
|
||||
|
||||
/** 每个请求的租户/用户/数据权限上下文,由 RequestContextInterceptor 写入。 */
|
||||
export interface RequestContext {
|
||||
tenantCode: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
dataScope: DataScope;
|
||||
orgCode: string | null;
|
||||
/** 数据权限可见的部门 code 集合;空数组表示「全部可见」 */
|
||||
orgCodes: string[];
|
||||
}
|
||||
|
||||
const storage = new AsyncLocalStorage<RequestContext>();
|
||||
|
||||
export function getRequestContext(): RequestContext | undefined {
|
||||
return storage.getStore();
|
||||
}
|
||||
|
||||
export function runWithRequestContext<T>(ctx: RequestContext, fn: () => T): T {
|
||||
return storage.run(ctx, fn);
|
||||
}
|
||||
63
src/common/data-scope.ts
Normal file
63
src/common/data-scope.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import { DataScope } from './enums.js';
|
||||
|
||||
/** 数据权限档位(从大到小)的优先级,用于多角色取最高权限。 */
|
||||
const RANK: Record<DataScope, number> = {
|
||||
all: 5,
|
||||
dept_and_child: 4,
|
||||
dept: 3,
|
||||
custom: 2,
|
||||
self: 1,
|
||||
};
|
||||
|
||||
/** 从多个角色的 data_scope 中取最高权限档。 */
|
||||
export function resolveDataScope(scopes: DataScope[]): DataScope {
|
||||
if (scopes.length === 0) return 'self';
|
||||
return scopes.reduce((a, b) => (RANK[a] >= RANK[b] ? a : b));
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算数据权限可见的部门 code 集合。
|
||||
* 返回空数组表示「全部可见」(不受数据权限限制)。
|
||||
*/
|
||||
export async function resolveVisibleOrgs(opts: {
|
||||
prisma: PrismaClient;
|
||||
tenantCode: string;
|
||||
dataScope: DataScope;
|
||||
orgCode: string | null;
|
||||
roleCodes: string[];
|
||||
}): Promise<string[]> {
|
||||
const { prisma, tenantCode, dataScope, orgCode, roleCodes } = opts;
|
||||
switch (dataScope) {
|
||||
case 'all':
|
||||
return [];
|
||||
case 'self':
|
||||
case 'dept':
|
||||
return orgCode ? [orgCode] : ['__none__'];
|
||||
case 'dept_and_child':
|
||||
return orgCode ? collectDescendants(prisma, tenantCode, orgCode) : ['__none__'];
|
||||
case 'custom': {
|
||||
const rows = await prisma.roleOrg.findMany({
|
||||
where: { tenantCode, roleCode: { in: roleCodes } },
|
||||
select: { orgCode: true },
|
||||
});
|
||||
const codes: string[] = [...new Set(rows.map((r) => r.orgCode))];
|
||||
return codes.length ? codes : ['__none__'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 收集某部门及其全部后代部门的 code(含自身)。 */
|
||||
async function collectDescendants(prisma: PrismaClient, tenantCode: string, rootCode: string): Promise<string[]> {
|
||||
const result: string[] = [];
|
||||
let frontier = [rootCode];
|
||||
while (frontier.length > 0) {
|
||||
result.push(...frontier);
|
||||
const children = await prisma.org.findMany({
|
||||
where: { tenantCode, parentCode: { in: frontier }, deletedAt: null },
|
||||
select: { code: true },
|
||||
});
|
||||
frontier = children.map((c) => c.code);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
39
src/common/enums.ts
Normal file
39
src/common/enums.ts
Normal file
@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 应用层枚举(VARCHAR 存储 + TS 常量校验)。
|
||||
* 取值与数据库列存储值一一对应,作为全项目唯一枚举源。
|
||||
*/
|
||||
|
||||
export const CommonStatus = {
|
||||
ENABLED: 'enabled',
|
||||
DISABLED: 'disabled',
|
||||
} as const;
|
||||
export type CommonStatus = (typeof CommonStatus)[keyof typeof CommonStatus];
|
||||
|
||||
export const UserStatus = {
|
||||
ENABLED: 'enabled',
|
||||
DISABLED: 'disabled',
|
||||
LOCKED: 'locked',
|
||||
} as const;
|
||||
export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus];
|
||||
|
||||
export const ResourceType = {
|
||||
MENU: 'menu',
|
||||
BUTTON: 'button',
|
||||
API: 'api',
|
||||
} as const;
|
||||
export type ResourceType = (typeof ResourceType)[keyof typeof ResourceType];
|
||||
|
||||
export const DataScope = {
|
||||
ALL: 'all',
|
||||
DEPT: 'dept',
|
||||
DEPT_AND_CHILD: 'dept_and_child',
|
||||
SELF: 'self',
|
||||
CUSTOM: 'custom',
|
||||
} as const;
|
||||
export type DataScope = (typeof DataScope)[keyof typeof DataScope];
|
||||
|
||||
export const LoginStatus = {
|
||||
SUCCESS: 'success',
|
||||
FAIL: 'fail',
|
||||
} as const;
|
||||
export type LoginStatus = (typeof LoginStatus)[keyof typeof LoginStatus];
|
||||
28
src/common/filters/http-exception.filter.ts
Normal file
28
src/common/filters/http-exception.filter.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
|
||||
/**
|
||||
* 全局异常过滤器:错误码分段与 HTTP 状态对齐(400xx/401xx/403xx/404xx/409xx/500xx)。
|
||||
*/
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
const body = exception instanceof HttpException ? exception.getResponse() : null;
|
||||
const message =
|
||||
typeof body === 'string'
|
||||
? body
|
||||
: Array.isArray((body as { message?: unknown })?.message)
|
||||
? ((body as { message: string[] }).message.join('; '))
|
||||
: ((body as { message?: string })?.message ?? (exception as Error).message ?? 'Internal server error');
|
||||
|
||||
response.status(status).json({
|
||||
code: status * 100,
|
||||
message,
|
||||
data: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
13
src/common/interceptors/transform.interceptor.ts
Normal file
13
src/common/interceptors/transform.interceptor.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
/**
|
||||
* 统一响应封装:{ code, message, data },成功 code = 0。
|
||||
*/
|
||||
@Injectable()
|
||||
export class TransformInterceptor<T> implements NestInterceptor<T, unknown> {
|
||||
intercept(_context: ExecutionContext, next: CallHandler<T>): Observable<unknown> {
|
||||
return next.handle().pipe(map((data) => ({ code: 0, message: 'ok', data: data ?? null })));
|
||||
}
|
||||
}
|
||||
21
src/common/page.dto.ts
Normal file
21
src/common/page.dto.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, Min } from 'class-validator';
|
||||
|
||||
export class PageQuery {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize = 20;
|
||||
}
|
||||
|
||||
export class IdParam {
|
||||
@IsOptional()
|
||||
id!: string;
|
||||
}
|
||||
34
src/configs/configs.controller.ts
Normal file
34
src/configs/configs.controller.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { PageQuery } from '../common/page.dto.js';
|
||||
import { CreateConfigDto, UpdateConfigDto } from './dto.js';
|
||||
import { ConfigsService } from './configs.service.js';
|
||||
|
||||
@Controller('configs')
|
||||
export class ConfigsController {
|
||||
constructor(private readonly service: ConfigsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() q: PageQuery) {
|
||||
return this.service.paginate(q.page, q.pageSize);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@Param('id') id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateConfigDto) {
|
||||
return this.service.createConfig(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateConfigDto) {
|
||||
return this.service.updateConfig(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.softRemove(id);
|
||||
}
|
||||
}
|
||||
9
src/configs/configs.module.ts
Normal file
9
src/configs/configs.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigsController } from './configs.controller.js';
|
||||
import { ConfigsService } from './configs.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [ConfigsController],
|
||||
providers: [ConfigsService],
|
||||
})
|
||||
export class ConfigsModule {}
|
||||
19
src/configs/configs.service.ts
Normal file
19
src/configs/configs.service.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BaseCrudService } from '../common/base-crud.service.js';
|
||||
import { PrismaService } from '../prisma/prisma.service.js';
|
||||
import { CreateConfigDto, UpdateConfigDto } from './dto.js';
|
||||
|
||||
@Injectable()
|
||||
export class ConfigsService extends BaseCrudService {
|
||||
constructor(prisma: PrismaService) {
|
||||
super(prisma, 'config');
|
||||
}
|
||||
|
||||
createConfig(dto: CreateConfigDto) {
|
||||
return this.create({ ...dto });
|
||||
}
|
||||
|
||||
updateConfig(id: string, dto: UpdateConfigDto) {
|
||||
return this.update(id, { ...dto });
|
||||
}
|
||||
}
|
||||
12
src/configs/dto.ts
Normal file
12
src/configs/dto.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateConfigDto {
|
||||
@IsString() @IsNotEmpty() key!: string;
|
||||
@IsOptional() @IsString() value?: string;
|
||||
@IsOptional() @IsString() remark?: string;
|
||||
}
|
||||
|
||||
export class UpdateConfigDto {
|
||||
@IsOptional() @IsString() value?: string;
|
||||
@IsOptional() @IsString() remark?: string;
|
||||
}
|
||||
33
src/dicts/dict-items.controller.ts
Normal file
33
src/dicts/dict-items.controller.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { CreateDictItemDto, UpdateDictItemDto } from './dto.js';
|
||||
import { DictItemsService } from './dict-items.service.js';
|
||||
|
||||
@Controller('dict-items')
|
||||
export class DictItemsController {
|
||||
constructor(private readonly service: DictItemsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query('dictTypeCode') dictTypeCode?: string) {
|
||||
return this.service.listByType(dictTypeCode);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@Param('id') id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateDictItemDto) {
|
||||
return this.service.createItem(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateDictItemDto) {
|
||||
return this.service.updateItem(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.softRemove(id);
|
||||
}
|
||||
}
|
||||
23
src/dicts/dict-items.service.ts
Normal file
23
src/dicts/dict-items.service.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BaseCrudService } from '../common/base-crud.service.js';
|
||||
import { PrismaService } from '../prisma/prisma.service.js';
|
||||
import { CreateDictItemDto, UpdateDictItemDto } from './dto.js';
|
||||
|
||||
@Injectable()
|
||||
export class DictItemsService extends BaseCrudService {
|
||||
constructor(prisma: PrismaService) {
|
||||
super(prisma, 'dictItem');
|
||||
}
|
||||
|
||||
createItem(dto: CreateDictItemDto) {
|
||||
return this.create({ ...dto });
|
||||
}
|
||||
|
||||
updateItem(id: string, dto: UpdateDictItemDto) {
|
||||
return this.update(id, { ...dto });
|
||||
}
|
||||
|
||||
listByType(dictTypeCode?: string) {
|
||||
return this.list(dictTypeCode ? { dictTypeCode } : {}, { sort: 'asc' });
|
||||
}
|
||||
}
|
||||
34
src/dicts/dict-types.controller.ts
Normal file
34
src/dicts/dict-types.controller.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { PageQuery } from '../common/page.dto.js';
|
||||
import { CreateDictTypeDto, UpdateDictTypeDto } from './dto.js';
|
||||
import { DictTypesService } from './dict-types.service.js';
|
||||
|
||||
@Controller('dict-types')
|
||||
export class DictTypesController {
|
||||
constructor(private readonly service: DictTypesService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() q: PageQuery) {
|
||||
return this.service.paginate(q.page, q.pageSize);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@Param('id') id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateDictTypeDto) {
|
||||
return this.service.createType(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateDictTypeDto) {
|
||||
return this.service.updateType(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.softRemove(id);
|
||||
}
|
||||
}
|
||||
19
src/dicts/dict-types.service.ts
Normal file
19
src/dicts/dict-types.service.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BaseCrudService } from '../common/base-crud.service.js';
|
||||
import { PrismaService } from '../prisma/prisma.service.js';
|
||||
import { CreateDictTypeDto, UpdateDictTypeDto } from './dto.js';
|
||||
|
||||
@Injectable()
|
||||
export class DictTypesService extends BaseCrudService {
|
||||
constructor(prisma: PrismaService) {
|
||||
super(prisma, 'dictType');
|
||||
}
|
||||
|
||||
createType(dto: CreateDictTypeDto) {
|
||||
return this.create({ ...dto });
|
||||
}
|
||||
|
||||
updateType(id: string, dto: UpdateDictTypeDto) {
|
||||
return this.update(id, { ...dto });
|
||||
}
|
||||
}
|
||||
11
src/dicts/dicts.module.ts
Normal file
11
src/dicts/dicts.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DictTypesController } from './dict-types.controller.js';
|
||||
import { DictTypesService } from './dict-types.service.js';
|
||||
import { DictItemsController } from './dict-items.controller.js';
|
||||
import { DictItemsService } from './dict-items.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [DictTypesController, DictItemsController],
|
||||
providers: [DictTypesService, DictItemsService],
|
||||
})
|
||||
export class DictsModule {}
|
||||
27
src/dicts/dto.ts
Normal file
27
src/dicts/dto.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateDictTypeDto {
|
||||
@IsString() @IsNotEmpty() code!: string;
|
||||
@IsString() @IsNotEmpty() name!: string;
|
||||
@IsOptional() @IsString() remark?: string;
|
||||
}
|
||||
|
||||
export class UpdateDictTypeDto {
|
||||
@IsOptional() @IsString() name?: string;
|
||||
@IsOptional() @IsString() status?: string;
|
||||
@IsOptional() @IsString() remark?: string;
|
||||
}
|
||||
|
||||
export class CreateDictItemDto {
|
||||
@IsString() @IsNotEmpty() dictTypeCode!: string;
|
||||
@IsString() @IsNotEmpty() label!: string;
|
||||
@IsString() @IsNotEmpty() value!: string;
|
||||
@IsOptional() @IsInt() sort?: number;
|
||||
}
|
||||
|
||||
export class UpdateDictItemDto {
|
||||
@IsOptional() @IsString() label?: string;
|
||||
@IsOptional() @IsString() value?: string;
|
||||
@IsOptional() @IsInt() sort?: number;
|
||||
@IsOptional() @IsString() status?: string;
|
||||
}
|
||||
@ -1,8 +1,13 @@
|
||||
import 'dotenv/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module.js';
|
||||
|
||||
async function bootstrap() {
|
||||
async function bootstrap(): Promise<void> {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.enableCors();
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
await bootstrap();
|
||||
|
||||
17
src/orgs/dto.ts
Normal file
17
src/orgs/dto.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateOrgDto {
|
||||
@IsString() @IsNotEmpty() code!: string;
|
||||
@IsString() @IsNotEmpty() name!: string;
|
||||
@IsOptional() @IsString() parentCode?: string;
|
||||
@IsOptional() @IsString() leader?: string;
|
||||
@IsOptional() @IsInt() sort?: number;
|
||||
}
|
||||
|
||||
export class UpdateOrgDto {
|
||||
@IsOptional() @IsString() name?: string;
|
||||
@IsOptional() @IsString() parentCode?: string;
|
||||
@IsOptional() @IsString() leader?: string;
|
||||
@IsOptional() @IsInt() sort?: number;
|
||||
@IsOptional() @IsString() status?: string;
|
||||
}
|
||||
33
src/orgs/orgs.controller.ts
Normal file
33
src/orgs/orgs.controller.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { CreateOrgDto, UpdateOrgDto } from './dto.js';
|
||||
import { OrgsService } from './orgs.service.js';
|
||||
|
||||
@Controller('orgs')
|
||||
export class OrgsController {
|
||||
constructor(private readonly service: OrgsService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.service.list({}, { sort: 'asc' });
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@Param('id') id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateOrgDto) {
|
||||
return this.service.createOrg(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateOrgDto) {
|
||||
return this.service.updateOrg(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.softRemove(id);
|
||||
}
|
||||
}
|
||||
9
src/orgs/orgs.module.ts
Normal file
9
src/orgs/orgs.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OrgsController } from './orgs.controller.js';
|
||||
import { OrgsService } from './orgs.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [OrgsController],
|
||||
providers: [OrgsService],
|
||||
})
|
||||
export class OrgsModule {}
|
||||
19
src/orgs/orgs.service.ts
Normal file
19
src/orgs/orgs.service.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BaseCrudService } from '../common/base-crud.service.js';
|
||||
import { PrismaService } from '../prisma/prisma.service.js';
|
||||
import { CreateOrgDto, UpdateOrgDto } from './dto.js';
|
||||
|
||||
@Injectable()
|
||||
export class OrgsService extends BaseCrudService {
|
||||
constructor(prisma: PrismaService) {
|
||||
super(prisma, 'org');
|
||||
}
|
||||
|
||||
createOrg(dto: CreateOrgDto) {
|
||||
return this.create({ ...dto });
|
||||
}
|
||||
|
||||
updateOrg(id: string, dto: UpdateOrgDto) {
|
||||
return this.update(id, { ...dto });
|
||||
}
|
||||
}
|
||||
9
src/prisma/prisma.module.ts
Normal file
9
src/prisma/prisma.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
47
src/prisma/prisma.service.ts
Normal file
47
src/prisma/prisma.service.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { getRequestContext } from '../common/context/request-context.js';
|
||||
|
||||
/** 不带 tenantCode 的模型(租户表自身)。 */
|
||||
const TENANT_FREE = new Set(['Tenant']);
|
||||
/** 软删模型:读操作统一注入 deletedAt IS NULL。 */
|
||||
const SOFT_DELETE = new Set(['User', 'Role', 'Resource', 'Org', 'DictType', 'DictItem', 'Config']);
|
||||
const READ_OPS = new Set(['findMany', 'findFirst', 'findUnique', 'count', 'aggregate', 'groupBy']);
|
||||
/** 没有 where 子句的操作(create 的租户由 service 在 data 里显式写入)。 */
|
||||
const NO_WHERE_OPS = new Set(['create', 'createMany']);
|
||||
|
||||
/**
|
||||
* Prisma 客户端封装:统一注入多租户过滤(tenantCode)与软删过滤(deletedAt)。
|
||||
* 通过 `this.prisma.client` 访问扩展后的客户端。
|
||||
*/
|
||||
@Injectable()
|
||||
export class PrismaService implements OnModuleInit, OnModuleDestroy {
|
||||
readonly client: PrismaClient;
|
||||
|
||||
constructor() {
|
||||
this.client = new PrismaClient().$extends({
|
||||
query: {
|
||||
$allModels: {
|
||||
async $allOperations({ model, operation, args, query }: any) {
|
||||
const ctx = getRequestContext();
|
||||
if (ctx && !TENANT_FREE.has(model) && !NO_WHERE_OPS.has(operation)) {
|
||||
args.where = { ...(args.where ?? {}), tenantCode: ctx.tenantCode };
|
||||
}
|
||||
if (SOFT_DELETE.has(model) && READ_OPS.has(operation)) {
|
||||
args.where = { ...(args.where ?? {}), deletedAt: null };
|
||||
}
|
||||
return query(args);
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.client.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.client.$disconnect();
|
||||
}
|
||||
}
|
||||
26
src/resources/dto.ts
Normal file
26
src/resources/dto.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateResourceDto {
|
||||
@IsString() @IsNotEmpty() type!: string;
|
||||
@IsString() @IsNotEmpty() code!: string;
|
||||
@IsString() @IsNotEmpty() name!: string;
|
||||
@IsOptional() @IsString() parentCode?: string;
|
||||
@IsOptional() @IsString() path?: string;
|
||||
@IsOptional() @IsString() component?: string;
|
||||
@IsOptional() @IsString() method?: string;
|
||||
@IsOptional() @IsString() apiPath?: string;
|
||||
@IsOptional() @IsString() icon?: string;
|
||||
@IsOptional() @IsInt() sort?: number;
|
||||
}
|
||||
|
||||
export class UpdateResourceDto {
|
||||
@IsOptional() @IsString() name?: string;
|
||||
@IsOptional() @IsString() parentCode?: string;
|
||||
@IsOptional() @IsString() path?: string;
|
||||
@IsOptional() @IsString() component?: string;
|
||||
@IsOptional() @IsString() method?: string;
|
||||
@IsOptional() @IsString() apiPath?: string;
|
||||
@IsOptional() @IsString() icon?: string;
|
||||
@IsOptional() @IsInt() sort?: number;
|
||||
@IsOptional() @IsString() status?: string;
|
||||
}
|
||||
33
src/resources/resources.controller.ts
Normal file
33
src/resources/resources.controller.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { CreateResourceDto, UpdateResourceDto } from './dto.js';
|
||||
import { ResourcesService } from './resources.service.js';
|
||||
|
||||
@Controller('resources')
|
||||
export class ResourcesController {
|
||||
constructor(private readonly service: ResourcesService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.service.list({}, { sort: 'asc' });
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@Param('id') id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateResourceDto) {
|
||||
return this.service.createResource(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateResourceDto) {
|
||||
return this.service.updateResource(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.softRemove(id);
|
||||
}
|
||||
}
|
||||
9
src/resources/resources.module.ts
Normal file
9
src/resources/resources.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ResourcesController } from './resources.controller.js';
|
||||
import { ResourcesService } from './resources.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [ResourcesController],
|
||||
providers: [ResourcesService],
|
||||
})
|
||||
export class ResourcesModule {}
|
||||
19
src/resources/resources.service.ts
Normal file
19
src/resources/resources.service.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BaseCrudService } from '../common/base-crud.service.js';
|
||||
import { PrismaService } from '../prisma/prisma.service.js';
|
||||
import { CreateResourceDto, UpdateResourceDto } from './dto.js';
|
||||
|
||||
@Injectable()
|
||||
export class ResourcesService extends BaseCrudService {
|
||||
constructor(prisma: PrismaService) {
|
||||
super(prisma, 'resource');
|
||||
}
|
||||
|
||||
createResource(dto: CreateResourceDto) {
|
||||
return this.create({ ...dto });
|
||||
}
|
||||
|
||||
updateResource(id: string, dto: UpdateResourceDto) {
|
||||
return this.update(id, { ...dto });
|
||||
}
|
||||
}
|
||||
19
src/roles/dto.ts
Normal file
19
src/roles/dto.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateRoleDto {
|
||||
@IsString() @IsNotEmpty() code!: string;
|
||||
@IsString() @IsNotEmpty() name!: string;
|
||||
@IsOptional() @IsString() dataScope?: string;
|
||||
@IsOptional() @IsString() remark?: string;
|
||||
}
|
||||
|
||||
export class UpdateRoleDto {
|
||||
@IsOptional() @IsString() name?: string;
|
||||
@IsOptional() @IsString() dataScope?: string;
|
||||
@IsOptional() @IsString() status?: string;
|
||||
@IsOptional() @IsString() remark?: string;
|
||||
}
|
||||
|
||||
export class AssignResourcesDto {
|
||||
@IsArray() @IsString({ each: true }) resourceCodes!: string[];
|
||||
}
|
||||
44
src/roles/roles.controller.ts
Normal file
44
src/roles/roles.controller.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
||||
import { PageQuery } from '../common/page.dto.js';
|
||||
import { AssignResourcesDto, CreateRoleDto, UpdateRoleDto } from './dto.js';
|
||||
import { RolesService } from './roles.service.js';
|
||||
|
||||
@Controller('roles')
|
||||
export class RolesController {
|
||||
constructor(private readonly service: RolesService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() q: PageQuery) {
|
||||
return this.service.paginate(q.page, q.pageSize);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@Param('id') id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateRoleDto) {
|
||||
return this.service.createRole(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateRoleDto) {
|
||||
return this.service.updateRole(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.softRemove(id);
|
||||
}
|
||||
|
||||
@Get(':id/resources')
|
||||
resources(@Param('id') id: string) {
|
||||
return this.service.assignedResources(id);
|
||||
}
|
||||
|
||||
@Put(':id/resources')
|
||||
assign(@Param('id') id: string, @Body() dto: AssignResourcesDto) {
|
||||
return this.service.assignResources(id, dto);
|
||||
}
|
||||
}
|
||||
9
src/roles/roles.module.ts
Normal file
9
src/roles/roles.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RolesController } from './roles.controller.js';
|
||||
import { RolesService } from './roles.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [RolesController],
|
||||
providers: [RolesService],
|
||||
})
|
||||
export class RolesModule {}
|
||||
40
src/roles/roles.service.ts
Normal file
40
src/roles/roles.service.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BaseCrudService } from '../common/base-crud.service.js';
|
||||
import { PrismaService } from '../prisma/prisma.service.js';
|
||||
import { getRequestContext } from '../common/context/request-context.js';
|
||||
import { AssignResourcesDto, CreateRoleDto, UpdateRoleDto } from './dto.js';
|
||||
|
||||
@Injectable()
|
||||
export class RolesService extends BaseCrudService {
|
||||
constructor(prisma: PrismaService) {
|
||||
super(prisma, 'role');
|
||||
}
|
||||
|
||||
createRole(dto: CreateRoleDto) {
|
||||
return this.create({ ...dto });
|
||||
}
|
||||
|
||||
updateRole(id: string, dto: UpdateRoleDto) {
|
||||
return this.update(id, { ...dto });
|
||||
}
|
||||
|
||||
async assignedResources(roleCode: string) {
|
||||
const ctx = getRequestContext()!;
|
||||
const rows = await this.prisma.client.roleResource.findMany({
|
||||
where: { tenantCode: ctx.tenantCode, roleCode },
|
||||
select: { resourceCode: true },
|
||||
});
|
||||
return rows.map((r) => r.resourceCode);
|
||||
}
|
||||
|
||||
async assignResources(roleCode: string, dto: AssignResourcesDto) {
|
||||
const ctx = getRequestContext()!;
|
||||
await this.prisma.client.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
|
||||
if (dto.resourceCodes.length > 0) {
|
||||
await this.prisma.client.roleResource.createMany({
|
||||
data: dto.resourceCodes.map((resourceCode) => ({ tenantCode: ctx.tenantCode, roleCode, resourceCode })),
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
14
src/tenants/dto.ts
Normal file
14
src/tenants/dto.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateTenantDto {
|
||||
@IsString() @IsNotEmpty() code!: string;
|
||||
@IsString() @IsNotEmpty() name!: string;
|
||||
@IsOptional() @IsString() status?: string;
|
||||
@IsOptional() @IsString() remark?: string;
|
||||
}
|
||||
|
||||
export class UpdateTenantDto {
|
||||
@IsOptional() @IsString() name?: string;
|
||||
@IsOptional() @IsString() status?: string;
|
||||
@IsOptional() @IsString() remark?: string;
|
||||
}
|
||||
34
src/tenants/tenants.controller.ts
Normal file
34
src/tenants/tenants.controller.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { PageQuery } from '../common/page.dto.js';
|
||||
import { CreateTenantDto, UpdateTenantDto } from './dto.js';
|
||||
import { TenantsService } from './tenants.service.js';
|
||||
|
||||
@Controller('tenants')
|
||||
export class TenantsController {
|
||||
constructor(private readonly service: TenantsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() q: PageQuery) {
|
||||
return this.service.paginate(q.page, q.pageSize);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@Param('id') id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateTenantDto) {
|
||||
return this.service.createTenant(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateTenantDto) {
|
||||
return this.service.updateTenant(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.removeTenant(id);
|
||||
}
|
||||
}
|
||||
9
src/tenants/tenants.module.ts
Normal file
9
src/tenants/tenants.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TenantsController } from './tenants.controller.js';
|
||||
import { TenantsService } from './tenants.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [TenantsController],
|
||||
providers: [TenantsService],
|
||||
})
|
||||
export class TenantsModule {}
|
||||
27
src/tenants/tenants.service.ts
Normal file
27
src/tenants/tenants.service.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BaseCrudService } from '../common/base-crud.service.js';
|
||||
import { PrismaService } from '../prisma/prisma.service.js';
|
||||
import { CreateTenantDto, UpdateTenantDto } from './dto.js';
|
||||
|
||||
@Injectable()
|
||||
export class TenantsService extends BaseCrudService {
|
||||
constructor(prisma: PrismaService) {
|
||||
super(prisma, 'tenant');
|
||||
}
|
||||
|
||||
async createTenant(dto: CreateTenantDto) {
|
||||
return this.create({ ...dto });
|
||||
}
|
||||
|
||||
async updateTenant(id: string, dto: UpdateTenantDto) {
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) throw new NotFoundException('租户不存在');
|
||||
return this.update(id, { ...dto });
|
||||
}
|
||||
|
||||
async removeTenant(id: string) {
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) throw new NotFoundException('租户不存在');
|
||||
return this.db().delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
24
src/users/dto.ts
Normal file
24
src/users/dto.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsString() @IsNotEmpty() username!: string;
|
||||
@IsString() @IsNotEmpty() password!: string;
|
||||
@IsOptional() @IsString() nickname?: string;
|
||||
@IsOptional() @IsString() email?: string;
|
||||
@IsOptional() @IsString() phone?: string;
|
||||
@IsOptional() @IsString() avatar?: string;
|
||||
@IsOptional() @IsString() orgCode?: string;
|
||||
}
|
||||
|
||||
export class UpdateUserDto {
|
||||
@IsOptional() @IsString() nickname?: string;
|
||||
@IsOptional() @IsString() email?: string;
|
||||
@IsOptional() @IsString() phone?: string;
|
||||
@IsOptional() @IsString() avatar?: string;
|
||||
@IsOptional() @IsString() orgCode?: string;
|
||||
@IsOptional() @IsString() status?: string;
|
||||
}
|
||||
|
||||
export class ResetPasswordDto {
|
||||
@IsString() @IsNotEmpty() password!: string;
|
||||
}
|
||||
39
src/users/users.controller.ts
Normal file
39
src/users/users.controller.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { PageQuery } from '../common/page.dto.js';
|
||||
import { CreateUserDto, ResetPasswordDto, UpdateUserDto } from './dto.js';
|
||||
import { UsersService } from './users.service.js';
|
||||
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(private readonly service: UsersService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() q: PageQuery) {
|
||||
return this.service.listUsers(q);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@Param('id') id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateUserDto) {
|
||||
return this.service.createUser(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateUserDto) {
|
||||
return this.service.updateUser(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/password')
|
||||
resetPassword(@Param('id') id: string, @Body() dto: ResetPasswordDto) {
|
||||
return this.service.resetPassword(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.softRemove(id);
|
||||
}
|
||||
}
|
||||
9
src/users/users.module.ts
Normal file
9
src/users/users.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersController } from './users.controller.js';
|
||||
import { UsersService } from './users.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
39
src/users/users.service.ts
Normal file
39
src/users/users.service.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { BaseCrudService } from '../common/base-crud.service.js';
|
||||
import { PrismaService } from '../prisma/prisma.service.js';
|
||||
import { getRequestContext } from '../common/context/request-context.js';
|
||||
import { CreateUserDto, ResetPasswordDto, UpdateUserDto } from './dto.js';
|
||||
import { PageQuery } from '../common/page.dto.js';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService extends BaseCrudService {
|
||||
constructor(prisma: PrismaService) {
|
||||
super(prisma, 'user');
|
||||
}
|
||||
|
||||
async createUser(dto: CreateUserDto) {
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
const { password: _p, ...rest } = dto;
|
||||
return this.create({ ...rest, passwordHash });
|
||||
}
|
||||
|
||||
async listUsers(q: PageQuery) {
|
||||
const ctx = getRequestContext();
|
||||
const where: Record<string, unknown> = {};
|
||||
// 数据权限:orgCodes 为空表示「全部可见」,否则限制在可见部门集合
|
||||
if (ctx && ctx.orgCodes.length > 0) {
|
||||
where.orgCode = { in: ctx.orgCodes };
|
||||
}
|
||||
return this.paginate(q.page, q.pageSize, where);
|
||||
}
|
||||
|
||||
async updateUser(id: string, dto: UpdateUserDto) {
|
||||
return this.update(id, { ...dto });
|
||||
}
|
||||
|
||||
async resetPassword(id: string, dto: ResetPasswordDto) {
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
return this.update(id, { passwordHash });
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user