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:
parent
c680fb9dc7
commit
7340658305
25
.env.example
Normal file
25
.env.example
Normal 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:"
|
||||||
@ -7,6 +7,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.23.1"
|
"node": "^22.23.1"
|
||||||
},
|
},
|
||||||
@ -23,7 +26,8 @@
|
|||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:cov": "vitest run --coverage",
|
"test:cov": "vitest run --coverage",
|
||||||
"test:debug": "vitest --inspect-brk --no-file-parallelism",
|
"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": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^12.0.1",
|
"@nestjs/common": "^12.0.1",
|
||||||
@ -35,6 +39,7 @@
|
|||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
|
"ioredis": "^6.0.0",
|
||||||
"nestjs-pino": "^5.3.1",
|
"nestjs-pino": "^5.3.1",
|
||||||
"pino": "^10.4.0",
|
"pino": "^10.4.0",
|
||||||
"pino-pretty": "^13.2.0",
|
"pino-pretty": "^13.2.0",
|
||||||
@ -59,6 +64,7 @@
|
|||||||
"prisma": "^6.19.3",
|
"prisma": "^6.19.3",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
|
"tsx": "^4.23.15",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
"vitest": "^4.1.2"
|
"vitest": "^4.1.2"
|
||||||
|
|||||||
84
pnpm-lock.yaml
generated
84
pnpm-lock.yaml
generated
@ -193,6 +193,9 @@ importers:
|
|||||||
class-validator:
|
class-validator:
|
||||||
specifier: ^0.15.1
|
specifier: ^0.15.1
|
||||||
version: 0.15.1
|
version: 0.15.1
|
||||||
|
ioredis:
|
||||||
|
specifier: ^6.0.0
|
||||||
|
version: 6.0.0(supports-color@7.2.0)
|
||||||
nestjs-pino:
|
nestjs-pino:
|
||||||
specifier: ^5.3.1
|
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)
|
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:
|
supertest:
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.3.1(supports-color@7.2.0)
|
version: 7.3.1(supports-color@7.2.0)
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.23.15
|
||||||
|
version: 4.23.15
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^6.0.2
|
specifier: ^6.0.2
|
||||||
version: 6.0.3
|
version: 6.0.3
|
||||||
vite-tsconfig-paths:
|
vite-tsconfig-paths:
|
||||||
specifier: ^5.1.4
|
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:
|
vitest:
|
||||||
specifier: ^4.1.2
|
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:
|
packages:
|
||||||
|
|
||||||
@ -608,6 +614,9 @@ packages:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@ioredis/commands@2.0.0':
|
||||||
|
resolution: {integrity: sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==}
|
||||||
|
|
||||||
'@jridgewell/resolve-uri@3.1.2':
|
'@jridgewell/resolve-uri@3.1.2':
|
||||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
@ -1363,6 +1372,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
|
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
|
||||||
engines: {node: '>=0.8'}
|
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:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
@ -1461,6 +1474,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
|
denque@2.1.0:
|
||||||
|
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
||||||
|
engines: {node: '>=0.10'}
|
||||||
|
|
||||||
depd@2.0.0:
|
depd@2.0.0:
|
||||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@ -1716,6 +1733,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==}
|
resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==}
|
||||||
engines: {node: '>=12.0.0'}
|
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:
|
ipaddr.js@1.9.1:
|
||||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
@ -2201,6 +2222,10 @@ packages:
|
|||||||
real-require@1.0.0:
|
real-require@1.0.0:
|
||||||
resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==}
|
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:
|
reflect-metadata@0.2.2:
|
||||||
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
||||||
|
|
||||||
@ -2319,6 +2344,9 @@ packages:
|
|||||||
stackback@0.0.2:
|
stackback@0.0.2:
|
||||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
|
standard-as-callback@2.1.0:
|
||||||
|
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||||
|
|
||||||
statuses@2.0.2:
|
statuses@2.0.2:
|
||||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@ -2431,6 +2459,11 @@ packages:
|
|||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
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:
|
type-fest@0.21.3:
|
||||||
resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==}
|
resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@ -2847,6 +2880,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 24.19.1
|
'@types/node': 24.19.1
|
||||||
|
|
||||||
|
'@ioredis/commands@2.0.0': {}
|
||||||
|
|
||||||
'@jridgewell/resolve-uri@3.1.2': {}
|
'@jridgewell/resolve-uri@3.1.2': {}
|
||||||
|
|
||||||
'@jridgewell/sourcemap-codec@1.6.0': {}
|
'@jridgewell/sourcemap-codec@1.6.0': {}
|
||||||
@ -3257,7 +3292,7 @@ snapshots:
|
|||||||
obug: 2.2.1
|
obug: 2.2.1
|
||||||
std-env: 4.3.0
|
std-env: 4.3.0
|
||||||
tinyrainbow: 3.2.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':
|
'@vitest/expect@4.1.11':
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -3268,13 +3303,13 @@ snapshots:
|
|||||||
chai: 6.3.0
|
chai: 6.3.0
|
||||||
tinyrainbow: 3.2.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:
|
dependencies:
|
||||||
'@vitest/spy': 4.1.11
|
'@vitest/spy': 4.1.11
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
optionalDependencies:
|
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':
|
'@vitest/pretty-format@4.1.11':
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -3485,6 +3520,8 @@ snapshots:
|
|||||||
|
|
||||||
clone@1.0.4: {}
|
clone@1.0.4: {}
|
||||||
|
|
||||||
|
cluster-key-slot@1.1.1: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
@ -3553,6 +3590,8 @@ snapshots:
|
|||||||
|
|
||||||
delayed-stream@1.0.0: {}
|
delayed-stream@1.0.0: {}
|
||||||
|
|
||||||
|
denque@2.1.0: {}
|
||||||
|
|
||||||
depd@2.0.0: {}
|
depd@2.0.0: {}
|
||||||
|
|
||||||
destr@2.0.5: {}
|
destr@2.0.5: {}
|
||||||
@ -3644,7 +3683,6 @@ snapshots:
|
|||||||
'@esbuild/win32-arm64': 0.28.2
|
'@esbuild/win32-arm64': 0.28.2
|
||||||
'@esbuild/win32-ia32': 0.28.2
|
'@esbuild/win32-ia32': 0.28.2
|
||||||
'@esbuild/win32-x64': 0.28.2
|
'@esbuild/win32-x64': 0.28.2
|
||||||
optional: true
|
|
||||||
|
|
||||||
escape-html@1.0.3: {}
|
escape-html@1.0.3: {}
|
||||||
|
|
||||||
@ -3863,6 +3901,17 @@ snapshots:
|
|||||||
through: 2.3.8
|
through: 2.3.8
|
||||||
wrap-ansi: 6.2.0
|
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: {}
|
ipaddr.js@1.9.1: {}
|
||||||
|
|
||||||
is-fullwidth-code-point@3.0.0: {}
|
is-fullwidth-code-point@3.0.0: {}
|
||||||
@ -4326,6 +4375,8 @@ snapshots:
|
|||||||
|
|
||||||
real-require@1.0.0: {}
|
real-require@1.0.0: {}
|
||||||
|
|
||||||
|
redis-errors@1.2.0: {}
|
||||||
|
|
||||||
reflect-metadata@0.2.2: {}
|
reflect-metadata@0.2.2: {}
|
||||||
|
|
||||||
require-from-string@2.0.2: {}
|
require-from-string@2.0.2: {}
|
||||||
@ -4475,6 +4526,8 @@ snapshots:
|
|||||||
|
|
||||||
stackback@0.0.2: {}
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
|
standard-as-callback@2.1.0: {}
|
||||||
|
|
||||||
statuses@2.0.2: {}
|
statuses@2.0.2: {}
|
||||||
|
|
||||||
std-env@4.3.0: {}
|
std-env@4.3.0: {}
|
||||||
@ -4585,6 +4638,12 @@ snapshots:
|
|||||||
|
|
||||||
tslib@2.8.1: {}
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
|
tsx@4.23.15:
|
||||||
|
dependencies:
|
||||||
|
esbuild: 0.28.2
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.3
|
||||||
|
|
||||||
type-fest@0.21.3: {}
|
type-fest@0.21.3: {}
|
||||||
|
|
||||||
type-is@1.6.18:
|
type-is@1.6.18:
|
||||||
@ -4620,18 +4679,18 @@ snapshots:
|
|||||||
|
|
||||||
vary@1.1.2: {}
|
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:
|
dependencies:
|
||||||
debug: 4.4.3(supports-color@7.2.0)
|
debug: 4.4.3(supports-color@7.2.0)
|
||||||
globrex: 0.1.2
|
globrex: 0.1.2
|
||||||
tsconfck: 3.1.6(typescript@6.0.3)
|
tsconfck: 3.1.6(typescript@6.0.3)
|
||||||
optionalDependencies:
|
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:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
- typescript
|
- 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:
|
dependencies:
|
||||||
lightningcss: 1.33.0
|
lightningcss: 1.33.0
|
||||||
picomatch: 4.0.7
|
picomatch: 4.0.7
|
||||||
@ -4643,12 +4702,13 @@ snapshots:
|
|||||||
esbuild: 0.28.2
|
esbuild: 0.28.2
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
jiti: 2.7.0
|
jiti: 2.7.0
|
||||||
|
tsx: 4.23.15
|
||||||
yaml: 2.9.1
|
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:
|
dependencies:
|
||||||
'@vitest/expect': 4.1.11
|
'@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/pretty-format': 4.1.11
|
||||||
'@vitest/runner': 4.1.11
|
'@vitest/runner': 4.1.11
|
||||||
'@vitest/snapshot': 4.1.11
|
'@vitest/snapshot': 4.1.11
|
||||||
@ -4665,7 +4725,7 @@ snapshots:
|
|||||||
tinyexec: 1.3.1
|
tinyexec: 1.3.1
|
||||||
tinyglobby: 0.2.17
|
tinyglobby: 0.2.17
|
||||||
tinyrainbow: 3.2.0
|
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
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 24.19.1
|
'@types/node': 24.19.1
|
||||||
|
|||||||
181
prisma/seed.ts
Normal file
181
prisma/seed.ts
Normal 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();
|
||||||
|
});
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
import { PrismaModule } from './prisma/prisma.module.js';
|
import { PrismaModule } from './prisma/prisma.module.js';
|
||||||
|
import { RedisModule } from './common/redis/redis.module.js';
|
||||||
import { createLoggerModule } from './logging/logger.config.js';
|
import { createLoggerModule } from './logging/logger.config.js';
|
||||||
import { AuthModule } from './auth/auth.module.js';
|
import { AuthModule } from './auth/auth.module.js';
|
||||||
import { JwtAuthGuard } from './auth/jwt-auth.guard.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 { RequestContextInterceptor } from './auth/request-context.interceptor.js';
|
||||||
import { BodyLoggingInterceptor } from './logging/body-logging.interceptor.js';
|
import { BodyLoggingInterceptor } from './logging/body-logging.interceptor.js';
|
||||||
import { TransformInterceptor } from './common/interceptors/transform.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 { AllExceptionsFilter } from './common/filters/http-exception.filter.js';
|
||||||
import { TenantsModule } from './tenants/tenants.module.js';
|
import { TenantsModule } from './tenants/tenants.module.js';
|
||||||
import { UsersModule } from './users/users.module.js';
|
import { UsersModule } from './users/users.module.js';
|
||||||
@ -25,6 +27,7 @@ import { AppService } from './app.service.js';
|
|||||||
imports: [
|
imports: [
|
||||||
createLoggerModule(),
|
createLoggerModule(),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
RedisModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
TenantsModule,
|
TenantsModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
@ -44,6 +47,7 @@ import { AppService } from './app.service.js';
|
|||||||
{ provide: APP_INTERCEPTOR, useClass: RequestContextInterceptor },
|
{ provide: APP_INTERCEPTOR, useClass: RequestContextInterceptor },
|
||||||
{ provide: APP_INTERCEPTOR, useClass: BodyLoggingInterceptor },
|
{ provide: APP_INTERCEPTOR, useClass: BodyLoggingInterceptor },
|
||||||
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
|
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
|
||||||
|
{ provide: APP_INTERCEPTOR, useClass: AuditLogInterceptor },
|
||||||
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
|
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { Controller, Get, Query } from '@nestjs/common';
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
import { PageQuery } from '../common/page.dto.js';
|
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';
|
import { AuditLogsService } from './audit-logs.service.js';
|
||||||
|
|
||||||
@Controller('audit-logs')
|
@Controller('audit-logs')
|
||||||
@ -7,6 +9,7 @@ export class AuditLogsController {
|
|||||||
constructor(private readonly service: AuditLogsService) {}
|
constructor(private readonly service: AuditLogsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequirePermissions(Permissions.audit.list)
|
||||||
list(@Query() q: PageQuery) {
|
list(@Query() q: PageQuery) {
|
||||||
return this.service.paginate(q.page, q.pageSize);
|
return this.service.paginate(q.page, q.pageSize);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,5 +5,6 @@ import { AuditLogsService } from './audit-logs.service.js';
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [AuditLogsController],
|
controllers: [AuditLogsController],
|
||||||
providers: [AuditLogsService],
|
providers: [AuditLogsService],
|
||||||
|
exports: [AuditLogsService],
|
||||||
})
|
})
|
||||||
export class AuditLogsModule {}
|
export class AuditLogsModule {}
|
||||||
|
|||||||
@ -1,10 +1,40 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
|
||||||
import { BaseCrudService } from '../common/base-crud.service.js';
|
import { BaseCrudService } from '../common/base-crud.service.js';
|
||||||
import { PrismaService } from '../prisma/prisma.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()
|
@Injectable()
|
||||||
export class AuditLogsService extends BaseCrudService {
|
export class AuditLogsService extends BaseCrudService {
|
||||||
constructor(prisma: PrismaService) {
|
constructor(
|
||||||
|
prisma: PrismaService,
|
||||||
|
@InjectPinoLogger(AuditLogsService.name) private readonly logger: PinoLogger,
|
||||||
|
) {
|
||||||
super(prisma, 'auditLog');
|
super(prisma, 'auditLog');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 写入一条审计日志;失败仅告警,不抛出(异步审计不阻塞业务)。 */
|
||||||
|
async record(data: AuditLogEntry): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.db().create({ data });
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn({ err }, '审计日志写入失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { AuthService } from './auth.service.js';
|
||||||
import { LoginDto, RefreshDto } from './dto.js';
|
import { LoginDto, RefreshDto } from './dto.js';
|
||||||
import { Public } from './decorators.js';
|
import { Public } from './decorators.js';
|
||||||
@ -9,8 +10,8 @@ export class AuthController {
|
|||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('login')
|
@Post('login')
|
||||||
login(@Body() dto: LoginDto) {
|
login(@Body() dto: LoginDto, @Req() req: Request) {
|
||||||
return this.auth.login(dto);
|
return this.auth.login(dto, req.ip, req.headers['user-agent']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@ -21,7 +22,7 @@ export class AuthController {
|
|||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
logout(@Body() dto: RefreshDto) {
|
logout(@Body() dto: RefreshDto, @Req() req: Request) {
|
||||||
return this.auth.logout(dto);
|
return this.auth.logout(dto, req.ip, req.headers['user-agent']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
|
|||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { AuthController } from './auth.controller.js';
|
import { AuthController } from './auth.controller.js';
|
||||||
import { AuthService } from './auth.service.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -10,6 +12,8 @@ import { AuthService } from './auth.service.js';
|
|||||||
secret: process.env.JWT_SECRET ?? 'dev-secret-change-me',
|
secret: process.env.JWT_SECRET ?? 'dev-secret-change-me',
|
||||||
signOptions: { expiresIn: Number(process.env.JWT_ACCESS_TTL ?? 7200) },
|
signOptions: { expiresIn: Number(process.env.JWT_ACCESS_TTL ?? 7200) },
|
||||||
}),
|
}),
|
||||||
|
AuditLogsModule,
|
||||||
|
LoginLogsModule,
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService],
|
providers: [AuthService],
|
||||||
|
|||||||
@ -3,34 +3,72 @@ import { JwtService } from '@nestjs/jwt';
|
|||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { createHash, randomUUID } from 'node:crypto';
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
import { PrismaService } from '../prisma/prisma.service.js';
|
import { PrismaService } from '../prisma/prisma.service.js';
|
||||||
|
import { RedisService } from '../common/redis/redis.service.js';
|
||||||
import { LoginDto, RefreshDto } from './dto.js';
|
import { LoginDto, RefreshDto } from './dto.js';
|
||||||
import { resolveDataScope, resolveVisibleOrgs } from '../common/data-scope.js';
|
import { resolveDataScope, resolveVisibleOrgs } from '../common/data-scope.js';
|
||||||
import type { DataScope } from '../common/enums.js';
|
import type { DataScope } from '../common/enums.js';
|
||||||
import type { RequestContext } from '../common/context/request-context.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 REFRESH_TTL = Number(process.env.JWT_REFRESH_TTL ?? 604800);
|
||||||
const ACCESS_TTL = Number(process.env.JWT_ACCESS_TTL ?? 7200);
|
const ACCESS_TTL = Number(process.env.JWT_ACCESS_TTL ?? 7200);
|
||||||
|
const SUPER_ROLE = 'super_admin';
|
||||||
|
|
||||||
|
export interface PermissionSet {
|
||||||
|
super: boolean;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly jwt: JwtService,
|
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 } });
|
const tenant = await this.prisma.client.tenant.findUnique({ where: { code: dto.tenantCode } });
|
||||||
if (!tenant || tenant.status !== 'enabled') {
|
if (!tenant || tenant.status !== 'enabled') {
|
||||||
|
await this.recordLoginLog(dto.tenantCode, dto.username, 'fail', '租户不存在或已停用', ip, userAgent);
|
||||||
throw new UnauthorizedException('租户不存在或已停用');
|
throw new UnauthorizedException('租户不存在或已停用');
|
||||||
}
|
}
|
||||||
const user = await this.prisma.client.user.findFirst({
|
const user = await this.prisma.client.user.findFirst({
|
||||||
where: { tenantCode: dto.tenantCode, username: dto.username, deletedAt: null },
|
where: { tenantCode: dto.tenantCode, username: dto.username, deletedAt: null },
|
||||||
});
|
});
|
||||||
if (!user) throw new UnauthorizedException('账号或密码错误');
|
if (!user) {
|
||||||
if (user.status !== 'enabled') throw new UnauthorizedException('账号已停用或锁定');
|
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);
|
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);
|
return this.issue(dto.tenantCode, user.id, user.username);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,25 +82,44 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
if (!user || user.status !== 'enabled') throw new UnauthorizedException('账号已失效');
|
if (!user || user.status !== 'enabled') throw new UnauthorizedException('账号已失效');
|
||||||
|
|
||||||
// 旋转:作废旧 refresh
|
// 旋转:作废旧 refresh,并重聚合权限集(权限变更在刷新时生效)
|
||||||
await this.prisma.client.refreshToken.update({
|
await this.prisma.client.refreshToken.update({
|
||||||
where: { jti: dto.refreshToken },
|
where: { jti: dto.refreshToken },
|
||||||
data: { revokedAt: new Date() },
|
data: { revokedAt: new Date() },
|
||||||
});
|
});
|
||||||
|
await this.writePermissionCache(record.tenantCode, user.id);
|
||||||
return this.issue(record.tenantCode, user.id, user.username);
|
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 } });
|
const record = await this.prisma.client.refreshToken.findUnique({ where: { jti: dto.refreshToken } });
|
||||||
if (record) {
|
if (record) {
|
||||||
await this.prisma.client.refreshToken.update({
|
await this.prisma.client.refreshToken.update({
|
||||||
where: { jti: dto.refreshToken },
|
where: { jti: dto.refreshToken },
|
||||||
data: { revokedAt: new Date() },
|
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;
|
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 调用,构建当前请求的租户/用户/数据权限上下文。 */
|
/** 由 RequestContextInterceptor 调用,构建当前请求的租户/用户/数据权限上下文。 */
|
||||||
async buildContext(user: { userId: string; tenantCode: string; username: string }): Promise<RequestContext> {
|
async buildContext(user: { userId: string; tenantCode: string; username: string }): Promise<RequestContext> {
|
||||||
const u = await this.prisma.client.user.findFirst({
|
const u = await this.prisma.client.user.findFirst({
|
||||||
@ -76,24 +133,93 @@ export class AuthService {
|
|||||||
select: { roleCode: true },
|
select: { roleCode: true },
|
||||||
});
|
});
|
||||||
const roleCodes = roleRows.map((r) => r.roleCode);
|
const roleCodes = roleRows.map((r) => r.roleCode);
|
||||||
|
const isSuper = roleCodes.includes(SUPER_ROLE);
|
||||||
const roles = roleCodes.length
|
const roles = roleCodes.length
|
||||||
? await this.prisma.client.role.findMany({
|
? await this.prisma.client.role.findMany({
|
||||||
where: { tenantCode: user.tenantCode, code: { in: roleCodes }, deletedAt: null },
|
where: { tenantCode: user.tenantCode, code: { in: roleCodes }, deletedAt: null },
|
||||||
select: { dataScope: true },
|
select: { dataScope: true },
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
const dataScope = resolveDataScope(roles.map((r) => r.dataScope as DataScope));
|
const dataScope: DataScope = isSuper ? 'all' : resolveDataScope(roles.map((r) => r.dataScope as DataScope));
|
||||||
const orgCodes = await resolveVisibleOrgs({
|
const orgCodes = isSuper
|
||||||
prisma: this.prisma.client,
|
? []
|
||||||
tenantCode: user.tenantCode,
|
: await resolveVisibleOrgs({
|
||||||
dataScope,
|
prisma: this.prisma.client,
|
||||||
orgCode,
|
tenantCode: user.tenantCode,
|
||||||
roleCodes,
|
dataScope,
|
||||||
});
|
orgCode,
|
||||||
|
roleCodes,
|
||||||
|
});
|
||||||
|
|
||||||
return { tenantCode: user.tenantCode, userId: user.userId, username: user.username, dataScope, orgCode, orgCodes };
|
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(
|
private async issue(
|
||||||
tenantCode: string,
|
tenantCode: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@ -8,6 +8,14 @@ export const PERMISSIONS_KEY = 'permissions';
|
|||||||
/** 要求当前用户具备给定权限标识(resource code)。 */
|
/** 要求当前用户具备给定权限标识(resource code)。 */
|
||||||
export const RequirePermissions = (...permissions: string[]) => SetMetadata(PERMISSIONS_KEY, permissions);
|
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) => {
|
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
|
||||||
return ctx.switchToHttp().getRequest().user;
|
return ctx.switchToHttp().getRequest().user;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import { PrismaService } from '../prisma/prisma.service.js';
|
import { AuthService } from './auth.service.js';
|
||||||
import { PERMISSIONS_KEY } from './decorators.js';
|
import { PERMISSIONS_KEY } from './decorators.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PermissionsGuard implements CanActivate {
|
export class PermissionsGuard implements CanActivate {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly auth: AuthService,
|
||||||
private readonly reflector: Reflector,
|
private readonly reflector: Reflector,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -21,17 +21,9 @@ export class PermissionsGuard implements CanActivate {
|
|||||||
const user = request.user as { userId: string; tenantCode: string } | undefined;
|
const user = request.user as { userId: string; tenantCode: string } | undefined;
|
||||||
if (!user) throw new ForbiddenException('无权限');
|
if (!user) throw new ForbiddenException('无权限');
|
||||||
|
|
||||||
const roleRows = await this.prisma.client.userRole.findMany({
|
const { super: isSuper, permissions } = await this.auth.getPermissionSet(user.tenantCode, user.userId);
|
||||||
where: { tenantCode: user.tenantCode, userId: user.userId },
|
if (isSuper) return true;
|
||||||
select: { roleCode: true },
|
if (required.every((code) => permissions.includes(code))) return true;
|
||||||
});
|
throw new ForbiddenException('无权限');
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
58
src/common/interceptors/audit-log.interceptor.ts
Normal file
58
src/common/interceptors/audit-log.interceptor.ts
Normal 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
25
src/common/mask.ts
Normal 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
57
src/common/permissions.ts
Normal 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),
|
||||||
|
);
|
||||||
9
src/common/redis/redis.module.ts
Normal file
9
src/common/redis/redis.module.ts
Normal 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 {}
|
||||||
56
src/common/redis/redis.service.ts
Normal file
56
src/common/redis/redis.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||||
import { PageQuery } from '../common/page.dto.js';
|
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 { CreateConfigDto, UpdateConfigDto } from './dto.js';
|
||||||
import { ConfigsService } from './configs.service.js';
|
import { ConfigsService } from './configs.service.js';
|
||||||
|
|
||||||
@ -8,26 +10,34 @@ export class ConfigsController {
|
|||||||
constructor(private readonly service: ConfigsService) {}
|
constructor(private readonly service: ConfigsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequirePermissions(Permissions.config.list)
|
||||||
list(@Query() q: PageQuery) {
|
list(@Query() q: PageQuery) {
|
||||||
return this.service.paginate(q.page, q.pageSize);
|
return this.service.paginate(q.page, q.pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequirePermissions(Permissions.config.list)
|
||||||
get(@Param('id') id: string) {
|
get(@Param('id') id: string) {
|
||||||
return this.service.findById(id);
|
return this.service.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequirePermissions(Permissions.config.create)
|
||||||
|
@AuditLog('config', 'create')
|
||||||
create(@Body() dto: CreateConfigDto) {
|
create(@Body() dto: CreateConfigDto) {
|
||||||
return this.service.createConfig(dto);
|
return this.service.createConfig(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@RequirePermissions(Permissions.config.update)
|
||||||
|
@AuditLog('config', 'update')
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateConfigDto) {
|
update(@Param('id') id: string, @Body() dto: UpdateConfigDto) {
|
||||||
return this.service.updateConfig(id, dto);
|
return this.service.updateConfig(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@RequirePermissions(Permissions.config.delete)
|
||||||
|
@AuditLog('config', 'delete')
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.service.softRemove(id);
|
return this.service.softRemove(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
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 { CreateDictItemDto, UpdateDictItemDto } from './dto.js';
|
||||||
import { DictItemsService } from './dict-items.service.js';
|
import { DictItemsService } from './dict-items.service.js';
|
||||||
|
|
||||||
@ -7,26 +9,34 @@ export class DictItemsController {
|
|||||||
constructor(private readonly service: DictItemsService) {}
|
constructor(private readonly service: DictItemsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequirePermissions(Permissions.dict.list)
|
||||||
list(@Query('dictTypeCode') dictTypeCode?: string) {
|
list(@Query('dictTypeCode') dictTypeCode?: string) {
|
||||||
return this.service.listByType(dictTypeCode);
|
return this.service.listByType(dictTypeCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequirePermissions(Permissions.dict.list)
|
||||||
get(@Param('id') id: string) {
|
get(@Param('id') id: string) {
|
||||||
return this.service.findById(id);
|
return this.service.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequirePermissions(Permissions.dict.create)
|
||||||
|
@AuditLog('dict', 'create')
|
||||||
create(@Body() dto: CreateDictItemDto) {
|
create(@Body() dto: CreateDictItemDto) {
|
||||||
return this.service.createItem(dto);
|
return this.service.createItem(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@RequirePermissions(Permissions.dict.update)
|
||||||
|
@AuditLog('dict', 'update')
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateDictItemDto) {
|
update(@Param('id') id: string, @Body() dto: UpdateDictItemDto) {
|
||||||
return this.service.updateItem(id, dto);
|
return this.service.updateItem(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@RequirePermissions(Permissions.dict.delete)
|
||||||
|
@AuditLog('dict', 'delete')
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.service.softRemove(id);
|
return this.service.softRemove(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||||
import { PageQuery } from '../common/page.dto.js';
|
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 { CreateDictTypeDto, UpdateDictTypeDto } from './dto.js';
|
||||||
import { DictTypesService } from './dict-types.service.js';
|
import { DictTypesService } from './dict-types.service.js';
|
||||||
|
|
||||||
@ -8,26 +10,34 @@ export class DictTypesController {
|
|||||||
constructor(private readonly service: DictTypesService) {}
|
constructor(private readonly service: DictTypesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequirePermissions(Permissions.dict.list)
|
||||||
list(@Query() q: PageQuery) {
|
list(@Query() q: PageQuery) {
|
||||||
return this.service.paginate(q.page, q.pageSize);
|
return this.service.paginate(q.page, q.pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequirePermissions(Permissions.dict.list)
|
||||||
get(@Param('id') id: string) {
|
get(@Param('id') id: string) {
|
||||||
return this.service.findById(id);
|
return this.service.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequirePermissions(Permissions.dict.create)
|
||||||
|
@AuditLog('dict', 'create')
|
||||||
create(@Body() dto: CreateDictTypeDto) {
|
create(@Body() dto: CreateDictTypeDto) {
|
||||||
return this.service.createType(dto);
|
return this.service.createType(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@RequirePermissions(Permissions.dict.update)
|
||||||
|
@AuditLog('dict', 'update')
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateDictTypeDto) {
|
update(@Param('id') id: string, @Body() dto: UpdateDictTypeDto) {
|
||||||
return this.service.updateType(id, dto);
|
return this.service.updateType(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@RequirePermissions(Permissions.dict.delete)
|
||||||
|
@AuditLog('dict', 'delete')
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.service.softRemove(id);
|
return this.service.softRemove(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,32 +1,7 @@
|
|||||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
|
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
|
||||||
|
import { maskSensitive } from '../common/mask.js';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 记录请求 body(POST/PUT/PATCH/DELETE),敏感字段脱敏,级别 debug。 */
|
/** 记录请求 body(POST/PUT/PATCH/DELETE),敏感字段脱敏,级别 debug。 */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -37,7 +12,7 @@ export class BodyLoggingInterceptor implements NestInterceptor {
|
|||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const { method, url, body } = request;
|
const { method, url, body } = request;
|
||||||
if (body && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) && Object.keys(body).length > 0) {
|
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();
|
return next.handle();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { Controller, Get, Query } from '@nestjs/common';
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
import { PageQuery } from '../common/page.dto.js';
|
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';
|
import { LoginLogsService } from './login-logs.service.js';
|
||||||
|
|
||||||
@Controller('login-logs')
|
@Controller('login-logs')
|
||||||
@ -7,6 +9,7 @@ export class LoginLogsController {
|
|||||||
constructor(private readonly service: LoginLogsService) {}
|
constructor(private readonly service: LoginLogsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequirePermissions(Permissions.loginLog.list)
|
||||||
list(@Query() q: PageQuery) {
|
list(@Query() q: PageQuery) {
|
||||||
return this.service.paginate(q.page, q.pageSize);
|
return this.service.paginate(q.page, q.pageSize);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,5 +5,6 @@ import { LoginLogsService } from './login-logs.service.js';
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [LoginLogsController],
|
controllers: [LoginLogsController],
|
||||||
providers: [LoginLogsService],
|
providers: [LoginLogsService],
|
||||||
|
exports: [LoginLogsService],
|
||||||
})
|
})
|
||||||
export class LoginLogsModule {}
|
export class LoginLogsModule {}
|
||||||
|
|||||||
@ -1,10 +1,32 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
|
||||||
import { BaseCrudService } from '../common/base-crud.service.js';
|
import { BaseCrudService } from '../common/base-crud.service.js';
|
||||||
import { PrismaService } from '../prisma/prisma.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()
|
@Injectable()
|
||||||
export class LoginLogsService extends BaseCrudService {
|
export class LoginLogsService extends BaseCrudService {
|
||||||
constructor(prisma: PrismaService) {
|
constructor(
|
||||||
|
prisma: PrismaService,
|
||||||
|
@InjectPinoLogger(LoginLogsService.name) private readonly logger: PinoLogger,
|
||||||
|
) {
|
||||||
super(prisma, 'loginLog');
|
super(prisma, 'loginLog');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 写入一条登录日志;失败仅告警,不抛出。 */
|
||||||
|
async record(data: LoginLogEntry): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.db().create({ data });
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn({ err }, '登录日志写入失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
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 { CreateOrgDto, UpdateOrgDto } from './dto.js';
|
||||||
import { OrgsService } from './orgs.service.js';
|
import { OrgsService } from './orgs.service.js';
|
||||||
|
|
||||||
@ -7,26 +9,34 @@ export class OrgsController {
|
|||||||
constructor(private readonly service: OrgsService) {}
|
constructor(private readonly service: OrgsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequirePermissions(Permissions.org.list)
|
||||||
list() {
|
list() {
|
||||||
return this.service.list({}, { sort: 'asc' });
|
return this.service.list({}, { sort: 'asc' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequirePermissions(Permissions.org.list)
|
||||||
get(@Param('id') id: string) {
|
get(@Param('id') id: string) {
|
||||||
return this.service.findById(id);
|
return this.service.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequirePermissions(Permissions.org.create)
|
||||||
|
@AuditLog('org', 'create')
|
||||||
create(@Body() dto: CreateOrgDto) {
|
create(@Body() dto: CreateOrgDto) {
|
||||||
return this.service.createOrg(dto);
|
return this.service.createOrg(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@RequirePermissions(Permissions.org.update)
|
||||||
|
@AuditLog('org', 'update')
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateOrgDto) {
|
update(@Param('id') id: string, @Body() dto: UpdateOrgDto) {
|
||||||
return this.service.updateOrg(id, dto);
|
return this.service.updateOrg(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@RequirePermissions(Permissions.org.delete)
|
||||||
|
@AuditLog('org', 'delete')
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.service.softRemove(id);
|
return this.service.softRemove(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
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 { CreateResourceDto, UpdateResourceDto } from './dto.js';
|
||||||
import { ResourcesService } from './resources.service.js';
|
import { ResourcesService } from './resources.service.js';
|
||||||
|
|
||||||
@ -7,26 +9,34 @@ export class ResourcesController {
|
|||||||
constructor(private readonly service: ResourcesService) {}
|
constructor(private readonly service: ResourcesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequirePermissions(Permissions.resource.list)
|
||||||
list() {
|
list() {
|
||||||
return this.service.list({}, { sort: 'asc' });
|
return this.service.list({}, { sort: 'asc' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequirePermissions(Permissions.resource.list)
|
||||||
get(@Param('id') id: string) {
|
get(@Param('id') id: string) {
|
||||||
return this.service.findById(id);
|
return this.service.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequirePermissions(Permissions.resource.create)
|
||||||
|
@AuditLog('resource', 'create')
|
||||||
create(@Body() dto: CreateResourceDto) {
|
create(@Body() dto: CreateResourceDto) {
|
||||||
return this.service.createResource(dto);
|
return this.service.createResource(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@RequirePermissions(Permissions.resource.update)
|
||||||
|
@AuditLog('resource', 'update')
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateResourceDto) {
|
update(@Param('id') id: string, @Body() dto: UpdateResourceDto) {
|
||||||
return this.service.updateResource(id, dto);
|
return this.service.updateResource(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@RequirePermissions(Permissions.resource.delete)
|
||||||
|
@AuditLog('resource', 'delete')
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.service.softRemove(id);
|
return this.service.softRemove(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
||||||
import { PageQuery } from '../common/page.dto.js';
|
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 { AssignResourcesDto, CreateRoleDto, UpdateRoleDto } from './dto.js';
|
||||||
import { RolesService } from './roles.service.js';
|
import { RolesService } from './roles.service.js';
|
||||||
|
|
||||||
@ -8,36 +10,47 @@ export class RolesController {
|
|||||||
constructor(private readonly service: RolesService) {}
|
constructor(private readonly service: RolesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequirePermissions(Permissions.role.list)
|
||||||
list(@Query() q: PageQuery) {
|
list(@Query() q: PageQuery) {
|
||||||
return this.service.paginate(q.page, q.pageSize);
|
return this.service.paginate(q.page, q.pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequirePermissions(Permissions.role.list)
|
||||||
get(@Param('id') id: string) {
|
get(@Param('id') id: string) {
|
||||||
return this.service.findById(id);
|
return this.service.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequirePermissions(Permissions.role.create)
|
||||||
|
@AuditLog('role', 'create')
|
||||||
create(@Body() dto: CreateRoleDto) {
|
create(@Body() dto: CreateRoleDto) {
|
||||||
return this.service.createRole(dto);
|
return this.service.createRole(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@RequirePermissions(Permissions.role.update)
|
||||||
|
@AuditLog('role', 'update')
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateRoleDto) {
|
update(@Param('id') id: string, @Body() dto: UpdateRoleDto) {
|
||||||
return this.service.updateRole(id, dto);
|
return this.service.updateRole(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@RequirePermissions(Permissions.role.delete)
|
||||||
|
@AuditLog('role', 'delete')
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.service.softRemove(id);
|
return this.service.softRemove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/resources')
|
@Get(':id/resources')
|
||||||
|
@RequirePermissions(Permissions.role.list)
|
||||||
resources(@Param('id') id: string) {
|
resources(@Param('id') id: string) {
|
||||||
return this.service.assignedResources(id);
|
return this.service.assignedResources(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id/resources')
|
@Put(':id/resources')
|
||||||
|
@RequirePermissions(Permissions.role.assignResource)
|
||||||
|
@AuditLog('role', 'assign-resource')
|
||||||
assign(@Param('id') id: string, @Body() dto: AssignResourcesDto) {
|
assign(@Param('id') id: string, @Body() dto: AssignResourcesDto) {
|
||||||
return this.service.assignResources(id, dto);
|
return this.service.assignResources(id, dto);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { BaseCrudService } from '../common/base-crud.service.js';
|
import { BaseCrudService } from '../common/base-crud.service.js';
|
||||||
import { PrismaService } from '../prisma/prisma.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 { getRequestContext } from '../common/context/request-context.js';
|
||||||
import { AssignResourcesDto, CreateRoleDto, UpdateRoleDto } from './dto.js';
|
import { AssignResourcesDto, CreateRoleDto, UpdateRoleDto } from './dto.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RolesService extends BaseCrudService {
|
export class RolesService extends BaseCrudService {
|
||||||
constructor(prisma: PrismaService) {
|
constructor(
|
||||||
|
prisma: PrismaService,
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
) {
|
||||||
super(prisma, 'role');
|
super(prisma, 'role');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,6 +39,8 @@ export class RolesService extends BaseCrudService {
|
|||||||
data: dto.resourceCodes.map((resourceCode) => ({ tenantCode: ctx.tenantCode, roleCode, resourceCode })),
|
data: dto.resourceCodes.map((resourceCode) => ({ tenantCode: ctx.tenantCode, roleCode, resourceCode })),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// 授权变更:删除该租户全部用户权限缓存,下一次请求守卫重新聚合并回填
|
||||||
|
await this.redis.delByPrefix(`perm:${ctx.tenantCode}:`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||||
import { PageQuery } from '../common/page.dto.js';
|
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 { CreateTenantDto, UpdateTenantDto } from './dto.js';
|
||||||
import { TenantsService } from './tenants.service.js';
|
import { TenantsService } from './tenants.service.js';
|
||||||
|
|
||||||
@ -8,26 +10,34 @@ export class TenantsController {
|
|||||||
constructor(private readonly service: TenantsService) {}
|
constructor(private readonly service: TenantsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequirePermissions(Permissions.tenant.list)
|
||||||
list(@Query() q: PageQuery) {
|
list(@Query() q: PageQuery) {
|
||||||
return this.service.paginate(q.page, q.pageSize);
|
return this.service.paginate(q.page, q.pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequirePermissions(Permissions.tenant.list)
|
||||||
get(@Param('id') id: string) {
|
get(@Param('id') id: string) {
|
||||||
return this.service.findById(id);
|
return this.service.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequirePermissions(Permissions.tenant.create)
|
||||||
|
@AuditLog('tenant', 'create')
|
||||||
create(@Body() dto: CreateTenantDto) {
|
create(@Body() dto: CreateTenantDto) {
|
||||||
return this.service.createTenant(dto);
|
return this.service.createTenant(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@RequirePermissions(Permissions.tenant.update)
|
||||||
|
@AuditLog('tenant', 'update')
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateTenantDto) {
|
update(@Param('id') id: string, @Body() dto: UpdateTenantDto) {
|
||||||
return this.service.updateTenant(id, dto);
|
return this.service.updateTenant(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@RequirePermissions(Permissions.tenant.delete)
|
||||||
|
@AuditLog('tenant', 'delete')
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.service.removeTenant(id);
|
return this.service.removeTenant(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||||
import { PageQuery } from '../common/page.dto.js';
|
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 { CreateUserDto, ResetPasswordDto, UpdateUserDto } from './dto.js';
|
||||||
import { UsersService } from './users.service.js';
|
import { UsersService } from './users.service.js';
|
||||||
|
|
||||||
@ -8,31 +10,41 @@ export class UsersController {
|
|||||||
constructor(private readonly service: UsersService) {}
|
constructor(private readonly service: UsersService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequirePermissions(Permissions.user.list)
|
||||||
list(@Query() q: PageQuery) {
|
list(@Query() q: PageQuery) {
|
||||||
return this.service.listUsers(q);
|
return this.service.listUsers(q);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequirePermissions(Permissions.user.list)
|
||||||
get(@Param('id') id: string) {
|
get(@Param('id') id: string) {
|
||||||
return this.service.findById(id);
|
return this.service.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequirePermissions(Permissions.user.create)
|
||||||
|
@AuditLog('user', 'create')
|
||||||
create(@Body() dto: CreateUserDto) {
|
create(@Body() dto: CreateUserDto) {
|
||||||
return this.service.createUser(dto);
|
return this.service.createUser(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@RequirePermissions(Permissions.user.update)
|
||||||
|
@AuditLog('user', 'update')
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateUserDto) {
|
update(@Param('id') id: string, @Body() dto: UpdateUserDto) {
|
||||||
return this.service.updateUser(id, dto);
|
return this.service.updateUser(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/password')
|
@Patch(':id/password')
|
||||||
|
@RequirePermissions(Permissions.user.resetPwd)
|
||||||
|
@AuditLog('user', 'reset-pwd')
|
||||||
resetPassword(@Param('id') id: string, @Body() dto: ResetPasswordDto) {
|
resetPassword(@Param('id') id: string, @Body() dto: ResetPasswordDto) {
|
||||||
return this.service.resetPassword(id, dto);
|
return this.service.resetPassword(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@RequirePermissions(Permissions.user.delete)
|
||||||
|
@AuditLog('user', 'delete')
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.service.softRemove(id);
|
return this.service.softRemove(id);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user