feat: enforce RBAC, audit/login logging, and seed script

收口 Phase 0 三处「声明未生效」:
- 三级鉴权落地:权限集存 Redis(key 前缀隔离多项目),@RequirePermissions 挂到全部受控接口,super_admin 角色绕过权限码与 data_scope
- 审计/登录日志自动落库:@AuditLog 装饰器 + 拦截器异步写入(写操作+登录登出),登录成功/失败均记 login_log(脱敏)
- seed 脚本:一键初始化租户 touyun/用户 touyun/超管 super_admin/全量资源树(48 资源全量授权)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-10 23:19:41 +08:00
parent c680fb9dc7
commit 7340658305
31 changed files with 814 additions and 76 deletions

25
.env.example Normal file
View File

@ -0,0 +1,25 @@
# 复制为 .env 并按需修改
# 数据库(MySQL 8)
DATABASE_URL="mysql://root:Mysql.123@127.0.0.1:3306/sysmgr"
# 服务端口
PORT=3000
# JWT(生产务必替换 secret)
JWT_SECRET="dev-secret-change-me"
JWT_ACCESS_TTL="7200"
JWT_REFRESH_TTL="604800"
# 日志(详见 src/logging/logger.config.ts)
LOG_LEVEL="info"
LOG_PRETTY="true"
LOG_FILE_ENABLED="false"
LOG_FILE_PATH="logs/app.log"
# Redis(权限缓存;多 fork 项目共用一个实例时用 REDIS_KEY_PREFIX 隔离)
REDIS_HOST="127.0.0.1"
REDIS_PORT="6379"
REDIS_DB="0"
REDIS_PASSWORD=""
REDIS_KEY_PREFIX="sysmgr:"

View File

@ -7,6 +7,9 @@
"private": true,
"license": "UNLICENSED",
"type": "module",
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"engines": {
"node": "^22.23.1"
},
@ -23,7 +26,8 @@
"test:watch": "vitest",
"test:cov": "vitest run --coverage",
"test:debug": "vitest --inspect-brk --no-file-parallelism",
"test:e2e": "vitest run --config ./vitest.config.e2e.ts"
"test:e2e": "vitest run --config ./vitest.config.e2e.ts",
"seed": "prisma db seed"
},
"dependencies": {
"@nestjs/common": "^12.0.1",
@ -35,6 +39,7 @@
"bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"ioredis": "^6.0.0",
"nestjs-pino": "^5.3.1",
"pino": "^10.4.0",
"pino-pretty": "^13.2.0",
@ -59,6 +64,7 @@
"prisma": "^6.19.3",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"tsx": "^4.23.15",
"typescript": "^6.0.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.1.2"

84
pnpm-lock.yaml generated
View File

@ -193,6 +193,9 @@ importers:
class-validator:
specifier: ^0.15.1
version: 0.15.1
ioredis:
specifier: ^6.0.0
version: 6.0.0(supports-color@7.2.0)
nestjs-pino:
specifier: ^5.3.1
version: 5.3.1(@nestjs/common@12.1.2(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.1.2)(pino-http@11.0.0)(pino@10.4.0)(rxjs@7.8.2)
@ -260,15 +263,18 @@ importers:
supertest:
specifier: ^7.0.0
version: 7.3.1(supports-color@7.2.0)
tsx:
specifier: ^4.23.15
version: 4.23.15
typescript:
specifier: ^6.0.2
version: 6.0.3
vite-tsconfig-paths:
specifier: ^5.1.4
version: 5.1.4(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))
version: 5.1.4(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
vitest:
specifier: ^4.1.2
version: 4.1.11(@types/node@24.19.1)(@vitest/coverage-v8@4.1.11)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))
version: 4.1.11(@types/node@24.19.1)(@vitest/coverage-v8@4.1.11)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
packages:
@ -608,6 +614,9 @@ packages:
'@types/node':
optional: true
'@ioredis/commands@2.0.0':
resolution: {integrity: sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
@ -1363,6 +1372,10 @@ packages:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'}
cluster-key-slot@1.1.1:
resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==}
engines: {node: '>=0.10.0'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@ -1461,6 +1474,10 @@ packages:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
denque@2.1.0:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'}
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
@ -1716,6 +1733,10 @@ packages:
resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==}
engines: {node: '>=12.0.0'}
ioredis@6.0.0:
resolution: {integrity: sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==}
engines: {node: '>=20.0.0'}
ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
@ -2201,6 +2222,10 @@ packages:
real-require@1.0.0:
resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==}
redis-errors@1.2.0:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'}
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
@ -2319,6 +2344,9 @@ packages:
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
@ -2431,6 +2459,11 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tsx@4.23.15:
resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==}
engines: {node: '>=18.0.0'}
hasBin: true
type-fest@0.21.3:
resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==}
engines: {node: '>=10'}
@ -2847,6 +2880,8 @@ snapshots:
optionalDependencies:
'@types/node': 24.19.1
'@ioredis/commands@2.0.0': {}
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.6.0': {}
@ -3257,7 +3292,7 @@ snapshots:
obug: 2.2.1
std-env: 4.3.0
tinyrainbow: 3.2.0
vitest: 4.1.11(@types/node@24.19.1)(@vitest/coverage-v8@4.1.11)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))
vitest: 4.1.11(@types/node@24.19.1)(@vitest/coverage-v8@4.1.11)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
'@vitest/expect@4.1.11':
dependencies:
@ -3268,13 +3303,13 @@ snapshots:
chai: 6.3.0
tinyrainbow: 3.2.0
'@vitest/mocker@4.1.11(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))':
'@vitest/mocker@4.1.11(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))':
dependencies:
'@vitest/spy': 4.1.11
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)
vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)
'@vitest/pretty-format@4.1.11':
dependencies:
@ -3485,6 +3520,8 @@ snapshots:
clone@1.0.4: {}
cluster-key-slot@1.1.1: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@ -3553,6 +3590,8 @@ snapshots:
delayed-stream@1.0.0: {}
denque@2.1.0: {}
depd@2.0.0: {}
destr@2.0.5: {}
@ -3644,7 +3683,6 @@ snapshots:
'@esbuild/win32-arm64': 0.28.2
'@esbuild/win32-ia32': 0.28.2
'@esbuild/win32-x64': 0.28.2
optional: true
escape-html@1.0.3: {}
@ -3863,6 +3901,17 @@ snapshots:
through: 2.3.8
wrap-ansi: 6.2.0
ioredis@6.0.0(supports-color@7.2.0):
dependencies:
'@ioredis/commands': 2.0.0
cluster-key-slot: 1.1.1
debug: 4.4.3(supports-color@7.2.0)
denque: 2.1.0
redis-errors: 1.2.0
standard-as-callback: 2.1.0
transitivePeerDependencies:
- supports-color
ipaddr.js@1.9.1: {}
is-fullwidth-code-point@3.0.0: {}
@ -4326,6 +4375,8 @@ snapshots:
real-require@1.0.0: {}
redis-errors@1.2.0: {}
reflect-metadata@0.2.2: {}
require-from-string@2.0.2: {}
@ -4475,6 +4526,8 @@ snapshots:
stackback@0.0.2: {}
standard-as-callback@2.1.0: {}
statuses@2.0.2: {}
std-env@4.3.0: {}
@ -4585,6 +4638,12 @@ snapshots:
tslib@2.8.1: {}
tsx@4.23.15:
dependencies:
esbuild: 0.28.2
optionalDependencies:
fsevents: 2.3.3
type-fest@0.21.3: {}
type-is@1.6.18:
@ -4620,18 +4679,18 @@ snapshots:
vary@1.1.2: {}
vite-tsconfig-paths@5.1.4(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)):
vite-tsconfig-paths@5.1.4(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)):
dependencies:
debug: 4.4.3(supports-color@7.2.0)
globrex: 0.1.2
tsconfck: 3.1.6(typescript@6.0.3)
optionalDependencies:
vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)
vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)
transitivePeerDependencies:
- supports-color
- typescript
vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1):
vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
@ -4643,12 +4702,13 @@ snapshots:
esbuild: 0.28.2
fsevents: 2.3.3
jiti: 2.7.0
tsx: 4.23.15
yaml: 2.9.1
vitest@4.1.11(@types/node@24.19.1)(@vitest/coverage-v8@4.1.11)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)):
vitest@4.1.11(@types/node@24.19.1)(@vitest/coverage-v8@4.1.11)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)):
dependencies:
'@vitest/expect': 4.1.11
'@vitest/mocker': 4.1.11(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))
'@vitest/mocker': 4.1.11(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
'@vitest/pretty-format': 4.1.11
'@vitest/runner': 4.1.11
'@vitest/snapshot': 4.1.11
@ -4665,7 +4725,7 @@ snapshots:
tinyexec: 1.3.1
tinyglobby: 0.2.17
tinyrainbow: 3.2.0
vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)
vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.19.1

181
prisma/seed.ts Normal file
View File

@ -0,0 +1,181 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import { Permissions } from '../src/common/permissions.js';
const prisma = new PrismaClient();
const TENANT = 'touyun';
const SUPER_ROLE = 'super_admin';
const ADMIN_USERNAME = 'touyun';
const ADMIN_PASSWORD = 'Touyun@123';
/** 菜单资源(导航)。 */
const MENUS = [
{ code: 'dashboard', name: '工作台', parent: null, path: '/dashboard', component: 'dashboard/index', sort: 1 },
{ code: 'system', name: '系统管理', parent: null, path: '/system', component: 'Layout', sort: 2 },
{ code: 'tenant', name: '租户管理', parent: 'system', path: '/system/tenant', component: 'system/tenant/index', sort: 1 },
{ code: 'user', name: '用户管理', parent: 'system', path: '/system/user', component: 'system/user/index', sort: 2 },
{ code: 'role', name: '角色管理', parent: 'system', path: '/system/role', component: 'system/role/index', sort: 3 },
{ code: 'resource', name: '资源管理', parent: 'system', path: '/system/resource', component: 'system/resource/index', sort: 4 },
{ code: 'org', name: '组织管理', parent: 'system', path: '/system/org', component: 'system/org/index', sort: 5 },
{ code: 'dict', name: '字典管理', parent: 'system', path: '/system/dict', component: 'system/dict/index', sort: 6 },
{ code: 'config', name: '参数配置', parent: 'system', path: '/system/config', component: 'system/config/index', sort: 7 },
{ code: 'monitor', name: '系统监控', parent: null, path: '/monitor', component: 'Layout', sort: 3 },
{ code: 'audit', name: '操作日志', parent: 'monitor', path: '/monitor/audit', component: 'monitor/audit/index', sort: 1 },
{ code: 'login-log', name: '登录日志', parent: 'monitor', path: '/monitor/login-log', component: 'monitor/login-log/index', sort: 2 },
];
/** 接口资源(每个受控接口一个,code = 权限码)。 */
const APIS: { code: string; name: string; method: string; path: string; parent: string }[] = [
{ code: Permissions.tenant.list, name: '租户列表', method: 'GET', path: '/api/v1/tenants', parent: 'tenant' },
{ code: Permissions.tenant.create, name: '创建租户', method: 'POST', path: '/api/v1/tenants', parent: 'tenant' },
{ code: Permissions.tenant.update, name: '更新租户', method: 'PATCH', path: '/api/v1/tenants/:id', parent: 'tenant' },
{ code: Permissions.tenant.delete, name: '删除租户', method: 'DELETE', path: '/api/v1/tenants/:id', parent: 'tenant' },
{ code: Permissions.user.list, name: '用户列表', method: 'GET', path: '/api/v1/users', parent: 'user' },
{ code: Permissions.user.create, name: '创建用户', method: 'POST', path: '/api/v1/users', parent: 'user' },
{ code: Permissions.user.update, name: '更新用户', method: 'PATCH', path: '/api/v1/users/:id', parent: 'user' },
{ code: Permissions.user.delete, name: '删除用户', method: 'DELETE', path: '/api/v1/users/:id', parent: 'user' },
{ code: Permissions.user.resetPwd, name: '重置密码', method: 'PATCH', path: '/api/v1/users/:id/password', parent: 'user' },
{ code: Permissions.role.list, name: '角色列表', method: 'GET', path: '/api/v1/roles', parent: 'role' },
{ code: Permissions.role.create, name: '创建角色', method: 'POST', path: '/api/v1/roles', parent: 'role' },
{ code: Permissions.role.update, name: '更新角色', method: 'PATCH', path: '/api/v1/roles/:id', parent: 'role' },
{ code: Permissions.role.delete, name: '删除角色', method: 'DELETE', path: '/api/v1/roles/:id', parent: 'role' },
{ code: Permissions.role.assignResource, name: '分配资源', method: 'PUT', path: '/api/v1/roles/:id/resources', parent: 'role' },
{ code: Permissions.resource.list, name: '资源列表', method: 'GET', path: '/api/v1/resources', parent: 'resource' },
{ code: Permissions.resource.create, name: '创建资源', method: 'POST', path: '/api/v1/resources', parent: 'resource' },
{ code: Permissions.resource.update, name: '更新资源', method: 'PATCH', path: '/api/v1/resources/:id', parent: 'resource' },
{ code: Permissions.resource.delete, name: '删除资源', method: 'DELETE', path: '/api/v1/resources/:id', parent: 'resource' },
{ code: Permissions.org.list, name: '组织列表', method: 'GET', path: '/api/v1/orgs', parent: 'org' },
{ code: Permissions.org.create, name: '创建组织', method: 'POST', path: '/api/v1/orgs', parent: 'org' },
{ code: Permissions.org.update, name: '更新组织', method: 'PATCH', path: '/api/v1/orgs/:id', parent: 'org' },
{ code: Permissions.org.delete, name: '删除组织', method: 'DELETE', path: '/api/v1/orgs/:id', parent: 'org' },
{ code: Permissions.dict.list, name: '字典列表', method: 'GET', path: '/api/v1/dict-types', parent: 'dict' },
{ code: Permissions.dict.create, name: '创建字典', method: 'POST', path: '/api/v1/dict-types', parent: 'dict' },
{ code: Permissions.dict.update, name: '更新字典', method: 'PATCH', path: '/api/v1/dict-types/:id', parent: 'dict' },
{ code: Permissions.dict.delete, name: '删除字典', method: 'DELETE', path: '/api/v1/dict-types/:id', parent: 'dict' },
{ code: Permissions.config.list, name: '配置列表', method: 'GET', path: '/api/v1/configs', parent: 'config' },
{ code: Permissions.config.create, name: '创建配置', method: 'POST', path: '/api/v1/configs', parent: 'config' },
{ code: Permissions.config.update, name: '更新配置', method: 'PATCH', path: '/api/v1/configs/:id', parent: 'config' },
{ code: Permissions.config.delete, name: '删除配置', method: 'DELETE', path: '/api/v1/configs/:id', parent: 'config' },
{ code: Permissions.audit.list, name: '操作日志列表', method: 'GET', path: '/api/v1/audit-logs', parent: 'audit' },
{ code: Permissions.loginLog.list, name: '登录日志列表', method: 'GET', path: '/api/v1/login-logs', parent: 'login-log' },
];
/** 按钮资源(纯前端按钮,无独立受控接口)。 */
const BUTTONS = [
{ code: 'user:export', name: '用户导出', parent: 'user' },
{ code: 'role:export', name: '角色导出', parent: 'role' },
{ code: 'audit:export', name: '操作日志导出', parent: 'audit' },
{ code: 'login-log:export', name: '登录日志导出', parent: 'login-log' },
];
async function main(): Promise<void> {
// 1. 租户
await prisma.tenant.upsert({
where: { code: TENANT },
update: { name: '投云', status: 'enabled' },
create: { code: TENANT, name: '投云', status: 'enabled' },
});
// 2. 根组织
await prisma.org.upsert({
where: { tenantCode_code: { tenantCode: TENANT, code: TENANT } },
update: { name: '投云总部' },
create: { tenantCode: TENANT, code: TENANT, name: '投云总部', parentCode: null, sort: 0, status: 'enabled' },
});
// 3. 超管角色
await prisma.role.upsert({
where: { tenantCode_code: { tenantCode: TENANT, code: SUPER_ROLE } },
update: { name: '超级管理员', dataScope: 'all', status: 'enabled' },
create: { tenantCode: TENANT, code: SUPER_ROLE, name: '超级管理员', dataScope: 'all', status: 'enabled' },
});
// 4. 管理员用户
const passwordHash = await bcrypt.hash(ADMIN_PASSWORD, 10);
const user = await prisma.user.upsert({
where: { tenantCode_username: { tenantCode: TENANT, username: ADMIN_USERNAME } },
update: { passwordHash, nickname: '管理员', status: 'enabled' },
create: {
tenantCode: TENANT,
username: ADMIN_USERNAME,
passwordHash,
nickname: '管理员',
status: 'enabled',
orgCode: TENANT,
},
});
// 5. 绑定超管角色
await prisma.userRole.upsert({
where: { tenantCode_userId_roleCode: { tenantCode: TENANT, userId: user.id, roleCode: SUPER_ROLE } },
update: {},
create: { tenantCode: TENANT, userId: user.id, roleCode: SUPER_ROLE },
});
// 6. 资源树(菜单 + 接口 + 按钮)
const allResourceCodes: string[] = [];
for (const m of MENUS) {
allResourceCodes.push(m.code);
await prisma.resource.upsert({
where: { tenantCode_code: { tenantCode: TENANT, code: m.code } },
update: { name: m.name, parentCode: m.parent, path: m.path, component: m.component, sort: m.sort, type: 'menu' },
create: {
tenantCode: TENANT,
code: m.code,
name: m.name,
parentCode: m.parent,
type: 'menu',
path: m.path,
component: m.component,
sort: m.sort,
status: 'enabled',
},
});
}
for (const a of APIS) {
allResourceCodes.push(a.code);
await prisma.resource.upsert({
where: { tenantCode_code: { tenantCode: TENANT, code: a.code } },
update: { name: a.name, parentCode: a.parent, method: a.method, apiPath: a.path, type: 'api' },
create: {
tenantCode: TENANT,
code: a.code,
name: a.name,
parentCode: a.parent,
type: 'api',
method: a.method,
apiPath: a.path,
status: 'enabled',
},
});
}
for (const b of BUTTONS) {
allResourceCodes.push(b.code);
await prisma.resource.upsert({
where: { tenantCode_code: { tenantCode: TENANT, code: b.code } },
update: { name: b.name, parentCode: b.parent, type: 'button' },
create: { tenantCode: TENANT, code: b.code, name: b.name, parentCode: b.parent, type: 'button', status: 'enabled' },
});
}
// 7. 超管全量授权(菜单 + 接口 + 按钮)
for (const resourceCode of allResourceCodes) {
await prisma.roleResource.upsert({
where: { tenantCode_roleCode_resourceCode: { tenantCode: TENANT, roleCode: SUPER_ROLE, resourceCode } },
update: {},
create: { tenantCode: TENANT, roleCode: SUPER_ROLE, resourceCode },
});
}
console.log(`seed 完成:租户 ${TENANT}、用户 ${ADMIN_USERNAME}、角色 ${SUPER_ROLE}、资源 ${allResourceCodes.length} 个(已全量授权)。`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { PrismaModule } from './prisma/prisma.module.js';
import { RedisModule } from './common/redis/redis.module.js';
import { createLoggerModule } from './logging/logger.config.js';
import { AuthModule } from './auth/auth.module.js';
import { JwtAuthGuard } from './auth/jwt-auth.guard.js';
@ -8,6 +9,7 @@ import { PermissionsGuard } from './auth/permissions.guard.js';
import { RequestContextInterceptor } from './auth/request-context.interceptor.js';
import { BodyLoggingInterceptor } from './logging/body-logging.interceptor.js';
import { TransformInterceptor } from './common/interceptors/transform.interceptor.js';
import { AuditLogInterceptor } from './common/interceptors/audit-log.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';
@ -25,6 +27,7 @@ import { AppService } from './app.service.js';
imports: [
createLoggerModule(),
PrismaModule,
RedisModule,
AuthModule,
TenantsModule,
UsersModule,
@ -44,6 +47,7 @@ import { AppService } from './app.service.js';
{ provide: APP_INTERCEPTOR, useClass: RequestContextInterceptor },
{ provide: APP_INTERCEPTOR, useClass: BodyLoggingInterceptor },
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
{ provide: APP_INTERCEPTOR, useClass: AuditLogInterceptor },
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
],
})

View File

@ -1,5 +1,7 @@
import { Controller, Get, Query } from '@nestjs/common';
import { PageQuery } from '../common/page.dto.js';
import { Permissions } from '../common/permissions.js';
import { RequirePermissions } from '../auth/decorators.js';
import { AuditLogsService } from './audit-logs.service.js';
@Controller('audit-logs')
@ -7,6 +9,7 @@ export class AuditLogsController {
constructor(private readonly service: AuditLogsService) {}
@Get()
@RequirePermissions(Permissions.audit.list)
list(@Query() q: PageQuery) {
return this.service.paginate(q.page, q.pageSize);
}

View File

@ -5,5 +5,6 @@ import { AuditLogsService } from './audit-logs.service.js';
@Module({
controllers: [AuditLogsController],
providers: [AuditLogsService],
exports: [AuditLogsService],
})
export class AuditLogsModule {}

View File

@ -1,10 +1,40 @@
import { Injectable } from '@nestjs/common';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
import { BaseCrudService } from '../common/base-crud.service.js';
import { PrismaService } from '../prisma/prisma.service.js';
export interface AuditLogEntry {
tenantCode: string;
userId?: string | null;
username?: string | null;
module?: string | null;
action?: string | null;
method?: string | null;
path?: string | null;
params?: string | null;
result?: string | null;
status?: string | null;
errorMessage?: string | null;
ip?: string | null;
userAgent?: string | null;
durationMs?: number | null;
}
@Injectable()
export class AuditLogsService extends BaseCrudService {
constructor(prisma: PrismaService) {
constructor(
prisma: PrismaService,
@InjectPinoLogger(AuditLogsService.name) private readonly logger: PinoLogger,
) {
super(prisma, 'auditLog');
}
/** 写入一条审计日志;失败仅告警,不抛出(异步审计不阻塞业务)。 */
async record(data: AuditLogEntry): Promise<void> {
try {
await this.db().create({ data });
} catch (err) {
this.logger.warn({ err }, '审计日志写入失败');
}
}
}

View File

@ -1,4 +1,5 @@
import { Body, Controller, Post } from '@nestjs/common';
import { Body, Controller, Post, Req } from '@nestjs/common';
import type { Request } from 'express';
import { AuthService } from './auth.service.js';
import { LoginDto, RefreshDto } from './dto.js';
import { Public } from './decorators.js';
@ -9,8 +10,8 @@ export class AuthController {
@Public()
@Post('login')
login(@Body() dto: LoginDto) {
return this.auth.login(dto);
login(@Body() dto: LoginDto, @Req() req: Request) {
return this.auth.login(dto, req.ip, req.headers['user-agent']);
}
@Public()
@ -21,7 +22,7 @@ export class AuthController {
@Public()
@Post('logout')
logout(@Body() dto: RefreshDto) {
return this.auth.logout(dto);
logout(@Body() dto: RefreshDto, @Req() req: Request) {
return this.auth.logout(dto, req.ip, req.headers['user-agent']);
}
}

View File

@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller.js';
import { AuthService } from './auth.service.js';
import { AuditLogsModule } from '../audit-logs/audit-logs.module.js';
import { LoginLogsModule } from '../login-logs/login-logs.module.js';
@Module({
imports: [
@ -10,6 +12,8 @@ import { AuthService } from './auth.service.js';
secret: process.env.JWT_SECRET ?? 'dev-secret-change-me',
signOptions: { expiresIn: Number(process.env.JWT_ACCESS_TTL ?? 7200) },
}),
AuditLogsModule,
LoginLogsModule,
],
controllers: [AuthController],
providers: [AuthService],

View File

@ -3,34 +3,72 @@ import { JwtService } from '@nestjs/jwt';
import bcrypt from 'bcryptjs';
import { createHash, randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service.js';
import { RedisService } from '../common/redis/redis.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';
import { AuditLogsService } from '../audit-logs/audit-logs.service.js';
import { LoginLogsService } from '../login-logs/login-logs.service.js';
const REFRESH_TTL = Number(process.env.JWT_REFRESH_TTL ?? 604800);
const ACCESS_TTL = Number(process.env.JWT_ACCESS_TTL ?? 7200);
const SUPER_ROLE = 'super_admin';
export interface PermissionSet {
super: boolean;
permissions: string[];
}
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly redis: RedisService,
private readonly auditLogs: AuditLogsService,
private readonly loginLogs: LoginLogsService,
) {}
async login(dto: LoginDto): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
async login(
dto: LoginDto,
ip?: string,
userAgent?: string,
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
const tenant = await this.prisma.client.tenant.findUnique({ where: { code: dto.tenantCode } });
if (!tenant || tenant.status !== 'enabled') {
await this.recordLoginLog(dto.tenantCode, dto.username, 'fail', '租户不存在或已停用', ip, userAgent);
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('账号已停用或锁定');
if (!user) {
await this.recordLoginLog(dto.tenantCode, dto.username, 'fail', '账号或密码错误', ip, userAgent);
throw new UnauthorizedException('账号或密码错误');
}
if (user.status !== 'enabled') {
await this.recordLoginLog(dto.tenantCode, dto.username, 'fail', '账号已停用或锁定', ip, userAgent);
throw new UnauthorizedException('账号已停用或锁定');
}
const ok = await bcrypt.compare(dto.password, user.passwordHash);
if (!ok) throw new UnauthorizedException('账号或密码错误');
if (!ok) {
await this.recordLoginLog(dto.tenantCode, dto.username, 'fail', '账号或密码错误', ip, userAgent);
throw new UnauthorizedException('账号或密码错误');
}
await this.writePermissionCache(dto.tenantCode, user.id);
await this.recordLoginLog(dto.tenantCode, dto.username, 'success', null, ip, userAgent);
await this.recordAudit({
tenantCode: dto.tenantCode,
userId: user.id,
username: user.username,
module: 'auth',
action: 'login',
status: 'success',
ip: ip ?? null,
userAgent: userAgent ?? null,
});
return this.issue(dto.tenantCode, user.id, user.username);
}
@ -44,25 +82,44 @@ export class AuthService {
});
if (!user || user.status !== 'enabled') throw new UnauthorizedException('账号已失效');
// 旋转:作废旧 refresh
// 旋转:作废旧 refresh,并重聚合权限集(权限变更在刷新时生效)
await this.prisma.client.refreshToken.update({
where: { jti: dto.refreshToken },
data: { revokedAt: new Date() },
});
await this.writePermissionCache(record.tenantCode, user.id);
return this.issue(record.tenantCode, user.id, user.username);
}
async logout(dto: RefreshDto): Promise<null> {
async logout(dto: RefreshDto, ip?: string, userAgent?: string): 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() },
});
await this.redis.del(this.permKey(record.tenantCode, record.userId));
await this.recordAudit({
tenantCode: record.tenantCode,
userId: record.userId,
username: null,
module: 'auth',
action: 'logout',
status: 'success',
ip: ip ?? null,
userAgent: userAgent ?? null,
});
}
return null;
}
/** 读取用户权限集;缓存缺失时重新聚合并回填(Redis 重启自愈)。 */
async getPermissionSet(tenantCode: string, userId: string): Promise<PermissionSet> {
const cached = await this.redis.getJson<PermissionSet>(this.permKey(tenantCode, userId));
if (cached) return cached;
return this.writePermissionCache(tenantCode, userId);
}
/** 由 RequestContextInterceptor 调用,构建当前请求的租户/用户/数据权限上下文。 */
async buildContext(user: { userId: string; tenantCode: string; username: string }): Promise<RequestContext> {
const u = await this.prisma.client.user.findFirst({
@ -76,24 +133,93 @@ export class AuthService {
select: { roleCode: true },
});
const roleCodes = roleRows.map((r) => r.roleCode);
const isSuper = roleCodes.includes(SUPER_ROLE);
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,
});
const dataScope: DataScope = isSuper ? 'all' : resolveDataScope(roles.map((r) => r.dataScope as DataScope));
const orgCodes = isSuper
? []
: 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 permKey(tenantCode: string, userId: string): string {
return `perm:${tenantCode}:${userId}`;
}
/** 聚合权限码与超管标志。 */
private async resolveAuth(tenantCode: string, userId: string): Promise<PermissionSet> {
const roleRows = await this.prisma.client.userRole.findMany({
where: { tenantCode, userId },
select: { roleCode: true },
});
const roleCodes = roleRows.map((r) => r.roleCode);
const isSuper = roleCodes.includes(SUPER_ROLE);
const resourceRows = await this.prisma.client.roleResource.findMany({
where: { tenantCode, roleCode: { in: roleCodes } },
select: { resourceCode: true },
});
const resourceCodes = [...new Set(resourceRows.map((r) => r.resourceCode))];
const permissions: string[] = resourceCodes.length
? (
await this.prisma.client.resource.findMany({
where: { tenantCode, code: { in: resourceCodes }, type: { in: ['button', 'api'] }, deletedAt: null },
select: { code: true },
})
).map((r) => r.code)
: [];
return { super: isSuper, permissions };
}
/** 聚合并写入权限缓存(登录/刷新时调用)。 */
private async writePermissionCache(tenantCode: string, userId: string): Promise<PermissionSet> {
const set = await this.resolveAuth(tenantCode, userId);
await this.redis.setJson(this.permKey(tenantCode, userId), set, REFRESH_TTL);
return set;
}
private async recordLoginLog(
tenantCode: string,
username: string,
status: string,
message: string | null,
ip?: string,
userAgent?: string,
): Promise<void> {
await this.loginLogs.record({
tenantCode,
username,
status,
message,
ip: ip ?? null,
userAgent: userAgent ?? null,
});
}
private async recordAudit(entry: {
tenantCode: string;
userId: string | null;
username: string | null;
module: string;
action: string;
status: string;
ip?: string | null;
userAgent?: string | null;
}): Promise<void> {
await this.auditLogs.record(entry);
}
private async issue(
tenantCode: string,
userId: string,

View File

@ -8,6 +8,14 @@ export const PERMISSIONS_KEY = 'permissions';
/** 要求当前用户具备给定权限标识(resource code)。 */
export const RequirePermissions = (...permissions: string[]) => SetMetadata(PERMISSIONS_KEY, permissions);
export const AUDIT_KEY = 'audit';
export interface AuditMeta {
module: string;
action: string;
}
/** 标记写接口需自动落审计日志(module/action 记录到 sys_audit_log)。 */
export const AuditLog = (module: string, action: string) => SetMetadata(AUDIT_KEY, { module, action } satisfies AuditMeta);
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
return ctx.switchToHttp().getRequest().user;
});

View File

@ -1,12 +1,12 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PrismaService } from '../prisma/prisma.service.js';
import { AuthService } from './auth.service.js';
import { PERMISSIONS_KEY } from './decorators.js';
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(
private readonly prisma: PrismaService,
private readonly auth: AuthService,
private readonly reflector: Reflector,
) {}
@ -21,17 +21,9 @@ export class PermissionsGuard implements CanActivate {
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;
const { super: isSuper, permissions } = await this.auth.getPermissionSet(user.tenantCode, user.userId);
if (isSuper) return true;
if (required.every((code) => permissions.includes(code))) return true;
throw new ForbiddenException('无权限');
}
}

View File

@ -0,0 +1,58 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { catchError, Observable, tap, throwError } from 'rxjs';
import { AUDIT_KEY, AuditMeta } from '../../auth/decorators.js';
import { AuditLogsService } from '../../audit-logs/audit-logs.service.js';
import { maskSensitive } from '../mask.js';
/**
* 审计日志拦截器:对标注 @AuditLog(module, action) 的接口,在请求完成后异步写入
* sys_audit_log(成功 status=success,异常 status=fail + error_message)。
* 入参脱敏;写入失败由 AuditLogsService 内部告警,不阻塞响应。
*/
@Injectable()
export class AuditLogInterceptor implements NestInterceptor {
constructor(
private readonly reflector: Reflector,
private readonly auditLogs: AuditLogsService,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const meta = this.reflector.getAllAndOverride<AuditMeta>(AUDIT_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!meta) return next.handle();
const request = context.switchToHttp().getRequest();
const user = request.user as { userId?: string; tenantCode?: string; username?: string } | undefined;
const start = Date.now();
const base = {
tenantCode: user?.tenantCode ?? '',
userId: user?.userId ?? null,
username: user?.username ?? null,
module: meta.module,
action: meta.action,
method: request.method,
path: request.originalUrl ?? request.url,
params: request.body ? JSON.stringify(maskSensitive(request.body)) : null,
ip: request.ip ?? request.remoteAddress ?? null,
userAgent: request.headers?.['user-agent'] ?? null,
};
return next.handle().pipe(
tap(() => {
void this.auditLogs.record({ ...base, status: 'success', durationMs: Date.now() - start });
}),
catchError((err) => {
void this.auditLogs.record({
...base,
status: 'fail',
errorMessage: err instanceof Error ? err.message : String(err),
durationMs: Date.now() - start,
});
return throwError(() => err);
}),
);
}
}

25
src/common/mask.ts Normal file
View File

@ -0,0 +1,25 @@
/** 敏感字段递归脱敏:命中的字段值替换为 ***(供请求体日志与审计参数复用)。 */
const SENSITIVE_KEYS = new Set([
'password',
'passwordhash',
'oldpassword',
'newpassword',
'confirmpassword',
'refreshtoken',
'token',
'secret',
'authorization',
]);
export function maskSensitive(value: unknown): unknown {
if (value === null || value === undefined) return value;
if (Array.isArray(value)) return value.map(maskSensitive);
if (typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = SENSITIVE_KEYS.has(k.toLowerCase()) ? '***' : maskSensitive(v);
}
return out;
}
return value;
}

57
src/common/permissions.ts Normal file
View File

@ -0,0 +1,57 @@
/**
* 权限码单一事实源(细粒度 domain:action)。
* 控制器 @RequirePermissions 与 seed 资源树共用,避免漂移。
*/
export const Permissions = {
tenant: {
list: 'tenant:list',
create: 'tenant:create',
update: 'tenant:update',
delete: 'tenant:delete',
},
user: {
list: 'user:list',
create: 'user:create',
update: 'user:update',
delete: 'user:delete',
resetPwd: 'user:reset-pwd',
},
role: {
list: 'role:list',
create: 'role:create',
update: 'role:update',
delete: 'role:delete',
assignResource: 'role:assign-resource',
},
resource: {
list: 'resource:list',
create: 'resource:create',
update: 'resource:update',
delete: 'resource:delete',
},
org: {
list: 'org:list',
create: 'org:create',
update: 'org:update',
delete: 'org:delete',
},
dict: {
list: 'dict:list',
create: 'dict:create',
update: 'dict:update',
delete: 'dict:delete',
},
config: {
list: 'config:list',
create: 'config:create',
update: 'config:update',
delete: 'config:delete',
},
audit: { list: 'audit:list' },
loginLog: { list: 'login-log:list' },
} as const;
/** 全部权限码扁平列表(seed 授权与资源生成用)。 */
export const ALL_PERMISSIONS: string[] = Object.values(Permissions).flatMap((m) =>
Object.values(m),
);

View File

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { RedisService } from './redis.service.js';
@Global()
@Module({
providers: [RedisService],
exports: [RedisService],
})
export class RedisModule {}

View File

@ -0,0 +1,56 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { Redis } from 'ioredis';
/**
* Redis 封装:统一 key 前缀(REDIS_KEY_PREFIX),支持 JSON 读写与按前缀删除。
* 多个 fork 项目共用一个 Redis 实例时,通过不同前缀隔离。
*/
@Injectable()
export class RedisService implements OnModuleDestroy {
readonly prefix: string;
private readonly client: Redis;
constructor() {
this.prefix = process.env.REDIS_KEY_PREFIX ?? 'sysmgr:';
this.client = new Redis({
host: process.env.REDIS_HOST ?? '127.0.0.1',
port: Number(process.env.REDIS_PORT ?? 6379),
db: Number(process.env.REDIS_DB ?? 0),
password: process.env.REDIS_PASSWORD || undefined,
});
}
private key(k: string): string {
return this.prefix + k;
}
async getJson<T>(key: string): Promise<T | null> {
const raw = await this.client.get(this.key(key));
return raw ? (JSON.parse(raw) as T) : null;
}
async setJson(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
const raw = JSON.stringify(value);
if (ttlSeconds && ttlSeconds > 0) await this.client.set(this.key(key), raw, 'EX', ttlSeconds);
else await this.client.set(this.key(key), raw);
}
async del(key: string): Promise<void> {
await this.client.del(this.key(key));
}
/** 按前缀删除(SCAN 而非 KEYS,避免阻塞)。 */
async delByPrefix(prefix: string): Promise<void> {
const fullPrefix = this.prefix + prefix;
let cursor = '0';
do {
const [next, keys] = await this.client.scan(cursor, 'MATCH', `${fullPrefix}*`, 'COUNT', 200);
cursor = next;
if (keys.length > 0) await this.client.del(...keys);
} while (cursor !== '0');
}
async onModuleDestroy(): Promise<void> {
await this.client.quit();
}
}

View File

@ -1,5 +1,7 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { PageQuery } from '../common/page.dto.js';
import { Permissions } from '../common/permissions.js';
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
import { CreateConfigDto, UpdateConfigDto } from './dto.js';
import { ConfigsService } from './configs.service.js';
@ -8,26 +10,34 @@ export class ConfigsController {
constructor(private readonly service: ConfigsService) {}
@Get()
@RequirePermissions(Permissions.config.list)
list(@Query() q: PageQuery) {
return this.service.paginate(q.page, q.pageSize);
}
@Get(':id')
@RequirePermissions(Permissions.config.list)
get(@Param('id') id: string) {
return this.service.findById(id);
}
@Post()
@RequirePermissions(Permissions.config.create)
@AuditLog('config', 'create')
create(@Body() dto: CreateConfigDto) {
return this.service.createConfig(dto);
}
@Patch(':id')
@RequirePermissions(Permissions.config.update)
@AuditLog('config', 'update')
update(@Param('id') id: string, @Body() dto: UpdateConfigDto) {
return this.service.updateConfig(id, dto);
}
@Delete(':id')
@RequirePermissions(Permissions.config.delete)
@AuditLog('config', 'delete')
remove(@Param('id') id: string) {
return this.service.softRemove(id);
}

View File

@ -1,4 +1,6 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { Permissions } from '../common/permissions.js';
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
import { CreateDictItemDto, UpdateDictItemDto } from './dto.js';
import { DictItemsService } from './dict-items.service.js';
@ -7,26 +9,34 @@ export class DictItemsController {
constructor(private readonly service: DictItemsService) {}
@Get()
@RequirePermissions(Permissions.dict.list)
list(@Query('dictTypeCode') dictTypeCode?: string) {
return this.service.listByType(dictTypeCode);
}
@Get(':id')
@RequirePermissions(Permissions.dict.list)
get(@Param('id') id: string) {
return this.service.findById(id);
}
@Post()
@RequirePermissions(Permissions.dict.create)
@AuditLog('dict', 'create')
create(@Body() dto: CreateDictItemDto) {
return this.service.createItem(dto);
}
@Patch(':id')
@RequirePermissions(Permissions.dict.update)
@AuditLog('dict', 'update')
update(@Param('id') id: string, @Body() dto: UpdateDictItemDto) {
return this.service.updateItem(id, dto);
}
@Delete(':id')
@RequirePermissions(Permissions.dict.delete)
@AuditLog('dict', 'delete')
remove(@Param('id') id: string) {
return this.service.softRemove(id);
}

View File

@ -1,5 +1,7 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { PageQuery } from '../common/page.dto.js';
import { Permissions } from '../common/permissions.js';
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
import { CreateDictTypeDto, UpdateDictTypeDto } from './dto.js';
import { DictTypesService } from './dict-types.service.js';
@ -8,26 +10,34 @@ export class DictTypesController {
constructor(private readonly service: DictTypesService) {}
@Get()
@RequirePermissions(Permissions.dict.list)
list(@Query() q: PageQuery) {
return this.service.paginate(q.page, q.pageSize);
}
@Get(':id')
@RequirePermissions(Permissions.dict.list)
get(@Param('id') id: string) {
return this.service.findById(id);
}
@Post()
@RequirePermissions(Permissions.dict.create)
@AuditLog('dict', 'create')
create(@Body() dto: CreateDictTypeDto) {
return this.service.createType(dto);
}
@Patch(':id')
@RequirePermissions(Permissions.dict.update)
@AuditLog('dict', 'update')
update(@Param('id') id: string, @Body() dto: UpdateDictTypeDto) {
return this.service.updateType(id, dto);
}
@Delete(':id')
@RequirePermissions(Permissions.dict.delete)
@AuditLog('dict', 'delete')
remove(@Param('id') id: string) {
return this.service.softRemove(id);
}

View File

@ -1,32 +1,7 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
const SENSITIVE_KEYS = new Set([
'password',
'passwordhash',
'oldpassword',
'newpassword',
'confirmpassword',
'refreshtoken',
'token',
'secret',
'authorization',
]);
/** 递归脱敏:命中的字段值替换为 ***。 */
function mask(value: unknown): unknown {
if (value === null || value === undefined) return value;
if (Array.isArray(value)) return value.map(mask);
if (typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = SENSITIVE_KEYS.has(k.toLowerCase()) ? '***' : mask(v);
}
return out;
}
return value;
}
import { maskSensitive } from '../common/mask.js';
/** 记录请求 body(POST/PUT/PATCH/DELETE),敏感字段脱敏,级别 debug。 */
@Injectable()
@ -37,7 +12,7 @@ export class BodyLoggingInterceptor implements NestInterceptor {
const request = context.switchToHttp().getRequest();
const { method, url, body } = request;
if (body && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) && Object.keys(body).length > 0) {
this.logger.debug({ method, url, body: mask(body) }, 'request body');
this.logger.debug({ method, url, body: maskSensitive(body) }, 'request body');
}
return next.handle();
}

View File

@ -1,5 +1,7 @@
import { Controller, Get, Query } from '@nestjs/common';
import { PageQuery } from '../common/page.dto.js';
import { Permissions } from '../common/permissions.js';
import { RequirePermissions } from '../auth/decorators.js';
import { LoginLogsService } from './login-logs.service.js';
@Controller('login-logs')
@ -7,6 +9,7 @@ export class LoginLogsController {
constructor(private readonly service: LoginLogsService) {}
@Get()
@RequirePermissions(Permissions.loginLog.list)
list(@Query() q: PageQuery) {
return this.service.paginate(q.page, q.pageSize);
}

View File

@ -5,5 +5,6 @@ import { LoginLogsService } from './login-logs.service.js';
@Module({
controllers: [LoginLogsController],
providers: [LoginLogsService],
exports: [LoginLogsService],
})
export class LoginLogsModule {}

View File

@ -1,10 +1,32 @@
import { Injectable } from '@nestjs/common';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
import { BaseCrudService } from '../common/base-crud.service.js';
import { PrismaService } from '../prisma/prisma.service.js';
export interface LoginLogEntry {
tenantCode: string;
username?: string | null;
ip?: string | null;
userAgent?: string | null;
status: string;
message?: string | null;
}
@Injectable()
export class LoginLogsService extends BaseCrudService {
constructor(prisma: PrismaService) {
constructor(
prisma: PrismaService,
@InjectPinoLogger(LoginLogsService.name) private readonly logger: PinoLogger,
) {
super(prisma, 'loginLog');
}
/** 写入一条登录日志;失败仅告警,不抛出。 */
async record(data: LoginLogEntry): Promise<void> {
try {
await this.db().create({ data });
} catch (err) {
this.logger.warn({ err }, '登录日志写入失败');
}
}
}

View File

@ -1,4 +1,6 @@
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { Permissions } from '../common/permissions.js';
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
import { CreateOrgDto, UpdateOrgDto } from './dto.js';
import { OrgsService } from './orgs.service.js';
@ -7,26 +9,34 @@ export class OrgsController {
constructor(private readonly service: OrgsService) {}
@Get()
@RequirePermissions(Permissions.org.list)
list() {
return this.service.list({}, { sort: 'asc' });
}
@Get(':id')
@RequirePermissions(Permissions.org.list)
get(@Param('id') id: string) {
return this.service.findById(id);
}
@Post()
@RequirePermissions(Permissions.org.create)
@AuditLog('org', 'create')
create(@Body() dto: CreateOrgDto) {
return this.service.createOrg(dto);
}
@Patch(':id')
@RequirePermissions(Permissions.org.update)
@AuditLog('org', 'update')
update(@Param('id') id: string, @Body() dto: UpdateOrgDto) {
return this.service.updateOrg(id, dto);
}
@Delete(':id')
@RequirePermissions(Permissions.org.delete)
@AuditLog('org', 'delete')
remove(@Param('id') id: string) {
return this.service.softRemove(id);
}

View File

@ -1,4 +1,6 @@
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { Permissions } from '../common/permissions.js';
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
import { CreateResourceDto, UpdateResourceDto } from './dto.js';
import { ResourcesService } from './resources.service.js';
@ -7,26 +9,34 @@ export class ResourcesController {
constructor(private readonly service: ResourcesService) {}
@Get()
@RequirePermissions(Permissions.resource.list)
list() {
return this.service.list({}, { sort: 'asc' });
}
@Get(':id')
@RequirePermissions(Permissions.resource.list)
get(@Param('id') id: string) {
return this.service.findById(id);
}
@Post()
@RequirePermissions(Permissions.resource.create)
@AuditLog('resource', 'create')
create(@Body() dto: CreateResourceDto) {
return this.service.createResource(dto);
}
@Patch(':id')
@RequirePermissions(Permissions.resource.update)
@AuditLog('resource', 'update')
update(@Param('id') id: string, @Body() dto: UpdateResourceDto) {
return this.service.updateResource(id, dto);
}
@Delete(':id')
@RequirePermissions(Permissions.resource.delete)
@AuditLog('resource', 'delete')
remove(@Param('id') id: string) {
return this.service.softRemove(id);
}

View File

@ -1,5 +1,7 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { PageQuery } from '../common/page.dto.js';
import { Permissions } from '../common/permissions.js';
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
import { AssignResourcesDto, CreateRoleDto, UpdateRoleDto } from './dto.js';
import { RolesService } from './roles.service.js';
@ -8,36 +10,47 @@ export class RolesController {
constructor(private readonly service: RolesService) {}
@Get()
@RequirePermissions(Permissions.role.list)
list(@Query() q: PageQuery) {
return this.service.paginate(q.page, q.pageSize);
}
@Get(':id')
@RequirePermissions(Permissions.role.list)
get(@Param('id') id: string) {
return this.service.findById(id);
}
@Post()
@RequirePermissions(Permissions.role.create)
@AuditLog('role', 'create')
create(@Body() dto: CreateRoleDto) {
return this.service.createRole(dto);
}
@Patch(':id')
@RequirePermissions(Permissions.role.update)
@AuditLog('role', 'update')
update(@Param('id') id: string, @Body() dto: UpdateRoleDto) {
return this.service.updateRole(id, dto);
}
@Delete(':id')
@RequirePermissions(Permissions.role.delete)
@AuditLog('role', 'delete')
remove(@Param('id') id: string) {
return this.service.softRemove(id);
}
@Get(':id/resources')
@RequirePermissions(Permissions.role.list)
resources(@Param('id') id: string) {
return this.service.assignedResources(id);
}
@Put(':id/resources')
@RequirePermissions(Permissions.role.assignResource)
@AuditLog('role', 'assign-resource')
assign(@Param('id') id: string, @Body() dto: AssignResourcesDto) {
return this.service.assignResources(id, dto);
}

View File

@ -1,12 +1,16 @@
import { Injectable } from '@nestjs/common';
import { BaseCrudService } from '../common/base-crud.service.js';
import { PrismaService } from '../prisma/prisma.service.js';
import { RedisService } from '../common/redis/redis.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) {
constructor(
prisma: PrismaService,
private readonly redis: RedisService,
) {
super(prisma, 'role');
}
@ -35,6 +39,8 @@ export class RolesService extends BaseCrudService {
data: dto.resourceCodes.map((resourceCode) => ({ tenantCode: ctx.tenantCode, roleCode, resourceCode })),
});
}
// 授权变更:删除该租户全部用户权限缓存,下一次请求守卫重新聚合并回填
await this.redis.delByPrefix(`perm:${ctx.tenantCode}:`);
return null;
}
}

View File

@ -1,5 +1,7 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { PageQuery } from '../common/page.dto.js';
import { Permissions } from '../common/permissions.js';
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
import { CreateTenantDto, UpdateTenantDto } from './dto.js';
import { TenantsService } from './tenants.service.js';
@ -8,26 +10,34 @@ export class TenantsController {
constructor(private readonly service: TenantsService) {}
@Get()
@RequirePermissions(Permissions.tenant.list)
list(@Query() q: PageQuery) {
return this.service.paginate(q.page, q.pageSize);
}
@Get(':id')
@RequirePermissions(Permissions.tenant.list)
get(@Param('id') id: string) {
return this.service.findById(id);
}
@Post()
@RequirePermissions(Permissions.tenant.create)
@AuditLog('tenant', 'create')
create(@Body() dto: CreateTenantDto) {
return this.service.createTenant(dto);
}
@Patch(':id')
@RequirePermissions(Permissions.tenant.update)
@AuditLog('tenant', 'update')
update(@Param('id') id: string, @Body() dto: UpdateTenantDto) {
return this.service.updateTenant(id, dto);
}
@Delete(':id')
@RequirePermissions(Permissions.tenant.delete)
@AuditLog('tenant', 'delete')
remove(@Param('id') id: string) {
return this.service.removeTenant(id);
}

View File

@ -1,5 +1,7 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { PageQuery } from '../common/page.dto.js';
import { Permissions } from '../common/permissions.js';
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
import { CreateUserDto, ResetPasswordDto, UpdateUserDto } from './dto.js';
import { UsersService } from './users.service.js';
@ -8,31 +10,41 @@ export class UsersController {
constructor(private readonly service: UsersService) {}
@Get()
@RequirePermissions(Permissions.user.list)
list(@Query() q: PageQuery) {
return this.service.listUsers(q);
}
@Get(':id')
@RequirePermissions(Permissions.user.list)
get(@Param('id') id: string) {
return this.service.findById(id);
}
@Post()
@RequirePermissions(Permissions.user.create)
@AuditLog('user', 'create')
create(@Body() dto: CreateUserDto) {
return this.service.createUser(dto);
}
@Patch(':id')
@RequirePermissions(Permissions.user.update)
@AuditLog('user', 'update')
update(@Param('id') id: string, @Body() dto: UpdateUserDto) {
return this.service.updateUser(id, dto);
}
@Patch(':id/password')
@RequirePermissions(Permissions.user.resetPwd)
@AuditLog('user', 'reset-pwd')
resetPassword(@Param('id') id: string, @Body() dto: ResetPasswordDto) {
return this.service.resetPassword(id, dto);
}
@Delete(':id')
@RequirePermissions(Permissions.user.delete)
@AuditLog('user', 'delete')
remove(@Param('id') id: string) {
return this.service.softRemove(id);
}