Compare commits

..

No commits in common. "9a9435e66565b0402a1246e35b7858cb3439f77b" and "ce2aa35a24ab351433ae7bb98aba2cab38c7e676" have entirely different histories.

58 changed files with 647 additions and 1445 deletions

View File

@ -1,9 +0,0 @@
FROM node:22-alpine
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm exec prisma generate && pnpm build
EXPOSE 3000
CMD ["node", "dist/main.js"]

View File

@ -2,10 +2,10 @@
"name": "sysmgr-nest",
"version": "0.0.1",
"packageManager": "pnpm@12.10.1",
"description": "通用系统管理脚手架 - 后端服务",
"description": "",
"author": "",
"private": true,
"license": "MIT",
"license": "UNLICENSED",
"type": "module",
"prisma": {
"seed": "tsx prisma/seed.ts"
@ -15,6 +15,7 @@
},
"scripts": {
"build": "nest build",
"deploy": "nest deploy",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
@ -33,14 +34,11 @@
"@nestjs/core": "^12.0.1",
"@nestjs/jwt": "^12.0.2",
"@nestjs/platform-express": "^12.0.1",
"@nestjs/schedule": "^12.0.2",
"@nestjs/swagger": "^12.0.2",
"@nestjs/throttler": "^6.7.1",
"@prisma/client": "^6.19.3",
"bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"helmet": "^8.3.0",
"ioredis": "^6.0.0",
"nestjs-pino": "^5.3.1",
"pino": "^10.4.0",
@ -51,6 +49,7 @@
},
"devDependencies": {
"@nestjs/cli": "^12.0.0",
"@nestjs/mau": "^0.2.6",
"@nestjs/schematics": "^12.0.0",
"@nestjs/testing": "^12.0.1",
"@types/bcryptjs": "^3.0.0",

408
pnpm-lock.yaml generated
View File

@ -178,15 +178,9 @@ importers:
'@nestjs/platform-express':
specifier: ^12.0.1
version: 12.1.2(@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)(supports-color@7.2.0)
'@nestjs/schedule':
specifier: ^12.0.2
version: 12.0.2(@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)
'@nestjs/swagger':
specifier: ^12.0.2
version: 12.0.2(@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)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(typescript@6.0.3)
'@nestjs/throttler':
specifier: ^6.7.1
version: 6.7.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)(reflect-metadata@0.2.2)
'@prisma/client':
specifier: ^6.19.3
version: 6.19.3(prisma@6.19.3(typescript@6.0.3))(typescript@6.0.3)
@ -199,9 +193,6 @@ importers:
class-validator:
specifier: ^0.15.1
version: 0.15.1
helmet:
specifier: ^8.3.0
version: 8.3.0
ioredis:
specifier: ^6.0.0
version: 6.0.0(supports-color@7.2.0)
@ -227,6 +218,9 @@ importers:
'@nestjs/cli':
specifier: ^12.0.0
version: 12.0.8(@types/node@24.19.1)(prettier@3.9.9)
'@nestjs/mau':
specifier: ^0.2.6
version: 0.2.8
'@nestjs/schematics':
specifier: ^12.0.0
version: 12.0.6(chokidar@5.0.0)(prettier@3.9.9)(typescript@6.0.3)
@ -721,19 +715,17 @@ packages:
class-validator:
optional: true
'@nestjs/mau@0.2.8':
resolution: {integrity: sha512-nIb0i3c/KhKEbxZfYMEKv9Mcvk5miBdZcexnvkgnAUhoMO2BUjOGT+Khf2cZzu7B51Y7v01NG2JVl/HdQEUQ6w==}
engines: {node: '>= 20.0.0'}
hasBin: true
'@nestjs/platform-express@12.1.2':
resolution: {integrity: sha512-//ftPv5+UONizm5zcme/AP91EFo1EsqFFyC2I3HC2w8swYVXadHKW1L3A2mgUuOhM1sdKkd2gzjQmL9504x85Q==}
peerDependencies:
'@nestjs/common': ^12.0.0
'@nestjs/core': ^12.0.0
'@nestjs/schedule@12.0.2':
resolution: {integrity: sha512-5iAvJEtk0njbfTrG4JQr+SXw8ZrqgDM1wJH9nUs6tVLCX5pWGd1LJ0a8b089UT1FDeDWaThRRfSLixFz9zztcA==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@nestjs/common': ^11.0.0 || ^12.0.0
'@nestjs/core': ^11.0.0 || ^12.0.0
'@nestjs/schematics@12.0.6':
resolution: {integrity: sha512-2OG0BUhGPJqVvWFhiy+7oaQa4VQUvTS9Dw8YRfvweznl4KS3YyZrdPi4cVGuRAThoEIsbdfguZzSOv1+t8U1tg==}
engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0}
@ -778,14 +770,6 @@ packages:
'@nestjs/platform-express':
optional: true
'@nestjs/throttler@6.7.1':
resolution: {integrity: sha512-gAyY1AkU+cSK5DCjFOB43xax9oakameH4xZa9kAgqBqSBQZgmv3DZylLV8KyZNzOGhceL3kDVtby+CF8VAdmEA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
peerDependencies:
'@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0
'@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0
reflect-metadata: ^0.1.13 || ^0.2.0
'@noble/hashes@1.8.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
@ -1131,9 +1115,6 @@ packages:
'@types/jsonwebtoken@9.0.10':
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
'@types/luxon@3.7.6':
resolution: {integrity: sha512-6KSjliQAXK8ZLgFQO4B7iE4Rpf/B3rItzCFuXezBY/XIAGxOdLd7vRNK9/StRPwiS/yJsVbB7HKpW46B8tcEuQ==}
'@types/methods@1.1.4':
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
@ -1212,6 +1193,10 @@ packages:
ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
ansi-escapes@4.3.2:
resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==}
engines: {node: '>=8'}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
@ -1220,6 +1205,10 @@ packages:
resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==}
engines: {node: '>=12'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
ansis@4.4.0:
resolution: {integrity: sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==}
engines: {node: '>=14'}
@ -1254,10 +1243,16 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
bcryptjs@3.0.3:
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
hasBin: true
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
body-parser@2.3.0:
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
engines: {node: '>=18'}
@ -1272,6 +1267,9 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
busboy@1.6.0:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'}
@ -1300,6 +1298,10 @@ packages:
resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==}
engines: {node: '>=18'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
chalk@5.6.2:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
@ -1308,6 +1310,9 @@ packages:
resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==}
engines: {node: '>=10'}
chardet@0.7.0:
resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==}
chardet@2.2.0:
resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==}
@ -1331,10 +1336,22 @@ packages:
class-validator@0.15.1:
resolution: {integrity: sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==}
cli-cursor@3.1.0:
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
engines: {node: '>=8'}
cli-cursor@5.0.0:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'}
cli-progress@3.12.0:
resolution: {integrity: sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==}
engines: {node: '>=4'}
cli-spinners@2.9.2:
resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==}
engines: {node: '>=6'}
cli-spinners@3.4.0:
resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==}
engines: {node: '>=18.20'}
@ -1343,14 +1360,29 @@ packages:
resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==}
engines: {node: 10.* || >= 12.*}
cli-width@3.0.0:
resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==}
engines: {node: '>= 10'}
cli-width@4.1.0:
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
engines: {node: '>= 12'}
clone@1.0.4:
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'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
@ -1362,6 +1394,10 @@ packages:
resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==}
engines: {node: '>=22.12.0'}
commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
comment-json@5.0.0:
resolution: {integrity: sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==}
engines: {node: '>= 6'}
@ -1409,10 +1445,6 @@ packages:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'}
cron@4.4.0:
resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==}
engines: {node: '>=18.x'}
date-fns@4.4.0:
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
@ -1432,6 +1464,9 @@ packages:
resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==}
engines: {node: '>=16.0.0'}
defaults@1.0.4:
resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==}
defu@6.1.7:
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
@ -1526,6 +1561,10 @@ packages:
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
escape-string-regexp@1.0.5:
resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
engines: {node: '>=0.8.0'}
esprima@4.0.1:
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
engines: {node: '>=4'}
@ -1549,6 +1588,10 @@ packages:
exsolve@1.1.3:
resolution: {integrity: sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==}
external-editor@3.1.0:
resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==}
engines: {node: '>=4'}
fast-check@3.23.2:
resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
engines: {node: '>=8.0.0'}
@ -1583,6 +1626,10 @@ packages:
picomatch:
optional: true
figures@3.2.0:
resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==}
engines: {node: '>=8'}
file-type@22.1.1:
resolution: {integrity: sha512-sEB0oStmeLGtpmSAD88RDNuLsvhv4iC3u0lE25CUvfCULvj49xoQ3l3K525Vn+ZzsZ6DOLajQCV6RmhwwhqiJg==}
engines: {node: '>=22'}
@ -1658,10 +1705,6 @@ packages:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
helmet@8.3.0:
resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==}
engines: {node: '>=18.0.0'}
help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
@ -1672,6 +1715,10 @@ packages:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
iconv-lite@0.7.3:
resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
engines: {node: '>=0.10.0'}
@ -1682,6 +1729,10 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
inquirer@8.2.6:
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'}
@ -1694,6 +1745,10 @@ packages:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-interactive@1.0.0:
resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==}
engines: {node: '>=8'}
is-interactive@2.0.0:
resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==}
engines: {node: '>=12'}
@ -1701,6 +1756,10 @@ packages:
is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
is-unicode-supported@0.1.0:
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
engines: {node: '>=10'}
is-unicode-supported@2.1.0:
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
engines: {node: '>=18'}
@ -1859,14 +1918,17 @@ packages:
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
log-symbols@4.1.0:
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
engines: {node: '>=10'}
log-symbols@7.0.1:
resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==}
engines: {node: '>=18'}
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@ -1921,6 +1983,10 @@ packages:
engines: {node: '>=4.0.0'}
hasBin: true
mimic-fn@2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
mimic-function@5.0.1:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'}
@ -1939,6 +2005,9 @@ packages:
resolution: {integrity: sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==}
engines: {node: '>= 10.16.0'}
mute-stream@0.0.8:
resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==}
mute-stream@3.0.0:
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
engines: {node: ^20.17.0 || >=22.9.0}
@ -1962,6 +2031,10 @@ packages:
pino-http: ^11.0.0
rxjs: ^7.1.0
node-emoji@2.1.3:
resolution: {integrity: sha512-E2WEOVsgs7O16zsURJ/eH8BqhF029wGpEOnv7Urwdo2wmQanOACwJQh0devF9D9RhoZru0+9JXIS0dBXIAz+lA==}
engines: {node: '>=18'}
node-emoji@2.2.0:
resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==}
engines: {node: '>=18'}
@ -2000,14 +2073,26 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
onetime@5.1.2:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'}
onetime@7.0.0:
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
engines: {node: '>=18'}
ora@5.4.1:
resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
engines: {node: '>=10'}
ora@9.4.1:
resolution: {integrity: sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==}
engines: {node: '>=20'}
os-tmpdir@1.0.2:
resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==}
engines: {node: '>=0.10.0'}
oxlint-tsgolint@7.0.2003:
resolution: {integrity: sha512-VnK4zlqgmgq/7ZcjzCk/WpN8kKFsYGcB85Io9qT3wL4K8Un3RKmJkp793crNETieMusPMKOA4a+Mw2wbteI6TQ==}
hasBin: true
@ -2122,6 +2207,10 @@ packages:
rc9@2.1.2:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
@ -2144,6 +2233,10 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
restore-cursor@3.1.0:
resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
engines: {node: '>=8'}
restore-cursor@5.1.0:
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
engines: {node: '>=18'}
@ -2157,6 +2250,10 @@ packages:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
run-async@2.4.1:
resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==}
engines: {node: '>=0.12.0'}
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
@ -2208,6 +2305,9 @@ packages:
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
@ -2270,6 +2370,9 @@ packages:
resolution: {integrity: sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==}
engines: {node: '>=20'}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
@ -2309,6 +2412,9 @@ packages:
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
engines: {node: '>=20'}
through@2.3.8:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@ -2324,6 +2430,10 @@ packages:
resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==}
engines: {node: '>=14.0.0'}
tmp@0.0.33:
resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
engines: {node: '>=0.6.0'}
toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
@ -2354,6 +2464,10 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
type-fest@0.21.3:
resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==}
engines: {node: '>=10'}
type-is@1.6.18:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'}
@ -2378,6 +2492,10 @@ packages:
undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
undici@6.20.1:
resolution: {integrity: sha512-AjQF1QsmqfJys+LXfGTNum+qw4S88CojRInG/6t31W/1fk6G59s92bnAvGz5Cmur+kQv2SURXEvvudLmbrE8QA==}
engines: {node: '>=18.17'}
unicode-emoji-modifier-base@1.0.0:
resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==}
engines: {node: '>=4'}
@ -2386,6 +2504,9 @@ packages:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
validator@13.15.35:
resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==}
engines: {node: '>= 0.10'}
@ -2486,11 +2607,18 @@ packages:
jsdom:
optional: true
wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@ -2831,6 +2959,16 @@ snapshots:
class-transformer: 0.5.1
class-validator: 0.15.1
'@nestjs/mau@0.2.8':
dependencies:
chalk: 4.1.2
cli-progress: 3.12.0
commander: 4.1.1
inquirer: 8.2.6
node-emoji: 2.1.3
ora: 5.4.1
undici: 6.20.1
'@nestjs/platform-express@12.1.2(@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)(supports-color@7.2.0)':
dependencies:
'@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)
@ -2843,12 +2981,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@nestjs/schedule@12.0.2(@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)':
dependencies:
'@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(@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/platform-express@12.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
cron: 4.4.0
'@nestjs/schematics@12.0.6(chokidar@5.0.0)(prettier@3.9.9)(typescript@6.0.3)':
dependencies:
'@angular-devkit/core': 22.2.0(chokidar@5.0.0)
@ -2887,12 +3019,6 @@ snapshots:
optionalDependencies:
'@nestjs/platform-express': 12.1.2(@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)(supports-color@7.2.0)
'@nestjs/throttler@6.7.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)(reflect-metadata@0.2.2)':
dependencies:
'@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(@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/platform-express@12.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
reflect-metadata: 0.2.2
'@noble/hashes@1.8.0': {}
'@oxc-project/types@0.153.0': {}
@ -3119,8 +3245,6 @@ snapshots:
'@types/ms': 2.1.0
'@types/node': 24.19.1
'@types/luxon@3.7.6': {}
'@types/methods@1.1.4': {}
'@types/ms@2.1.0': {}
@ -3227,10 +3351,18 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
ansi-escapes@4.3.2:
dependencies:
type-fest: 0.21.3
ansi-regex@5.0.1: {}
ansi-regex@6.4.0: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
ansis@4.4.0: {}
append-field@1.0.0: {}
@ -3255,8 +3387,16 @@ snapshots:
balanced-match@4.0.4: {}
base64-js@1.5.1: {}
bcryptjs@3.0.3: {}
bl@4.1.0:
dependencies:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.2
body-parser@2.3.0(supports-color@7.2.0):
dependencies:
bytes: 3.1.2
@ -3279,6 +3419,11 @@ snapshots:
buffer-from@1.1.2: {}
buffer@5.7.1:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
busboy@1.6.0:
dependencies:
streamsearch: 1.1.0
@ -3312,10 +3457,17 @@ snapshots:
chai@6.3.0: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
chalk@5.6.2: {}
char-regex@1.0.2: {}
chardet@0.7.0: {}
chardet@2.2.0: {}
chokidar@4.0.3:
@ -3340,10 +3492,20 @@ snapshots:
libphonenumber-js: 1.13.15
validator: 13.15.35
cli-cursor@3.1.0:
dependencies:
restore-cursor: 3.1.0
cli-cursor@5.0.0:
dependencies:
restore-cursor: 5.1.0
cli-progress@3.12.0:
dependencies:
string-width: 4.2.3
cli-spinners@2.9.2: {}
cli-spinners@3.4.0: {}
cli-table3@0.6.5:
@ -3352,10 +3514,20 @@ snapshots:
optionalDependencies:
'@colors/colors': 1.5.0
cli-width@3.0.0: {}
cli-width@4.1.0: {}
clone@1.0.4: {}
cluster-key-slot@1.1.1: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
colorette@2.0.20: {}
combined-stream@1.0.8:
@ -3364,6 +3536,8 @@ snapshots:
commander@15.0.0: {}
commander@4.1.1: {}
comment-json@5.0.0:
dependencies:
array-timsort: 1.0.3
@ -3396,11 +3570,6 @@ snapshots:
object-assign: 4.1.1
vary: 1.1.2
cron@4.4.0:
dependencies:
'@types/luxon': 3.7.6
luxon: 3.7.2
date-fns@4.4.0: {}
dateformat@4.6.3: {}
@ -3413,6 +3582,10 @@ snapshots:
deepmerge-ts@7.1.5: {}
defaults@1.0.4:
dependencies:
clone: 1.0.4
defu@6.1.7: {}
delayed-stream@1.0.0: {}
@ -3513,6 +3686,8 @@ snapshots:
escape-html@1.0.3: {}
escape-string-regexp@1.0.5: {}
esprima@4.0.1: {}
estree-walker@3.0.3:
@ -3558,6 +3733,12 @@ snapshots:
exsolve@1.1.3: {}
external-editor@3.1.0:
dependencies:
chardet: 0.7.0
iconv-lite: 0.4.24
tmp: 0.0.33
fast-check@3.23.2:
dependencies:
pure-rand: 6.1.0
@ -3584,6 +3765,10 @@ snapshots:
optionalDependencies:
picomatch: 4.0.7
figures@3.2.0:
dependencies:
escape-string-regexp: 1.0.5
file-type@22.1.1(supports-color@7.2.0):
dependencies:
'@tokenizer/inflate': 0.4.1(supports-color@7.2.0)
@ -3674,8 +3859,6 @@ snapshots:
dependencies:
function-bind: 1.1.2
helmet@8.3.0: {}
help-me@5.0.0: {}
html-escaper@2.0.2: {}
@ -3688,6 +3871,10 @@ snapshots:
statuses: 2.0.2
toidentifier: 1.0.1
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
iconv-lite@0.7.3:
dependencies:
safer-buffer: 2.1.2
@ -3696,6 +3883,24 @@ snapshots:
inherits@2.0.4: {}
inquirer@8.2.6:
dependencies:
ansi-escapes: 4.3.2
chalk: 4.1.2
cli-cursor: 3.1.0
cli-width: 3.0.0
external-editor: 3.1.0
figures: 3.2.0
lodash: 4.18.1
mute-stream: 0.0.8
ora: 5.4.1
run-async: 2.4.1
rxjs: 7.8.2
string-width: 4.2.3
strip-ansi: 6.0.1
through: 2.3.8
wrap-ansi: 6.2.0
ioredis@6.0.0(supports-color@7.2.0):
dependencies:
'@ioredis/commands': 2.0.0
@ -3711,10 +3916,14 @@ snapshots:
is-fullwidth-code-point@3.0.0: {}
is-interactive@1.0.0: {}
is-interactive@2.0.0: {}
is-promise@4.0.0: {}
is-unicode-supported@0.1.0: {}
is-unicode-supported@2.1.0: {}
istanbul-lib-coverage@3.2.2: {}
@ -3839,13 +4048,18 @@ snapshots:
lodash.once@4.1.1: {}
lodash@4.18.1: {}
log-symbols@4.1.0:
dependencies:
chalk: 4.1.2
is-unicode-supported: 0.1.0
log-symbols@7.0.1:
dependencies:
is-unicode-supported: 2.1.0
yoctocolors: 2.2.0
luxon@3.7.2: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
@ -3888,6 +4102,8 @@ snapshots:
mime@2.6.0: {}
mimic-fn@2.1.0: {}
mimic-function@5.0.1: {}
minimatch@10.2.6:
@ -3904,6 +4120,8 @@ snapshots:
busboy: 1.6.0
type-is: 1.6.18
mute-stream@0.0.8: {}
mute-stream@3.0.0: {}
nanoid@3.3.20: {}
@ -3920,6 +4138,13 @@ snapshots:
pino-http: 11.0.0
rxjs: 7.8.2
node-emoji@2.1.3:
dependencies:
'@sindresorhus/is': 4.6.0
char-regex: 1.0.2
emojilib: 2.4.0
skin-tone: 2.0.0
node-emoji@2.2.0:
dependencies:
'@sindresorhus/is': 4.6.0
@ -3953,10 +4178,26 @@ snapshots:
dependencies:
wrappy: 1.0.2
onetime@5.1.2:
dependencies:
mimic-fn: 2.1.0
onetime@7.0.0:
dependencies:
mimic-function: 5.0.1
ora@5.4.1:
dependencies:
bl: 4.1.0
chalk: 4.1.2
cli-cursor: 3.1.0
cli-spinners: 2.9.2
is-interactive: 1.0.0
is-unicode-supported: 0.1.0
log-symbols: 4.1.0
strip-ansi: 6.0.1
wcwidth: 1.0.1
ora@9.4.1:
dependencies:
chalk: 5.6.2
@ -3968,6 +4209,8 @@ snapshots:
stdin-discarder: 0.3.2
string-width: 8.3.0
os-tmpdir@1.0.2: {}
oxlint-tsgolint@7.0.2003:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 7.0.2003
@ -4120,6 +4363,12 @@ snapshots:
defu: 6.1.7
destr: 2.0.5
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
readdirp@4.1.2: {}
readdirp@5.1.1: {}
@ -4132,6 +4381,11 @@ snapshots:
require-from-string@2.0.2: {}
restore-cursor@3.1.0:
dependencies:
onetime: 5.1.2
signal-exit: 3.0.7
restore-cursor@5.1.0:
dependencies:
onetime: 7.0.0
@ -4168,6 +4422,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
run-async@2.4.1: {}
rxjs@7.8.2:
dependencies:
tslib: 2.8.1
@ -4239,6 +4495,8 @@ snapshots:
siginfo@2.0.0: {}
signal-exit@3.0.7: {}
signal-exit@4.1.0: {}
skin-tone@2.0.0:
@ -4289,6 +4547,10 @@ snapshots:
get-east-asian-width: 1.7.0
strip-ansi: 7.2.0
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
@ -4339,6 +4601,8 @@ snapshots:
dependencies:
real-require: 1.0.0
through@2.3.8: {}
tinybench@2.9.0: {}
tinyexec@1.3.1: {}
@ -4350,6 +4614,10 @@ snapshots:
tinyrainbow@3.2.0: {}
tmp@0.0.33:
dependencies:
os-tmpdir: 1.0.2
toidentifier@1.0.1: {}
token-types@6.1.2:
@ -4376,6 +4644,8 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
type-fest@0.21.3: {}
type-is@1.6.18:
dependencies:
media-typer: 0.3.0
@ -4397,10 +4667,14 @@ snapshots:
undici-types@7.24.6: {}
undici@6.20.1: {}
unicode-emoji-modifier-base@1.0.0: {}
unpipe@1.0.0: {}
util-deprecate@1.0.2: {}
validator@13.15.35: {}
vary@1.1.2: {}
@ -4459,11 +4733,21 @@ snapshots:
transitivePeerDependencies:
- msw
wcwidth@1.0.1:
dependencies:
defaults: 1.0.4
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrappy@1.0.2: {}
yaml@2.9.1:

View File

@ -1,9 +0,0 @@
-- AlterTable
ALTER TABLE `sys_dict_item` ADD COLUMN `built_in` BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN `color_type` VARCHAR(16) NULL;
-- AlterTable
ALTER TABLE `sys_dict_type` ADD COLUMN `built_in` BOOLEAN NOT NULL DEFAULT false;
-- CreateIndex
CREATE UNIQUE INDEX `sys_dict_item_tenant_code_dict_type_code_value_key` ON `sys_dict_item`(`tenant_code`, `dict_type_code`, `value`);

View File

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE `sys_user` ADD COLUMN `token_version` INT NOT NULL DEFAULT 0;

View File

@ -35,7 +35,6 @@ model User {
phone String? @db.VarChar(32)
avatar String? @db.VarChar(255)
status String @default("enabled") @db.VarChar(16)
tokenVersion Int @default(0) @map("token_version")
orgCode String? @map("org_code") @db.VarChar(64)
createdAt DateTime @default(now()) @map("created_at")
createdBy String? @map("created_by") @db.VarChar(36)
@ -121,7 +120,6 @@ model DictType {
code String @db.VarChar(64)
name String @db.VarChar(64)
status String @default("enabled") @db.VarChar(16)
builtIn Boolean @default(false) @map("built_in")
remark String? @db.VarChar(255)
createdAt DateTime @default(now()) @map("created_at")
createdBy String? @map("created_by") @db.VarChar(36)
@ -142,14 +140,11 @@ model DictItem {
value String @db.VarChar(64)
sort Int @default(0)
status String @default("enabled") @db.VarChar(16)
builtIn Boolean @default(false) @map("built_in")
colorType String? @map("color_type") @db.VarChar(16)
createdAt DateTime @default(now()) @map("created_at")
createdBy String? @map("created_by") @db.VarChar(36)
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
@@unique([tenantCode, dictTypeCode, value])
@@index([tenantCode, dictTypeCode])
@@map("sys_dict_item")
}

View File

@ -1,35 +1,174 @@
import { PrismaClient } from '@prisma/client';
import { PLATFORM_ROLE_CODE, PLATFORM_TENANT_CODE } from '../src/common/constants.js';
import { initTenant } from '../src/common/tenant-template.js';
import bcrypt from 'bcryptjs';
import { Permissions } from '../src/common/permissions.js';
const prisma = new PrismaClient();
const TENANT = PLATFORM_TENANT_CODE;
const SUPER_ROLE = PLATFORM_ROLE_CODE;
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. 平台租户
// 1. 租户
await prisma.tenant.upsert({
where: { code: TENANT },
update: { name: '投云', status: 'enabled' },
create: { code: TENANT, name: '投云', status: 'enabled' },
});
// 2. 平台租户初始化(根组织 + 超管角色 + 管理员 + 全量资源 + 内置字典 + 授权)
await initTenant({
prisma,
tenantCode: TENANT,
tenantName: '投云',
adminRoleCode: SUPER_ROLE,
adminRoleName: '超级管理员',
adminUsername: ADMIN_USERNAME,
adminPassword: ADMIN_PASSWORD,
includePlatformPerms: true,
// 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' },
});
console.log(`seed 完成:平台租户 ${TENANT}、用户 ${ADMIN_USERNAME}、角色 ${SUPER_ROLE}。`);
// 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()

View File

@ -11,9 +11,6 @@ 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 { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { ScheduleModule } from '@nestjs/schedule';
import { CleanupService } from './common/cleanup.service.js';
import { TenantsModule } from './tenants/tenants.module.js';
import { UsersModule } from './users/users.module.js';
import { RolesModule } from './roles/roles.module.js';
@ -28,8 +25,6 @@ import { AppService } from './app.service.js';
@Module({
imports: [
ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]),
ScheduleModule.forRoot(),
createLoggerModule(),
PrismaModule,
RedisModule,
@ -47,8 +42,6 @@ import { AppService } from './app.service.js';
controllers: [AppController],
providers: [
AppService,
CleanupService,
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
{ provide: APP_INTERCEPTOR, useClass: RequestContextInterceptor },

View File

@ -1,7 +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 { ListAuditLogQuery } from './dto.js';
import { AuditLogsService } from './audit-logs.service.js';
@Controller('audit-logs')
@ -10,7 +10,7 @@ export class AuditLogsController {
@Get()
@RequirePermissions(Permissions.audit.list)
list(@Query() q: ListAuditLogQuery) {
return this.service.listAuditLogs(q);
list(@Query() q: PageQuery) {
return this.service.paginate(q.page, q.pageSize);
}
}

View File

@ -2,8 +2,6 @@ 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';
import { getRequestContext } from '../common/context/request-context.js';
import { ListAuditLogQuery } from './dto.js';
export interface AuditLogEntry {
tenantCode: string;
@ -39,29 +37,4 @@ export class AuditLogsService extends BaseCrudService {
this.logger.warn({ err }, '审计日志写入失败');
}
}
async listAuditLogs(q: ListAuditLogQuery) {
const ctx = getRequestContext();
const where: Record<string, unknown> = {};
if (q.username) where.username = { contains: q.username };
if (q.module) where.module = { contains: q.module };
if (q.status) where.status = q.status;
// 数据权限:仅本人按 userId;否则按可见部门内的操作人
if (ctx && ctx.dataScope !== 'all') {
if (ctx.dataScope === 'self') {
where.userId = ctx.userId;
} else {
const userIds = ctx.orgCodes.length
? (
await this.prisma.client.user.findMany({
where: { tenantCode: ctx.tenantCode, orgCode: { in: ctx.orgCodes }, deletedAt: null },
select: { id: true },
})
).map((u) => u.id)
: [];
where.userId = { in: userIds };
}
}
return this.paginate(q.page, q.pageSize, where);
}
}

View File

@ -1,20 +0,0 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
import { PageQuery } from '../common/page.dto.js';
export class ListAuditLogQuery extends PageQuery {
@ApiPropertyOptional({ description: '操作人(模糊)' })
@IsOptional()
@IsString()
username?: string;
@ApiPropertyOptional({ description: '模块(模糊)' })
@IsOptional()
@IsString()
module?: string;
@ApiPropertyOptional({ description: '状态:success/fail' })
@IsOptional()
@IsString()
status?: string;
}

View File

@ -1,9 +1,8 @@
import { Body, Controller, Get, Patch, Post, Req, UnauthorizedException } from '@nestjs/common';
import { Body, Controller, Post, Req } from '@nestjs/common';
import type { Request } from 'express';
import { AuthService } from './auth.service.js';
import { ChangePasswordDto, LoginDto, RefreshDto, RegisterDto } from './dto.js';
import { LoginDto, RefreshDto } from './dto.js';
import { Public } from './decorators.js';
import { getRequestContext } from '../common/context/request-context.js';
@Controller('auth')
export class AuthController {
@ -15,12 +14,6 @@ export class AuthController {
return this.auth.login(dto, req.ip, req.headers['user-agent']);
}
@Public()
@Post('register')
register(@Body() dto: RegisterDto) {
return this.auth.register(dto);
}
@Public()
@Post('refresh')
refresh(@Body() dto: RefreshDto) {
@ -32,28 +25,4 @@ export class AuthController {
logout(@Body() dto: RefreshDto, @Req() req: Request) {
return this.auth.logout(dto, req.ip, req.headers['user-agent']);
}
/** 当前用户菜单树(仅需登录,无需额外权限)。 */
@Get('menus')
menus() {
const ctx = getRequestContext();
if (!ctx) throw new UnauthorizedException('未登录');
return this.auth.getMenus(ctx.tenantCode, ctx.userId);
}
/** 当前用户权限码(含超管标志),供前端按钮/路由级权限控制(仅需登录)。 */
@Get('permissions')
permissions() {
const ctx = getRequestContext();
if (!ctx) throw new UnauthorizedException('未登录');
return this.auth.getPermissionSet(ctx.tenantCode, ctx.userId);
}
/** 修改本人密码(仅需登录)。 */
@Patch('me/password')
changePassword(@Body() dto: ChangePasswordDto) {
const ctx = getRequestContext();
if (!ctx) throw new UnauthorizedException('未登录');
return this.auth.changePassword(ctx.tenantCode, ctx.userId, dto);
}
}

View File

@ -5,19 +5,11 @@ import { AuthService } from './auth.service.js';
import { AuditLogsModule } from '../audit-logs/audit-logs.module.js';
import { LoginLogsModule } from '../login-logs/login-logs.module.js';
const jwtSecret = process.env.JWT_SECRET;
if (!jwtSecret) {
throw new Error('JWT_SECRET 环境变量未配置,拒绝启动');
}
if (process.env.NODE_ENV === 'production' && (jwtSecret === 'dev-secret-change-me' || jwtSecret.length < 32)) {
throw new Error('生产环境 JWT_SECRET 过弱,请配置至少 32 位强密钥');
}
@Module({
imports: [
JwtModule.register({
global: true,
secret: jwtSecret,
secret: process.env.JWT_SECRET ?? 'dev-secret-change-me',
signOptions: { expiresIn: Number(process.env.JWT_ACCESS_TTL ?? 7200) },
}),
AuditLogsModule,

View File

@ -1,75 +1,25 @@
import { BadRequestException, ConflictException, Injectable, UnauthorizedException } from '@nestjs/common';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import bcrypt from 'bcryptjs';
import { createHash, randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service.js';
import { RedisService } from '../common/redis/redis.service.js';
import { LoginDto, RefreshDto, RegisterDto, ChangePasswordDto } from './dto.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';
import { PLATFORM_ROLE_CODE as SUPER_ROLE, permCacheKey, userStateCacheKey } from '../common/constants.js';
const REFRESH_TTL = Number(process.env.JWT_REFRESH_TTL ?? 604800);
const ACCESS_TTL = Number(process.env.JWT_ACCESS_TTL ?? 7200);
const LOGIN_FAIL_PREFIX = 'loginFail:';
const MAX_LOGIN_FAILS = 5;
const LOGIN_LOCK_SECONDS = 900;
const SUPER_ROLE = 'super_admin';
export interface PermissionSet {
super: boolean;
permissions: string[];
}
interface MenuRow {
id: string;
code: string;
name: string;
parentCode: string | null;
type: string;
path: string | null;
component: string | null;
icon: string | null;
sort: number;
}
export interface MenuNode extends MenuRow {
children: MenuNode[];
}
const MENU_SELECT = {
id: true,
code: true,
name: true,
parentCode: true,
type: true,
path: true,
component: true,
icon: true,
sort: true,
} as const;
/** 由扁平菜单行构建树(按 sort + code 升序)。 */
function buildMenuTree(rows: MenuRow[]): MenuNode[] {
const map = new Map<string, MenuNode>();
for (const r of rows) map.set(r.code, { ...r, children: [] });
const roots: MenuNode[] = [];
for (const node of map.values()) {
const parent = node.parentCode ? map.get(node.parentCode) : undefined;
if (parent) parent.children.push(node);
else roots.push(node);
}
const sortRec = (nodes: MenuNode[]): void => {
nodes.sort((a, b) => a.sort - b.sort || a.code.localeCompare(b.code));
for (const n of nodes) sortRec(n.children);
};
sortRec(roots);
return roots;
}
@Injectable()
export class AuthService {
constructor(
@ -84,98 +34,33 @@ export class AuthService {
dto: LoginDto,
ip?: string,
userAgent?: string,
): Promise<
| { accessToken: string; refreshToken: string; expiresIn: number }
| { needSelectTenant: true; tenants: { tenantCode: string; tenantName: string }[] }
> {
await this.assertNotLocked(dto.username);
if (dto.tenantCode) {
try {
const result = await this.loginTenant(dto.tenantCode, dto.username, dto.password, ip, userAgent);
await this.clearLoginFail(dto.username);
return result;
} catch (e) {
if (e instanceof UnauthorizedException) await this.recordLoginFail(dto.username);
throw e;
}
}
// 未指定租户:按用户名探测(一个用户名可存在于多个租户)
const users = await this.prisma.client.user.findMany({
where: { username: dto.username, deletedAt: null },
select: { tenantCode: true, passwordHash: true, status: true },
});
const matched: { tenantCode: string; tenantName: string }[] = [];
for (const u of users) {
if (u.status !== 'enabled') continue;
const tenant = await this.prisma.client.tenant.findUnique({ where: { code: u.tenantCode } });
if (!tenant || tenant.status !== 'enabled') continue;
if (await bcrypt.compare(dto.password, u.passwordHash)) {
matched.push({ tenantCode: u.tenantCode, tenantName: tenant.name });
}
}
if (matched.length === 0) {
await this.recordLoginFail(dto.username);
await this.recordLoginLog('', dto.username, 'fail', '账号或密码错误', ip, userAgent);
throw new UnauthorizedException('账号或密码错误');
}
await this.clearLoginFail(dto.username);
if (matched.length === 1) {
return this.loginTenant(matched[0].tenantCode, dto.username, dto.password, ip, userAgent);
}
return { needSelectTenant: true, tenants: matched };
}
/** 登录失败锁定:连续失败超阈值后拒绝,直到窗口过期。 */
private async assertNotLocked(username: string): Promise<void> {
const count = (await this.redis.getJson<number>(`${LOGIN_FAIL_PREFIX}${username}`)) ?? 0;
if (count >= MAX_LOGIN_FAILS) {
throw new UnauthorizedException('登录失败次数过多,请稍后再试');
}
}
private async recordLoginFail(username: string): Promise<void> {
const key = `${LOGIN_FAIL_PREFIX}${username}`;
const count = ((await this.redis.getJson<number>(key)) ?? 0) + 1;
await this.redis.setJson(key, count, LOGIN_LOCK_SECONDS);
}
private async clearLoginFail(username: string): Promise<void> {
await this.redis.del(`${LOGIN_FAIL_PREFIX}${username}`);
}
/** 指定租户登录(单租户逻辑)。 */
private async loginTenant(
tenantCode: string,
username: string,
password: string,
ip?: string,
userAgent?: string,
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
const tenant = await this.prisma.client.tenant.findUnique({ where: { code: tenantCode } });
const tenant = await this.prisma.client.tenant.findUnique({ where: { code: dto.tenantCode } });
if (!tenant || tenant.status !== 'enabled') {
await this.recordLoginLog(tenantCode, username, 'fail', '租户不存在或已停用', ip, userAgent);
await this.recordLoginLog(dto.tenantCode, dto.username, 'fail', '租户不存在或已停用', ip, userAgent);
throw new UnauthorizedException('租户不存在或已停用');
}
const user = await this.prisma.client.user.findFirst({
where: { tenantCode, username, deletedAt: null },
where: { tenantCode: dto.tenantCode, username: dto.username, deletedAt: null },
});
if (!user) {
await this.recordLoginLog(tenantCode, username, 'fail', '账号或密码错误', ip, userAgent);
await this.recordLoginLog(dto.tenantCode, dto.username, 'fail', '账号或密码错误', ip, userAgent);
throw new UnauthorizedException('账号或密码错误');
}
if (user.status !== 'enabled') {
await this.recordLoginLog(tenantCode, username, 'fail', '账号已停用或锁定', ip, userAgent);
await this.recordLoginLog(dto.tenantCode, dto.username, 'fail', '账号已停用或锁定', ip, userAgent);
throw new UnauthorizedException('账号已停用或锁定');
}
const ok = await bcrypt.compare(password, user.passwordHash);
const ok = await bcrypt.compare(dto.password, user.passwordHash);
if (!ok) {
await this.recordLoginLog(tenantCode, username, 'fail', '账号或密码错误', ip, userAgent);
await this.recordLoginLog(dto.tenantCode, dto.username, 'fail', '账号或密码错误', ip, userAgent);
throw new UnauthorizedException('账号或密码错误');
}
await this.writePermissionCache(tenantCode, user.id);
await this.recordLoginLog(tenantCode, username, 'success', null, ip, userAgent);
await this.writePermissionCache(dto.tenantCode, user.id);
await this.recordLoginLog(dto.tenantCode, dto.username, 'success', null, ip, userAgent);
await this.recordAudit({
tenantCode,
tenantCode: dto.tenantCode,
userId: user.id,
username: user.username,
module: 'auth',
@ -184,54 +69,12 @@ export class AuthService {
ip: ip ?? null,
userAgent: userAgent ?? null,
});
return this.issue(tenantCode, user.id, user.username, user.tokenVersion);
}
/** 注册:在指定租户下创建普通用户(无角色,状态启用)。 */
async register(dto: RegisterDto): Promise<{ id: string; username: string }> {
const tenant = await this.prisma.client.tenant.findUnique({ where: { code: dto.tenantCode } });
if (!tenant || tenant.status !== 'enabled') throw new BadRequestException('租户不存在或已停用');
const exists = await this.prisma.client.user.findFirst({
where: { tenantCode: dto.tenantCode, username: dto.username, deletedAt: null },
});
if (exists) throw new ConflictException('用户名已存在');
const passwordHash = await bcrypt.hash(dto.password, 10);
const user = await this.prisma.client.user.create({
data: {
tenantCode: dto.tenantCode,
username: dto.username,
passwordHash,
email: dto.email ?? null,
phone: dto.phone ?? null,
status: 'enabled',
},
});
return { id: user.id, username: user.username };
}
/** 修改本人密码:校验原密码,更新后吊销其它会话。 */
async changePassword(tenantCode: string, userId: string, dto: ChangePasswordDto): Promise<null> {
const user = await this.prisma.client.user.findFirst({
where: { tenantCode, id: userId, deletedAt: null },
});
if (!user) throw new UnauthorizedException('账号不存在');
const ok = await bcrypt.compare(dto.oldPassword, user.passwordHash);
if (!ok) throw new BadRequestException('原密码错误');
const passwordHash = await bcrypt.hash(dto.newPassword, 10);
await this.prisma.client.user.updateMany({
where: { tenantCode, id: userId },
data: { passwordHash, tokenVersion: { increment: 1 } },
});
await this.prisma.client.refreshToken.deleteMany({ where: { tenantCode, userId } });
await this.redis.del(permCacheKey(tenantCode, userId));
await this.redis.del(userStateCacheKey(tenantCode, userId));
return null;
return this.issue(dto.tenantCode, user.id, user.username);
}
async refresh(dto: RefreshDto): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
const record = await this.prisma.client.refreshToken.findUnique({ where: { jti: dto.refreshToken } });
const tokenHash = createHash('sha256').update(dto.refreshToken).digest('hex');
if (!record || record.tokenHash !== tokenHash || record.revokedAt || record.expireAt.getTime() < Date.now()) {
if (!record || record.revokedAt || record.expireAt.getTime() < Date.now()) {
throw new UnauthorizedException('refresh token 无效或已过期');
}
const user = await this.prisma.client.user.findFirst({
@ -245,7 +88,7 @@ export class AuthService {
data: { revokedAt: new Date() },
});
await this.writePermissionCache(record.tenantCode, user.id);
return this.issue(record.tenantCode, user.id, user.username, user.tokenVersion);
return this.issue(record.tenantCode, user.id, user.username);
}
async logout(dto: RefreshDto, ip?: string, userAgent?: string): Promise<null> {
@ -256,11 +99,10 @@ export class AuthService {
data: { revokedAt: new Date() },
});
await this.redis.del(this.permKey(record.tenantCode, record.userId));
const user = await this.prisma.client.user.findUnique({ where: { id: record.userId } });
await this.recordAudit({
tenantCode: record.tenantCode,
userId: record.userId,
username: user?.username ?? null,
username: null,
module: 'auth',
action: 'logout',
status: 'success',
@ -278,56 +120,6 @@ export class AuthService {
return this.writePermissionCache(tenantCode, userId);
}
/** 当前用户的菜单树(超管返回全部启用菜单,否则按角色可见菜单 + 补祖先)。 */
async getMenus(tenantCode: string, userId: string): Promise<MenuNode[]> {
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 rows = isSuper
? await this.prisma.client.resource.findMany({
where: { tenantCode, type: 'menu', status: 'enabled', deletedAt: null },
orderBy: { sort: 'asc' },
select: MENU_SELECT,
})
: await this.menusForRoles(tenantCode, roleCodes);
return buildMenuTree(rows as MenuRow[]);
}
/** 非超管:角色可访问的菜单 + 其祖先(保证目录可达)。 */
private async menusForRoles(tenantCode: string, roleCodes: string[]): Promise<MenuRow[]> {
if (!roleCodes.length) return [];
const roleResourceRows = await this.prisma.client.roleResource.findMany({
where: { tenantCode, roleCode: { in: roleCodes } },
select: { resourceCode: true },
});
const codes = [...new Set(roleResourceRows.map((r) => r.resourceCode))];
if (!codes.length) return [];
const accessible = await this.prisma.client.resource.findMany({
where: { tenantCode, code: { in: codes }, type: 'menu', status: 'enabled', deletedAt: null },
select: MENU_SELECT,
});
const all = await this.prisma.client.resource.findMany({
where: { tenantCode, type: 'menu', deletedAt: null },
select: MENU_SELECT,
});
const byCode = new Map(all.map((r) => [r.code, r]));
const needed = new Set(accessible.map((r) => r.code));
for (const r of [...accessible]) {
let cur = r.parentCode ? byCode.get(r.parentCode) : undefined;
while (cur && !needed.has(cur.code)) {
needed.add(cur.code);
cur = cur.parentCode ? byCode.get(cur.parentCode) : undefined;
}
}
return all.filter((r) => needed.has(r.code)) as MenuRow[];
}
/** 由 RequestContextInterceptor 调用,构建当前请求的租户/用户/数据权限上下文。 */
async buildContext(user: { userId: string; tenantCode: string; username: string }): Promise<RequestContext> {
const u = await this.prisma.client.user.findFirst({
@ -363,23 +155,7 @@ export class AuthService {
}
private permKey(tenantCode: string, userId: string): string {
return permCacheKey(tenantCode, userId);
}
/** 校验用户当前是否可用(status=enabled 且 tokenVersion 与签发时一致)。 */
async checkUserActive(tenantCode: string, userId: string, tokenVersion: number): Promise<boolean> {
const key = userStateCacheKey(tenantCode, userId);
let state = await this.redis.getJson<{ status: string; tokenVersion: number }>(key);
if (!state) {
const user = await this.prisma.client.user.findFirst({
where: { tenantCode, id: userId, deletedAt: null },
select: { status: true, tokenVersion: true },
});
if (!user) return false;
state = { status: user.status, tokenVersion: user.tokenVersion };
await this.redis.setJson(key, state, ACCESS_TTL);
}
return state.status === 'enabled' && state.tokenVersion === tokenVersion;
return `perm:${tenantCode}:${userId}`;
}
/** 聚合权限码与超管标志。 */
@ -389,26 +165,16 @@ export class AuthService {
select: { roleCode: true },
});
const roleCodes = roleRows.map((r) => r.roleCode);
// 过滤已删除角色(软删角色不参与权限聚合)
const activeRoles = roleCodes.length
? await this.prisma.client.role.findMany({
where: { tenantCode, code: { in: roleCodes }, deletedAt: null },
select: { code: true },
})
: [];
const activeRoleCodes = activeRoles.map((r) => r.code);
const isSuper = activeRoleCodes.includes(SUPER_ROLE);
const resourceRows = activeRoleCodes.length
? await this.prisma.client.roleResource.findMany({
where: { tenantCode, roleCode: { in: activeRoleCodes } },
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'] }, status: 'enabled', deletedAt: null },
where: { tenantCode, code: { in: resourceCodes }, type: { in: ['button', 'api'] }, deletedAt: null },
select: { code: true },
})
).map((r) => r.code)
@ -458,9 +224,8 @@ export class AuthService {
tenantCode: string,
userId: string,
username: string,
tokenVersion: number,
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
const accessToken = this.jwt.sign({ sub: userId, tenantCode, username, ver: tokenVersion });
const accessToken = this.jwt.sign({ sub: userId, tenantCode, username });
const jti = randomUUID();
await this.prisma.client.refreshToken.create({
data: {

View File

@ -1,24 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class LoginDto {
@ApiPropertyOptional({ description: '租户编码(多租户二选一时传入;缺省按用户名自动探测)' })
@IsOptional()
@IsString()
tenantCode?: string;
@ApiProperty({ description: '用户名' })
@IsString()
@IsNotEmpty()
username!: string;
@ApiProperty({ description: '密码' })
@IsString()
@IsNotEmpty()
password!: string;
}
export class RegisterDto {
@ApiProperty({ description: '租户编码' })
@IsString()
@IsNotEmpty()
@ -32,31 +15,7 @@ export class RegisterDto {
@ApiProperty({ description: '密码' })
@IsString()
@IsNotEmpty()
@MinLength(6, { message: '密码长度不能少于 6 位' })
password!: string;
@ApiPropertyOptional({ description: '邮箱' })
@IsOptional()
@IsString()
email?: string;
@ApiPropertyOptional({ description: '手机号' })
@IsOptional()
@IsString()
phone?: string;
}
export class ChangePasswordDto {
@ApiProperty({ description: '原密码' })
@IsString()
@IsNotEmpty()
oldPassword!: string;
@ApiProperty({ description: '新密码' })
@IsString()
@IsNotEmpty()
@MinLength(6, { message: '密码长度不能少于 6 位' })
newPassword!: string;
}
export class RefreshDto {

View File

@ -2,7 +2,6 @@ import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { IS_PUBLIC_KEY } from './decorators.js';
import { AuthService } from './auth.service.js';
export interface AuthUser {
userId: string;
@ -15,7 +14,6 @@ export class JwtAuthGuard implements CanActivate {
constructor(
private readonly jwt: JwtService,
private readonly reflector: Reflector,
private readonly auth: AuthService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
@ -32,13 +30,10 @@ export class JwtAuthGuard implements CanActivate {
const token = this.extractToken(request);
if (!token) throw new UnauthorizedException('未登录');
try {
const payload = await this.jwt.verifyAsync<{ sub: string; tenantCode: string; username: string; ver?: number }>(token);
const active = await this.auth.checkUserActive(payload.tenantCode, payload.sub, payload.ver ?? 0);
if (!active) throw new UnauthorizedException('账号已停用或登录已失效');
const payload = await this.jwt.verifyAsync<{ sub: string; tenantCode: string; username: string }>(token);
request.user = { userId: payload.sub, tenantCode: payload.tenantCode, username: payload.username } satisfies AuthUser;
return true;
} catch (e) {
if (e instanceof UnauthorizedException) throw e;
} catch {
throw new UnauthorizedException('token 无效或已过期');
}
}

View File

@ -1,21 +1,6 @@
import { PrismaService } from '../prisma/prisma.service.js';
import { getRequestContext } from './context/request-context.js';
/**
* 软删时需「改名回收」唯一键的模型映射:modelName → 唯一字段及其列长。
* 目的:软删只置 deletedAt 不释放 @@unique,重建同名会 P2002;删除时把唯一字段改名
* 成「原值(截断) + #id」释放唯一键,展示名字段(name/label)保持不变。
*/
const RECYCLE_FIELDS: Record<string, { field: string; maxLen: number }> = {
user: { field: 'username', maxLen: 64 },
role: { field: 'code', maxLen: 64 },
resource: { field: 'code', maxLen: 128 },
org: { field: 'code', maxLen: 64 },
dictType: { field: 'code', maxLen: 64 },
dictItem: { field: 'value', maxLen: 64 },
config: { field: 'key', maxLen: 128 },
};
/**
* 通用 CRUD 基类:封装分页/创建/更新/软删。
* 创建时自动注入 tenantCode(Tenant 模型除外);软删由子类按需调用 softRemove。
@ -53,10 +38,7 @@ export class BaseCrudService {
async create(data: Record<string, unknown>) {
const ctx = getRequestContext();
if (ctx && this.modelName !== 'tenant') {
data.tenantCode = ctx.tenantCode;
data.createdBy = ctx.userId;
}
if (ctx && this.modelName !== 'Tenant') data.tenantCode = ctx.tenantCode;
return this.db().create({ data });
}
@ -65,18 +47,6 @@ export class BaseCrudService {
}
async softRemove(id: string) {
const recycle = RECYCLE_FIELDS[this.modelName];
if (!recycle) {
return this.db().update({ where: { id }, data: { deletedAt: new Date() } });
}
// 先读原值,改名回收唯一键,再软删
const record = await this.findById(id);
const original = record?.[recycle.field];
if (original == null) {
return this.db().update({ where: { id }, data: { deletedAt: new Date() } });
}
const suffix = `#${id}`;
const recycled = `${String(original).slice(0, recycle.maxLen - suffix.length)}${suffix}`;
return this.db().update({ where: { id }, data: { [recycle.field]: recycled, deletedAt: new Date() } });
}
}

View File

@ -1,33 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
import { PrismaService } from '../prisma/prisma.service.js';
/** 定时清理:过期/吊销 refresh token 与超过保留期的审计/登录日志。 */
@Injectable()
export class CleanupService {
constructor(
private readonly prisma: PrismaService,
@InjectPinoLogger(CleanupService.name) private readonly logger: PinoLogger,
) {}
/** 每天 03:07 执行。 */
@Cron('7 3 * * *')
async cleanup(): Promise<void> {
try {
const db = this.prisma.raw;
const tokens = await db.refreshToken.deleteMany({
where: { OR: [{ revokedAt: { not: null } }, { expireAt: { lt: new Date() } }] },
});
const cutoff = new Date(Date.now() - 90 * 24 * 3600 * 1000);
const audit = await db.auditLog.deleteMany({ where: { createdAt: { lt: cutoff } } });
const login = await db.loginLog.deleteMany({ where: { createdAt: { lt: cutoff } } });
this.logger.info(
{ refreshTokens: tokens.count, auditLogs: audit.count, loginLogs: login.count },
'定时清理完成',
);
} catch (err) {
this.logger.warn({ err }, '定时清理失败');
}
}
}

View File

@ -1,24 +0,0 @@
/**
* 平台级常量:平台租户与保留角色码的单一事实源。
*/
/** 平台租户(投云科技,根租户):唯一可维护租户表的租户。 */
export const PLATFORM_TENANT_CODE = 'touyun';
/** 平台超管角色码(保留):isSuper 全绕过 + 唯一拥有 tenant:* 权限。 */
export const PLATFORM_ROLE_CODE = 'super_admin';
/** 业务租户顶层管理员角色码(保留):本租户内除 tenant:* 外全权限。 */
export const TENANT_ADMIN_ROLE_CODE = 'tenant_super_admin';
/** 保留角色码:租户不可自行创建。 */
export const RESERVED_ROLE_CODES: readonly string[] = [PLATFORM_ROLE_CODE, TENANT_ADMIN_ROLE_CODE];
/** 平台级权限码前缀(仅平台租户可授予)。 */
export const PLATFORM_PERMISSION_PREFIX = 'tenant:';
/** 权限集缓存 key(Redis,供守卫/授权读取)。 */
export const permCacheKey = (tenantCode: string, userId: string): string => `perm:${tenantCode}:${userId}`;
/** 用户状态缓存 key(Redis,status + tokenVersion,供 JwtAuthGuard 每请求校验)。 */
export const userStateCacheKey = (tenantCode: string, userId: string): string => `user:${tenantCode}:${userId}`;

View File

@ -1,16 +0,0 @@
import { describe, expect, it } from 'vitest';
import { resolveDataScope } from './data-scope.js';
import type { DataScope } from './enums.js';
describe('resolveDataScope', () => {
it('空角色默认 self(仅本人)', () => {
expect(resolveDataScope([])).toBe('self');
});
it('多角色取最高数据权限档', () => {
expect(resolveDataScope(['self', 'all'] as DataScope[])).toBe('all');
expect(resolveDataScope(['self', 'dept'] as DataScope[])).toBe('dept');
expect(resolveDataScope(['dept', 'dept_and_child'] as DataScope[])).toBe('dept_and_child');
expect(resolveDataScope(['custom', 'dept'] as DataScope[])).toBe('dept');
});
});

View File

@ -32,8 +32,6 @@ export async function resolveVisibleOrgs(opts: {
case 'all':
return [];
case 'self':
// 仅本人:不走组织过滤,由调用方按 userId 过滤(返回哨兵避免误放大为全量)
return ['__none__'];
case 'dept':
return orgCode ? [orgCode] : ['__none__'];
case 'dept_and_child':
@ -49,22 +47,14 @@ export async function resolveVisibleOrgs(opts: {
}
}
/** 收集某部门及其全部后代部门的 code(含自身),带 visited 防环。 */
/** 收集某部门及其全部后代部门的 code(含自身)。 */
async function collectDescendants(prisma: PrismaClient, tenantCode: string, rootCode: string): Promise<string[]> {
const result: string[] = [];
const visited = new Set<string>();
let frontier = [rootCode];
while (frontier.length > 0) {
const next: string[] = [];
for (const code of frontier) {
if (visited.has(code)) continue;
visited.add(code);
result.push(code);
next.push(code);
}
if (next.length === 0) break;
result.push(...frontier);
const children = await prisma.org.findMany({
where: { tenantCode, parentCode: { in: next }, deletedAt: null },
where: { tenantCode, parentCode: { in: frontier }, deletedAt: null },
select: { code: true },
});
frontier = children.map((c) => c.code);

View File

@ -1,5 +1,4 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { Response } from 'express';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
@ -14,32 +13,14 @@ export class AllExceptionsFilter implements ExceptionFilter {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message: string;
if (exception instanceof HttpException) {
status = exception.getStatus();
const body = exception.getResponse();
message =
const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
const body = exception instanceof HttpException ? exception.getResponse() : null;
const message =
typeof body === 'string'
? body
: Array.isArray((body as { message?: unknown })?.message)
? ((body as { message: string[] }).message.join('; '))
: ((body as { message?: string })?.message ?? exception.message);
} else if (exception instanceof Prisma.PrismaClientKnownRequestError) {
if (exception.code === 'P2002') {
status = HttpStatus.CONFLICT;
message = '数据已存在(唯一约束冲突),请勿重复创建';
} else if (exception.code === 'P2025') {
status = HttpStatus.NOT_FOUND;
message = '记录不存在或已被删除';
} else {
message = '数据库操作失败';
}
} else {
// 未知异常:不向客户端泄漏内部错误细节
message = '系统内部错误';
}
: ((body as { message?: string })?.message ?? (exception as Error).message ?? 'Internal server error');
if (status >= 500) {
this.logger.error({ err: exception }, message);

View File

@ -2,24 +2,12 @@ import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nes
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
/** 递归将 Date 转为 Unix 毫秒 number(API 时间契约:统一毫秒时间戳)。 */
function toUnixMs(value: unknown): unknown {
if (value instanceof Date) return value.getTime();
if (Array.isArray(value)) return value.map(toUnixMs);
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) out[k] = toUnixMs(v);
return out;
}
return value;
}
/**
* 统一响应封装:{ code, message, data },成功 code = 0;时间统一 Unix 毫秒。
* 统一响应封装:{ code, message, data },成功 code = 0。
*/
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, unknown> {
intercept(_context: ExecutionContext, next: CallHandler<T>): Observable<unknown> {
return next.handle().pipe(map((data) => ({ code: 0, message: 'ok', data: toUnixMs(data ?? null) })));
return next.handle().pipe(map((data) => ({ code: 0, message: 'ok', data: data ?? null })));
}
}

View File

@ -1,24 +0,0 @@
import { describe, expect, it } from 'vitest';
import { maskSensitive } from './mask.js';
describe('maskSensitive', () => {
it('脱敏密码/令牌类字段', () => {
expect(maskSensitive({ password: 'secret', token: 'abc' })).toEqual({ password: '***', token: '***' });
});
it('脱敏手机号与邮箱', () => {
expect(maskSensitive({ phone: '13800138000', email: 'a@b.com' })).toEqual({ phone: '***', email: '***' });
});
it('递归脱敏嵌套对象与数组', () => {
expect(maskSensitive({ user: { password: 'x' }, list: [{ phone: '1' }] })).toEqual({
user: { password: '***' },
list: [{ phone: '***' }],
});
});
it('原样返回基本类型', () => {
expect(maskSensitive('hello')).toBe('hello');
expect(maskSensitive(null)).toBeNull();
});
});

View File

@ -9,9 +9,6 @@ const SENSITIVE_KEYS = new Set([
'token',
'secret',
'authorization',
'phone',
'mobile',
'email',
]);
export function maskSensitive(value: unknown): unknown {

View File

@ -1,6 +1,6 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
import { IsInt, IsOptional, Min } from 'class-validator';
export class PageQuery {
@ApiPropertyOptional({ description: '页码(从 1 开始)', default: 1 })
@ -15,7 +15,6 @@ export class PageQuery {
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
pageSize = 20;
}

View File

@ -15,7 +15,6 @@ export const Permissions = {
update: 'user:update',
delete: 'user:delete',
resetPwd: 'user:reset-pwd',
assignRole: 'user:assign-role',
},
role: {
list: 'role:list',

View File

@ -1,194 +0,0 @@
import bcrypt from 'bcryptjs';
import type { PrismaClient } from '@prisma/client';
import { Permissions } from './permissions.js';
/** 菜单资源(导航)。 */
export const MENUS = [
{ code: 'dashboard', name: '工作台', parent: null, path: '/', component: 'dashboard/index', icon: 'mdi:view-dashboard', sort: 1 },
{ code: 'permission', name: '权限管理', parent: null, path: '/permission', component: 'Layout', icon: 'mdi:shield-account', sort: 2 },
{ code: 'user', name: '用户管理', parent: 'permission', path: '/system/user', component: 'system/user/index', icon: 'mdi:account-group', sort: 1 },
{ code: 'role', name: '角色管理', parent: 'permission', path: '/system/role', component: 'system/role/index', icon: 'mdi:account-key', sort: 2 },
{ code: 'resource', name: '资源管理', parent: 'permission', path: '/system/resource', component: 'system/resource/index', icon: 'mdi:view-list', sort: 3 },
{ code: 'org', name: '组织管理', parent: 'permission', path: '/system/org', component: 'system/org/index', icon: 'mdi:sitemap', sort: 4 },
{ code: 'system', name: '系统管理', parent: null, path: '/system', component: 'Layout', icon: 'mdi:cog', sort: 3 },
{ code: 'tenant', name: '租户管理', parent: 'system', path: '/system/tenant', component: 'system/tenant/index', icon: 'mdi:domain', sort: 1 },
{ code: 'dict', name: '字典管理', parent: 'system', path: '/system/dict', component: 'system/dict/index', icon: 'mdi:book-open-variant', sort: 2 },
{ code: 'config', name: '参数配置', parent: 'system', path: '/system/config', component: 'system/config/index', icon: 'mdi:tune', sort: 3 },
{ code: 'monitor', name: '系统监控', parent: null, path: '/monitor', component: 'Layout', icon: 'mdi:monitor-dashboard', sort: 4 },
{ code: 'audit', name: '操作日志', parent: 'monitor', path: '/monitor/audit', component: 'monitor/audit/index', icon: 'mdi:clipboard-text', sort: 1 },
{ code: 'login-log', name: '登录日志', parent: 'monitor', path: '/monitor/login-log', component: 'monitor/login-log/index', icon: 'mdi:login', sort: 2 },
];
/** 接口资源(每个受控接口一个,code = 权限码)。 */
export 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.user.assignRole, name: '分配角色', method: 'PUT', path: '/api/v1/users/:id/roles', 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' },
];
/** 按钮资源(纯前端按钮,无独立受控接口)。 */
export 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' },
];
/** 内置枚举字典(值对齐 enums.ts,仅展示层 label/颜色/排序可改)。 */
export const DICTS: { code: string; name: string; items: { label: string; value: string; colorType: string; sort: number }[] }[] = [
{ code: 'common_status', name: '通用状态', items: [
{ label: '启用', value: 'enabled', colorType: 'success', sort: 1 },
{ label: '停用', value: 'disabled', colorType: 'info', sort: 2 },
] },
{ code: 'user_status', name: '用户状态', items: [
{ label: '启用', value: 'enabled', colorType: 'success', sort: 1 },
{ label: '停用', value: 'disabled', colorType: 'info', sort: 2 },
{ label: '锁定', value: 'locked', colorType: 'danger', sort: 3 },
] },
{ code: 'resource_type', name: '资源类型', items: [
{ label: '菜单', value: 'menu', colorType: 'primary', sort: 1 },
{ label: '按钮', value: 'button', colorType: 'warning', sort: 2 },
{ label: '接口', value: 'api', colorType: 'info', sort: 3 },
] },
{ code: 'data_scope', name: '数据权限', items: [
{ label: '全部', value: 'all', colorType: 'success', sort: 1 },
{ label: '本部门', value: 'dept', colorType: 'info', sort: 2 },
{ label: '本部门及以下', value: 'dept_and_child', colorType: 'warning', sort: 3 },
{ label: '本人', value: 'self', colorType: 'danger', sort: 4 },
{ label: '自定义', value: 'custom', colorType: 'primary', sort: 5 },
] },
{ code: 'login_status', name: '登录状态', items: [
{ label: '成功', value: 'success', colorType: 'success', sort: 1 },
{ label: '失败', value: 'fail', colorType: 'danger', sort: 2 },
] },
];
export interface InitTenantOptions {
prisma: PrismaClient;
tenantCode: string;
tenantName: string;
/** 顶层管理员角色码:平台=super_admin,业务租户=tenant_super_admin。 */
adminRoleCode: string;
adminRoleName: string;
adminUsername: string;
adminPassword: string;
/** 是否包含平台级权限(tenant:*)——仅平台租户为 true。 */
includePlatformPerms: boolean;
}
/** 初始化一个租户:根组织、顶层角色、管理员、全量资源、内置字典、授权(幂等)。 */
export async function initTenant(opts: InitTenantOptions): Promise<void> {
const { prisma, tenantCode, tenantName, adminRoleCode, adminRoleName, adminUsername, adminPassword, includePlatformPerms } = opts;
// 1. 根组织
await prisma.org.upsert({
where: { tenantCode_code: { tenantCode, code: tenantCode } },
update: {},
create: { tenantCode, code: tenantCode, name: `${tenantName}总部`, parentCode: null, sort: 0, status: 'enabled' },
});
// 2. 顶层角色
await prisma.role.upsert({
where: { tenantCode_code: { tenantCode, code: adminRoleCode } },
update: {},
create: { tenantCode, code: adminRoleCode, name: adminRoleName, dataScope: 'all', status: 'enabled' },
});
// 3. 管理员账号(update 不重置密码/状态)
const passwordHash = await bcrypt.hash(adminPassword, 10);
const admin = await prisma.user.upsert({
where: { tenantCode_username: { tenantCode, username: adminUsername } },
update: {},
create: { tenantCode, username: adminUsername, passwordHash, nickname: '管理员', status: 'enabled', orgCode: tenantCode },
});
// 4. 资源(菜单 + 接口 + 按钮;业务租户剥离 tenant:* 平台级接口)
const apis = includePlatformPerms ? APIS : APIS.filter((a) => !a.code.startsWith('tenant:'));
const allResourceCodes: string[] = [];
for (const m of MENUS) {
allResourceCodes.push(m.code);
await prisma.resource.upsert({
where: { tenantCode_code: { tenantCode, code: m.code } },
update: { name: m.name, parentCode: m.parent, path: m.path, component: m.component, icon: m.icon, sort: m.sort, type: 'menu' },
create: { tenantCode, code: m.code, name: m.name, parentCode: m.parent, type: 'menu', path: m.path, component: m.component, icon: m.icon, sort: m.sort, status: 'enabled' },
});
}
for (const a of apis) {
allResourceCodes.push(a.code);
await prisma.resource.upsert({
where: { tenantCode_code: { tenantCode, code: a.code } },
update: { name: a.name, parentCode: a.parent, method: a.method, apiPath: a.path, type: 'api' },
create: { tenantCode, 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, code: b.code } },
update: { name: b.name, parentCode: b.parent, type: 'button' },
create: { tenantCode, code: b.code, name: b.name, parentCode: b.parent, type: 'button', status: 'enabled' },
});
}
// 5. 内置字典
for (const d of DICTS) {
await prisma.dictType.upsert({
where: { tenantCode_code: { tenantCode, code: d.code } },
update: { name: d.name, status: 'enabled', builtIn: true },
create: { tenantCode, code: d.code, name: d.name, status: 'enabled', builtIn: true },
});
for (const it of d.items) {
await prisma.dictItem.upsert({
where: { tenantCode_dictTypeCode_value: { tenantCode, dictTypeCode: d.code, value: it.value } },
update: { label: it.label, colorType: it.colorType, sort: it.sort, status: 'enabled', builtIn: true },
create: { tenantCode, dictTypeCode: d.code, label: it.label, value: it.value, colorType: it.colorType, sort: it.sort, status: 'enabled', builtIn: true },
});
}
}
// 6. 绑定管理员到顶层角色
await prisma.userRole.upsert({
where: { tenantCode_userId_roleCode: { tenantCode, userId: admin.id, roleCode: adminRoleCode } },
update: {},
create: { tenantCode, userId: admin.id, roleCode: adminRoleCode },
});
// 7. 顶层角色全量授权
for (const code of allResourceCodes) {
await prisma.roleResource.upsert({
where: { tenantCode_roleCode_resourceCode: { tenantCode, roleCode: adminRoleCode, resourceCode: code } },
update: {},
create: { tenantCode, roleCode: adminRoleCode, resourceCode: code },
});
}
}

View File

@ -1,7 +1,8 @@
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, ListConfigQuery, UpdateConfigDto } from './dto.js';
import { CreateConfigDto, UpdateConfigDto } from './dto.js';
import { ConfigsService } from './configs.service.js';
@Controller('configs')
@ -10,8 +11,8 @@ export class ConfigsController {
@Get()
@RequirePermissions(Permissions.config.list)
list(@Query() q: ListConfigQuery) {
return this.service.listConfigs(q);
list(@Query() q: PageQuery) {
return this.service.paginate(q.page, q.pageSize);
}
@Get(':id')

View File

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { BaseCrudService } from '../common/base-crud.service.js';
import { PrismaService } from '../prisma/prisma.service.js';
import { CreateConfigDto, ListConfigQuery, UpdateConfigDto } from './dto.js';
import { CreateConfigDto, UpdateConfigDto } from './dto.js';
@Injectable()
export class ConfigsService extends BaseCrudService {
@ -16,11 +16,4 @@ export class ConfigsService extends BaseCrudService {
updateConfig(id: string, dto: UpdateConfigDto) {
return this.update(id, { ...dto });
}
listConfigs(q: ListConfigQuery) {
const where: Record<string, unknown> = {};
if (q.key) where.key = { contains: q.key };
if (q.value) where.value = { contains: q.value };
return this.paginate(q.page, q.pageSize, where);
}
}

View File

@ -1,6 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { PageQuery } from '../common/page.dto.js';
export class CreateConfigDto {
@ApiProperty({ description: '配置 key(租户内唯一)' })
@ -30,15 +29,3 @@ export class UpdateConfigDto {
@IsString()
remark?: string;
}
export class ListConfigQuery extends PageQuery {
@ApiPropertyOptional({ description: '键名(模糊)' })
@IsOptional()
@IsString()
key?: string;
@ApiPropertyOptional({ description: '值(模糊)' })
@IsOptional()
@IsString()
value?: string;
}

View File

@ -38,6 +38,6 @@ export class DictItemsController {
@RequirePermissions(Permissions.dict.delete)
@AuditLog('dict', 'delete')
remove(@Param('id') id: string) {
return this.service.removeItem(id);
return this.service.softRemove(id);
}
}

View File

@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { BaseCrudService } from '../common/base-crud.service.js';
import { PrismaService } from '../prisma/prisma.service.js';
import { CreateDictItemDto, UpdateDictItemDto } from './dto.js';
@ -13,22 +13,10 @@ export class DictItemsService extends BaseCrudService {
return this.create({ ...dto });
}
/** 更新前拦截内置项 value 变更(label/sort/colorType 放行)。 */
async updateItem(id: string, dto: UpdateDictItemDto) {
const item = await this.findById(id);
if (item?.builtIn && dto.value !== undefined && dto.value !== item.value) {
throw new BadRequestException('内置字典项 value 不可修改');
}
updateItem(id: string, dto: UpdateDictItemDto) {
return this.update(id, { ...dto });
}
/** 删除前拦截内置项。 */
async removeItem(id: string) {
const item = await this.findById(id);
if (item?.builtIn) throw new BadRequestException('内置字典项不可删除');
return this.softRemove(id);
}
listByType(dictTypeCode?: string) {
return this.list(dictTypeCode ? { dictTypeCode } : {}, { sort: 'asc' });
}

View File

@ -39,6 +39,6 @@ export class DictTypesController {
@RequirePermissions(Permissions.dict.delete)
@AuditLog('dict', 'delete')
remove(@Param('id') id: string) {
return this.service.removeType(id);
return this.service.softRemove(id);
}
}

View File

@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { BaseCrudService } from '../common/base-crud.service.js';
import { PrismaService } from '../prisma/prisma.service.js';
import { CreateDictTypeDto, UpdateDictTypeDto } from './dto.js';
@ -16,11 +16,4 @@ export class DictTypesService extends BaseCrudService {
updateType(id: string, dto: UpdateDictTypeDto) {
return this.update(id, { ...dto });
}
/** 删除前拦截内置类型。 */
async removeType(id: string) {
const type = await this.findById(id);
if (type?.builtIn) throw new BadRequestException('内置字典类型不可删除');
return this.softRemove(id);
}
}

View File

@ -1,13 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { DictsService } from './dicts.service.js';
/** 字典聚合接口:前端登录后一次性拉取全部启用字典(仅登录态,无需权限码)。 */
@Controller('dicts')
export class DictsController {
constructor(private readonly service: DictsService) {}
@Get()
all() {
return this.service.allEnabled();
}
}

View File

@ -3,11 +3,9 @@ import { DictTypesController } from './dict-types.controller.js';
import { DictTypesService } from './dict-types.service.js';
import { DictItemsController } from './dict-items.controller.js';
import { DictItemsService } from './dict-items.service.js';
import { DictsController } from './dicts.controller.js';
import { DictsService } from './dicts.service.js';
@Module({
controllers: [DictTypesController, DictItemsController, DictsController],
providers: [DictTypesService, DictItemsService, DictsService],
controllers: [DictTypesController, DictItemsController],
providers: [DictTypesService, DictItemsService],
})
export class DictsModule {}

View File

@ -1,39 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service.js';
export interface DictAggregateItem {
label: string;
value: string;
colorType: string | null;
sort: number;
}
export interface DictAggregateType {
code: string;
name: string;
items: DictAggregateItem[];
}
/** 字典聚合查询:当前租户全部启用字典类型 + 启用字典项。 */
@Injectable()
export class DictsService {
constructor(private readonly prisma: PrismaService) {}
async allEnabled(): Promise<{ list: DictAggregateType[] }> {
const [types, items] = await Promise.all([
this.prisma.client.dictType.findMany({ where: { status: 'enabled' }, orderBy: { createdAt: 'asc' } }),
this.prisma.client.dictItem.findMany({
where: { status: 'enabled' },
orderBy: [{ sort: 'asc' }, { createdAt: 'asc' }],
}),
]);
const list = types.map((t) => ({
code: t.code,
name: t.name,
items: items
.filter((i) => i.dictTypeCode === t.code)
.map((i) => ({ label: i.label, value: i.value, colorType: i.colorType, sort: i.sort })),
}));
return { list };
}
}

View File

@ -1,9 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { CommonStatus } from '../common/enums.js';
/** 标签色(对应前端 TagType)。 */
const COLOR_TYPES = ['primary', 'success', 'warning', 'danger', 'info'] as const;
import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateDictTypeDto {
@ApiProperty({ description: '字典类型编码' })
@ -30,7 +26,7 @@ export class UpdateDictTypeDto {
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
@IsOptional()
@IsIn(Object.values(CommonStatus))
@IsString()
status?: string;
@ApiPropertyOptional({ description: '备注' })
@ -59,11 +55,6 @@ export class CreateDictItemDto {
@IsOptional()
@IsInt()
sort?: number;
@ApiPropertyOptional({ description: '标签色:primary/success/warning/danger/info' })
@IsOptional()
@IsIn(COLOR_TYPES)
colorType?: string;
}
export class UpdateDictItemDto {
@ -84,11 +75,6 @@ export class UpdateDictItemDto {
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
@IsOptional()
@IsIn(Object.values(CommonStatus))
@IsString()
status?: string;
@ApiPropertyOptional({ description: '标签色:primary/success/warning/danger/info' })
@IsOptional()
@IsIn(COLOR_TYPES)
colorType?: string;
}

View File

@ -1,15 +0,0 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
import { PageQuery } from '../common/page.dto.js';
export class ListLoginLogQuery extends PageQuery {
@ApiPropertyOptional({ description: '用户名(模糊)' })
@IsOptional()
@IsString()
username?: string;
@ApiPropertyOptional({ description: '状态:success/fail' })
@IsOptional()
@IsString()
status?: string;
}

View File

@ -1,7 +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 { ListLoginLogQuery } from './dto.js';
import { LoginLogsService } from './login-logs.service.js';
@Controller('login-logs')
@ -10,7 +10,7 @@ export class LoginLogsController {
@Get()
@RequirePermissions(Permissions.loginLog.list)
list(@Query() q: ListLoginLogQuery) {
return this.service.listLoginLogs(q);
list(@Query() q: PageQuery) {
return this.service.paginate(q.page, q.pageSize);
}
}

View File

@ -2,8 +2,6 @@ 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';
import { getRequestContext } from '../common/context/request-context.js';
import { ListLoginLogQuery } from './dto.js';
export interface LoginLogEntry {
tenantCode: string;
@ -31,28 +29,4 @@ export class LoginLogsService extends BaseCrudService {
this.logger.warn({ err }, '登录日志写入失败');
}
}
async listLoginLogs(q: ListLoginLogQuery) {
const ctx = getRequestContext();
const where: Record<string, unknown> = {};
if (q.username) where.username = { contains: q.username };
if (q.status) where.status = q.status;
// 数据权限:仅本人按 username;否则按可见部门内的用户
if (ctx && ctx.dataScope !== 'all') {
if (ctx.dataScope === 'self') {
where.username = ctx.username;
} else {
const usernames = ctx.orgCodes.length
? (
await this.prisma.client.user.findMany({
where: { tenantCode: ctx.tenantCode, orgCode: { in: ctx.orgCodes }, deletedAt: null },
select: { username: true },
})
).map((u) => u.username)
: [];
where.username = { in: usernames };
}
}
return this.paginate(q.page, q.pageSize, where);
}
}

View File

@ -3,33 +3,29 @@ import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { Logger } from 'nestjs-pino';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import helmet from 'helmet';
import { AppModule } from './app.module.js';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.setGlobalPrefix('api/v1');
app.use(helmet());
const corsOrigin = process.env.CORS_ORIGIN
? process.env.CORS_ORIGIN.split(',').map((s) => s.trim())
: ['http://localhost:5173'];
app.enableCors({ origin: corsOrigin, credentials: true });
app.enableCors();
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.useLogger(app.get(Logger));
// Swagger 文档仅非生产环境开放(生产默认关闭)
if (process.env.NODE_ENV !== 'production') {
const swaggerConfig = new DocumentBuilder()
.setTitle('系统管理 API')
.setDescription('投云科技系统管理 API 文档')
.setVersion('1.0')
.addBearerAuth()
.build();
SwaggerModule.setup('docs', app, SwaggerModule.createDocument(app, swaggerConfig));
}
SwaggerModule.setup(
'docs',
app,
SwaggerModule.createDocument(app, swaggerConfig),
);
const port = Number(process.env.PORT ?? 3000);
await app.listen(port);
app.get(Logger).log(`服务启动成功,端口 ${port}`);
app.get(Logger).log(`服务启动成功,端口 ${port},Swagger 文档: http://localhost:${port}/docs`);
}
await bootstrap();

View File

@ -1,6 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { CommonStatus } from '../common/enums.js';
import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateOrgDto {
@ApiProperty({ description: '组织 code(租户内唯一)' })
@ -52,6 +51,6 @@ export class UpdateOrgDto {
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
@IsOptional()
@IsIn(Object.values(CommonStatus))
@IsString()
status?: string;
}

View File

@ -11,7 +11,7 @@ export class OrgsController {
@Get()
@RequirePermissions(Permissions.org.list)
list() {
return this.service.listOrgs();
return this.service.list({}, { sort: 'asc' });
}
@Get(':id')

View File

@ -1,7 +1,6 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { BaseCrudService } from '../common/base-crud.service.js';
import { PrismaService } from '../prisma/prisma.service.js';
import { getRequestContext } from '../common/context/request-context.js';
import { CreateOrgDto, UpdateOrgDto } from './dto.js';
@Injectable()
@ -10,45 +9,11 @@ export class OrgsService extends BaseCrudService {
super(prisma, 'org');
}
/** 组织树列表:按数据权限过滤(空 orgCodes=全部可见)。 */
listOrgs() {
const ctx = getRequestContext();
const where: Record<string, unknown> = {};
if (ctx && ctx.orgCodes.length > 0) {
where.code = { in: ctx.orgCodes };
}
return this.list(where, { sort: 'asc' });
}
async createOrg(dto: CreateOrgDto) {
await this.assertValidParent(dto.parentCode);
createOrg(dto: CreateOrgDto) {
return this.create({ ...dto });
}
async updateOrg(id: string, dto: UpdateOrgDto) {
if (dto.parentCode !== undefined) {
const current = await this.findById(id);
await this.assertValidParent(dto.parentCode, current?.code);
}
updateOrg(id: string, dto: UpdateOrgDto) {
return this.update(id, { ...dto });
}
/** 校验父组织存在,且不会构成环(父不能是自身或自身后代)。 */
private async assertValidParent(parentCode: string | null | undefined, selfCode?: string): Promise<void> {
if (!parentCode) return; // 根节点
const parent = await this.db().findFirst({ where: { code: parentCode } });
if (!parent) throw new BadRequestException('父组织不存在');
if (!selfCode) return;
let cur: string | null = parentCode;
const visited = new Set<string>();
while (cur && !visited.has(cur)) {
if (cur === selfCode) throw new BadRequestException('不能将组织挂到自身或其子组织下');
visited.add(cur);
const node: { parentCode: string | null } | null = await this.db().findFirst({
where: { code: cur },
select: { parentCode: true },
});
cur = node?.parentCode ?? null;
}
}
}

View File

@ -17,12 +17,9 @@ const NO_WHERE_OPS = new Set(['create', 'createMany']);
@Injectable()
export class PrismaService implements OnModuleInit, OnModuleDestroy {
readonly client: PrismaClient;
/** 未扩展的裸客户端(无租户/软删注入),供 seed/租户模板等跨租户初始化使用。 */
readonly raw: PrismaClient;
constructor() {
this.raw = new PrismaClient();
this.client = this.raw.$extends({
this.client = new PrismaClient().$extends({
query: {
$allModels: {
async $allOperations({ model, operation, args, query }: any) {

View File

@ -1,10 +1,9 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { CommonStatus, ResourceType } from '../common/enums.js';
import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateResourceDto {
@ApiProperty({ description: '类型:menu/button/api' })
@IsIn(Object.values(ResourceType))
@IsString()
@IsNotEmpty()
type!: string;
@ -97,6 +96,6 @@ export class UpdateResourceDto {
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
@IsOptional()
@IsIn(Object.values(CommonStatus))
@IsString()
status?: string;
}

View File

@ -1,7 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { PageQuery } from '../common/page.dto.js';
import { CommonStatus, DataScope } from '../common/enums.js';
import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateRoleDto {
@ApiProperty({ description: '角色编码' })
@ -16,7 +14,7 @@ export class CreateRoleDto {
@ApiPropertyOptional({ description: '数据权限:all/dept/dept_and_child/self/custom', default: 'all' })
@IsOptional()
@IsIn(Object.values(DataScope))
@IsString()
dataScope?: string;
@ApiPropertyOptional({ description: '备注' })
@ -33,12 +31,12 @@ export class UpdateRoleDto {
@ApiPropertyOptional({ description: '数据权限:all/dept/dept_and_child/self/custom' })
@IsOptional()
@IsIn(Object.values(DataScope))
@IsString()
dataScope?: string;
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
@IsOptional()
@IsIn(Object.values(CommonStatus))
@IsString()
status?: string;
@ApiPropertyOptional({ description: '备注' })
@ -53,20 +51,3 @@ export class AssignResourcesDto {
@IsString({ each: true })
resourceCodes!: string[];
}
export class ListRoleQuery extends PageQuery {
@ApiPropertyOptional({ description: '角色名称(模糊)' })
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({ description: '角色编码(模糊)' })
@IsOptional()
@IsString()
code?: string;
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
@IsOptional()
@IsIn(Object.values(CommonStatus))
status?: string;
}

View File

@ -1,7 +1,8 @@
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, ListRoleQuery, UpdateRoleDto } from './dto.js';
import { AssignResourcesDto, CreateRoleDto, UpdateRoleDto } from './dto.js';
import { RolesService } from './roles.service.js';
@Controller('roles')
@ -10,8 +11,8 @@ export class RolesController {
@Get()
@RequirePermissions(Permissions.role.list)
list(@Query() q: ListRoleQuery) {
return this.service.listRoles(q);
list(@Query() q: PageQuery) {
return this.service.paginate(q.page, q.pageSize);
}
@Get(':id')
@ -38,7 +39,7 @@ export class RolesController {
@RequirePermissions(Permissions.role.delete)
@AuditLog('role', 'delete')
remove(@Param('id') id: string) {
return this.service.removeRole(id);
return this.service.softRemove(id);
}
@Get(':id/resources')

View File

@ -1,10 +1,9 @@
import { BadRequestException, Injectable } from '@nestjs/common';
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 { PLATFORM_PERMISSION_PREFIX, PLATFORM_TENANT_CODE, RESERVED_ROLE_CODES } from '../common/constants.js';
import { AssignResourcesDto, CreateRoleDto, ListRoleQuery, UpdateRoleDto } from './dto.js';
import { AssignResourcesDto, CreateRoleDto, UpdateRoleDto } from './dto.js';
@Injectable()
export class RolesService extends BaseCrudService {
@ -16,9 +15,6 @@ export class RolesService extends BaseCrudService {
}
createRole(dto: CreateRoleDto) {
if (RESERVED_ROLE_CODES.includes(dto.code)) {
throw new BadRequestException(`角色编码 ${dto.code} 为系统保留,不可创建`);
}
return this.create({ ...dto });
}
@ -26,31 +22,6 @@ export class RolesService extends BaseCrudService {
return this.update(id, { ...dto });
}
listRoles(q: ListRoleQuery) {
const where: Record<string, unknown> = {};
if (q.name) where.name = { contains: q.name };
if (q.code) where.code = { contains: q.code };
if (q.status) where.status = q.status;
return this.paginate(q.page, q.pageSize, where);
}
/** 删除角色:级联清理关联并清权限缓存(保留角色不可删)。 */
async removeRole(id: string) {
const ctx = getRequestContext()!;
const role = await this.findById(id);
if (!role) throw new BadRequestException('角色不存在');
if (RESERVED_ROLE_CODES.includes(role.code)) {
throw new BadRequestException(`角色 ${role.code} 为系统保留角色,不可删除`);
}
const roleCode = role.code;
await this.prisma.client.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
await this.prisma.client.roleOrg.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
await this.prisma.client.userRole.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
await this.softRemove(id);
await this.redis.delByPrefix(`perm:${ctx.tenantCode}:`);
return null;
}
async assignedResources(roleCode: string) {
const ctx = getRequestContext()!;
const rows = await this.prisma.client.roleResource.findMany({
@ -62,22 +33,12 @@ export class RolesService extends BaseCrudService {
async assignResources(roleCode: string, dto: AssignResourcesDto) {
const ctx = getRequestContext()!;
// 平台级权限(tenant:*)仅平台租户可授予,非平台租户一律剥离
const resourceCodes =
ctx.tenantCode === PLATFORM_TENANT_CODE
? dto.resourceCodes
: dto.resourceCodes.filter((code) => !code.startsWith(PLATFORM_PERMISSION_PREFIX));
const role = await this.db().findFirst({ where: { code: roleCode } });
if (!role) throw new BadRequestException('角色不存在');
// 事务化:删除 + 重建授权,避免中途失败导致角色授权被清空
await this.prisma.client.$transaction(async (tx) => {
await tx.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
if (resourceCodes.length > 0) {
await tx.roleResource.createMany({
data: resourceCodes.map((resourceCode) => ({ tenantCode: ctx.tenantCode, roleCode, resourceCode })),
await this.prisma.client.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
if (dto.resourceCodes.length > 0) {
await this.prisma.client.roleResource.createMany({
data: dto.resourceCodes.map((resourceCode) => ({ tenantCode: ctx.tenantCode, roleCode, resourceCode })),
});
}
});
// 授权变更:删除该租户全部用户权限缓存,下一次请求守卫重新聚合并回填
await this.redis.delByPrefix(`perm:${ctx.tenantCode}:`);
return null;

View File

@ -1,7 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { PageQuery } from '../common/page.dto.js';
import { CommonStatus } from '../common/enums.js';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateTenantDto {
@ApiProperty({ description: '租户编码' })
@ -16,19 +14,9 @@ export class CreateTenantDto {
@ApiPropertyOptional({ description: '状态:enabled/disabled', default: 'enabled' })
@IsOptional()
@IsIn(Object.values(CommonStatus))
@IsString()
status?: string;
@ApiPropertyOptional({ description: '初始管理员用户名', default: 'admin' })
@IsOptional()
@IsString()
adminUsername?: string;
@ApiPropertyOptional({ description: '初始管理员密码', default: 'Admin@123456' })
@IsOptional()
@IsString()
adminPassword?: string;
@ApiPropertyOptional({ description: '备注' })
@IsOptional()
@IsString()
@ -43,7 +31,7 @@ export class UpdateTenantDto {
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
@IsOptional()
@IsIn(Object.values(CommonStatus))
@IsString()
status?: string;
@ApiPropertyOptional({ description: '备注' })
@ -51,20 +39,3 @@ export class UpdateTenantDto {
@IsString()
remark?: string;
}
export class ListTenantQuery extends PageQuery {
@ApiPropertyOptional({ description: '租户名称(模糊)' })
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({ description: '租户编码(模糊)' })
@IsOptional()
@IsString()
code?: string;
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
@IsOptional()
@IsIn(Object.values(CommonStatus))
status?: string;
}

View File

@ -1,7 +1,8 @@
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, ListTenantQuery, UpdateTenantDto } from './dto.js';
import { CreateTenantDto, UpdateTenantDto } from './dto.js';
import { TenantsService } from './tenants.service.js';
@Controller('tenants')
@ -10,8 +11,8 @@ export class TenantsController {
@Get()
@RequirePermissions(Permissions.tenant.list)
list(@Query() q: ListTenantQuery) {
return this.service.listTenants(q);
list(@Query() q: PageQuery) {
return this.service.paginate(q.page, q.pageSize);
}
@Get(':id')

View File

@ -1,9 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BaseCrudService } from '../common/base-crud.service.js';
import { PrismaService } from '../prisma/prisma.service.js';
import { TENANT_ADMIN_ROLE_CODE } from '../common/constants.js';
import { initTenant } from '../common/tenant-template.js';
import { CreateTenantDto, ListTenantQuery, UpdateTenantDto } from './dto.js';
import { CreateTenantDto, UpdateTenantDto } from './dto.js';
@Injectable()
export class TenantsService extends BaseCrudService {
@ -11,33 +9,8 @@ export class TenantsService extends BaseCrudService {
super(prisma, 'tenant');
}
/** 创建租户并初始化:根组织 + 租户管理员角色 + 管理员账号 + 资源 + 字典 + 授权。 */
async createTenant(dto: CreateTenantDto) {
const tenant = await this.create({
code: dto.code,
name: dto.name,
status: dto.status ?? 'enabled',
remark: dto.remark,
});
await initTenant({
prisma: this.prisma.raw,
tenantCode: dto.code,
tenantName: dto.name,
adminRoleCode: TENANT_ADMIN_ROLE_CODE,
adminRoleName: '租户管理员',
adminUsername: dto.adminUsername ?? 'admin',
adminPassword: dto.adminPassword ?? 'Admin@123456',
includePlatformPerms: false,
});
return tenant;
}
listTenants(q: ListTenantQuery) {
const where: Record<string, unknown> = {};
if (q.name) where.name = { contains: q.name };
if (q.code) where.code = { contains: q.code };
if (q.status) where.status = q.status;
return this.paginate(q.page, q.pageSize, where);
return this.create({ ...dto });
}
async updateTenant(id: string, dto: UpdateTenantDto) {
@ -46,28 +19,9 @@ export class TenantsService extends BaseCrudService {
return this.update(id, { ...dto });
}
/** 删除租户并级联清理其全部业务数据。 */
async removeTenant(id: string) {
const existing = await this.findById(id);
if (!existing) throw new NotFoundException('租户不存在');
const code = existing.code;
const db = this.prisma.raw;
await db.$transaction([
db.userRole.deleteMany({ where: { tenantCode: code } }),
db.roleResource.deleteMany({ where: { tenantCode: code } }),
db.roleOrg.deleteMany({ where: { tenantCode: code } }),
db.refreshToken.deleteMany({ where: { tenantCode: code } }),
db.loginLog.deleteMany({ where: { tenantCode: code } }),
db.auditLog.deleteMany({ where: { tenantCode: code } }),
db.config.deleteMany({ where: { tenantCode: code } }),
db.dictItem.deleteMany({ where: { tenantCode: code } }),
db.dictType.deleteMany({ where: { tenantCode: code } }),
db.org.deleteMany({ where: { tenantCode: code } }),
db.resource.deleteMany({ where: { tenantCode: code } }),
db.role.deleteMany({ where: { tenantCode: code } }),
db.user.deleteMany({ where: { tenantCode: code } }),
db.tenant.delete({ where: { id } }),
]);
return null;
return this.db().delete({ where: { id } });
}
}

View File

@ -1,7 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
import { PageQuery } from '../common/page.dto.js';
import { UserStatus } from '../common/enums.js';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateUserDto {
@ApiProperty({ description: '用户名' })
@ -12,7 +10,6 @@ export class CreateUserDto {
@ApiProperty({ description: '初始密码' })
@IsString()
@IsNotEmpty()
@MinLength(6, { message: '密码长度不能少于 6 位' })
password!: string;
@ApiPropertyOptional({ description: '昵称' })
@ -69,7 +66,7 @@ export class UpdateUserDto {
@ApiPropertyOptional({ description: '状态:enabled/disabled/locked' })
@IsOptional()
@IsIn(Object.values(UserStatus))
@IsString()
status?: string;
}
@ -77,35 +74,5 @@ export class ResetPasswordDto {
@ApiProperty({ description: '新密码' })
@IsString()
@IsNotEmpty()
@MinLength(6, { message: '密码长度不能少于 6 位' })
password!: string;
}
export class AssignRolesDto {
@ApiProperty({ description: '角色 code 列表', type: [String] })
@IsArray()
@IsString({ each: true })
roleCodes!: string[];
}
export class ListUserQuery extends PageQuery {
@ApiPropertyOptional({ description: '用户名(模糊)' })
@IsOptional()
@IsString()
username?: string;
@ApiPropertyOptional({ description: '昵称(模糊)' })
@IsOptional()
@IsString()
nickname?: string;
@ApiPropertyOptional({ description: '手机号(模糊)' })
@IsOptional()
@IsString()
phone?: string;
@ApiPropertyOptional({ description: '状态:enabled/disabled/locked' })
@IsOptional()
@IsIn(Object.values(UserStatus))
status?: string;
}

View File

@ -1,7 +1,8 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
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 { AssignRolesDto, CreateUserDto, ListUserQuery, ResetPasswordDto, UpdateUserDto } from './dto.js';
import { CreateUserDto, ResetPasswordDto, UpdateUserDto } from './dto.js';
import { UsersService } from './users.service.js';
@Controller('users')
@ -10,7 +11,7 @@ export class UsersController {
@Get()
@RequirePermissions(Permissions.user.list)
list(@Query() q: ListUserQuery) {
list(@Query() q: PageQuery) {
return this.service.listUsers(q);
}
@ -41,19 +42,6 @@ export class UsersController {
return this.service.resetPassword(id, dto);
}
@Get(':id/roles')
@RequirePermissions(Permissions.user.list)
roles(@Param('id') id: string) {
return this.service.assignedRoles(id);
}
@Put(':id/roles')
@RequirePermissions(Permissions.user.assignRole)
@AuditLog('user', 'assign-role')
assignRoles(@Param('id') id: string, @Body() dto: AssignRolesDto) {
return this.service.assignRoles(id, dto);
}
@Delete(':id')
@RequirePermissions(Permissions.user.delete)
@AuditLog('user', 'delete')

View File

@ -1,18 +1,14 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import bcrypt from 'bcryptjs';
import { BaseCrudService } from '../common/base-crud.service.js';
import { PrismaService } from '../prisma/prisma.service.js';
import { RedisService } from '../common/redis/redis.service.js';
import { getRequestContext } from '../common/context/request-context.js';
import { permCacheKey, userStateCacheKey } from '../common/constants.js';
import { AssignRolesDto, CreateUserDto, ListUserQuery, ResetPasswordDto, UpdateUserDto } from './dto.js';
import { CreateUserDto, ResetPasswordDto, UpdateUserDto } from './dto.js';
import { PageQuery } from '../common/page.dto.js';
@Injectable()
export class UsersService extends BaseCrudService {
constructor(
prisma: PrismaService,
private readonly redis: RedisService,
) {
constructor(prisma: PrismaService) {
super(prisma, 'user');
}
@ -23,17 +19,11 @@ export class UsersService extends BaseCrudService {
return this.omitPasswordHash(created);
}
async listUsers(q: ListUserQuery) {
async listUsers(q: PageQuery) {
const ctx = getRequestContext();
const where: Record<string, unknown> = {};
if (q.username) where.username = { contains: q.username };
if (q.nickname) where.nickname = { contains: q.nickname };
if (q.phone) where.phone = { contains: q.phone };
if (q.status) where.status = q.status;
// 数据权限:仅本人按 userId 过滤;否则限制在可见部门集合(空数组=全部可见)
if (ctx && ctx.dataScope === 'self') {
where.id = ctx.userId;
} else if (ctx && ctx.orgCodes.length > 0) {
// 数据权限:orgCodes 为空表示「全部可见」,否则限制在可见部门集合
if (ctx && ctx.orgCodes.length > 0) {
where.orgCode = { in: ctx.orgCodes };
}
const result = await this.paginate(q.page, q.pageSize, where);
@ -42,87 +32,26 @@ export class UsersService extends BaseCrudService {
}
async findById(id: string) {
await this.assertCanView(id);
const user = await super.findById(id);
return user ? this.omitPasswordHash(user) : user;
}
/** 详情查看的数据权限校验:越界直接 403。 */
private async assertCanView(userId: string): Promise<void> {
const ctx = getRequestContext();
if (!ctx || ctx.dataScope === 'all') return;
if (ctx.dataScope === 'self') {
if (userId !== ctx.userId) throw new ForbiddenException('无权查看该用户');
return;
}
const scopes = ctx.orgCodes.length ? ctx.orgCodes : ['__none__'];
const target = await this.db().findFirst({ where: { id: userId, orgCode: { in: scopes } } });
if (!target) throw new ForbiddenException('无权查看该用户');
}
async updateUser(id: string, dto: UpdateUserDto) {
const updated = await this.update(id, { ...dto });
if (dto.status && dto.status !== 'enabled') {
await this.invalidateSessions(id);
}
return this.omitPasswordHash(updated);
}
async resetPassword(id: string, dto: ResetPasswordDto) {
const passwordHash = await bcrypt.hash(dto.password, 10);
const updated = await this.update(id, { passwordHash });
await this.invalidateSessions(id);
return this.omitPasswordHash(updated);
}
async softRemove(id: string) {
const removed = await super.softRemove(id);
await this.invalidateSessions(id);
return this.omitPasswordHash(removed);
}
/** 吊销该用户所有会话:tokenVersion 自增 + 删 refresh token + 清缓存。 */
private async invalidateSessions(userId: string): Promise<void> {
const ctx = getRequestContext();
if (!ctx) return;
await this.db().updateMany({ where: { id: userId }, data: { tokenVersion: { increment: 1 } } });
await this.prisma.client.refreshToken.deleteMany({ where: { tenantCode: ctx.tenantCode, userId } });
await this.redis.del(permCacheKey(ctx.tenantCode, userId));
await this.redis.del(userStateCacheKey(ctx.tenantCode, userId));
}
/** 用户已分配的角色 code 列表。 */
async assignedRoles(userId: string): Promise<string[]> {
const ctx = getRequestContext()!;
const rows = await this.prisma.client.userRole.findMany({
where: { tenantCode: ctx.tenantCode, userId },
select: { roleCode: true },
});
return rows.map((r) => r.roleCode);
}
/** 分配角色:事务化替换,过滤不存在的角色,并清该用户权限缓存。 */
async assignRoles(userId: string, dto: AssignRolesDto): Promise<null> {
const ctx = getRequestContext()!;
const roles = dto.roleCodes.length
? await this.prisma.client.role.findMany({
where: { tenantCode: ctx.tenantCode, code: { in: dto.roleCodes }, deletedAt: null },
select: { code: true },
})
: [];
const validCodes = roles.map((r) => r.code);
await this.prisma.client.$transaction(async (tx) => {
await tx.userRole.deleteMany({ where: { tenantCode: ctx.tenantCode, userId } });
if (validCodes.length > 0) {
await tx.userRole.createMany({
data: validCodes.map((roleCode) => ({ tenantCode: ctx.tenantCode, userId, roleCode })),
});
}
});
await this.redis.del(permCacheKey(ctx.tenantCode, userId));
return null;
}
/** 响应边界剥掉 passwordHash,避免泄漏(登录内部校验直接查库,不受影响)。 */
private omitPasswordHash<T extends { passwordHash?: unknown }>(row: T): Omit<T, 'passwordHash'> {
const { passwordHash: _passwordHash, ...rest } = row;

View File

@ -1,45 +1,29 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types.js';
import { AppModule } from './../src/app.module.js';
/**
* 登录链路 e2e:需要已启动的 MySQL/Redis 且已执行 pnpm seed(默认账号 touyun / Touyun@123)。
*/
describe('Auth (e2e)', () => {
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeAll(async () => {
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.setGlobalPrefix('api/v1');
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
});
it('登录成功返回 token(按用户名自动探测租户)', async () => {
const res = await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({ username: 'touyun', password: 'Touyun@123' })
.expect(201);
expect(res.body.code).toBe(0);
expect(res.body.data.accessToken).toBeDefined();
expect(res.body.data.refreshToken).toBeDefined();
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
it('密码错误返回 401', async () => {
const res = await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({ username: 'touyun', password: 'wrong-password' })
.expect(401);
expect(res.body.code).toBe(40100);
});
afterAll(async () => {
afterEach(async () => {
await app.close();
});
});