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:
liuyun 2026-10-10 21:13:35 +08:00
parent dc40ca3343
commit d8d3055c73
55 changed files with 2764 additions and 29 deletions

View File

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

File diff suppressed because it is too large Load Diff

5
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,5 @@
allowBuilds:
'@prisma/client': true
'@prisma/engines': true
esbuild: true
prisma: true

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

View 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
View 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")
}

View File

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

View 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
View 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
View 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
View 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
View 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;
}

View 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;
}
}

View 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;
}
}

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

View 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() } });
}
}

View 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
View 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
View 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];

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

View 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
View 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;
}

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

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

View 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
View 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;
}

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

View 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' });
}
}

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

View 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
View 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
View 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;
}

View File

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

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

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

View 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
View 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;
}

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

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

View 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
View 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[];
}

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

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

View 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
View 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;
}

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

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

View 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
View 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;
}

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

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

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