Compare commits
6 Commits
web-recove
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 569d827b78 | |||
| 0024562863 | |||
| 3cd7a044ca | |||
| f22469d400 | |||
| 48a6cb99db | |||
| 2812832fa5 |
@ -3,6 +3,7 @@
|
|||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true
|
"deleteOutDir": true,
|
||||||
|
"plugins": ["@nestjs/swagger"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,11 +16,15 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
|
"@nestjs/config": "^4.0.3",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
"@nestjs/mapped-types": "*",
|
"@nestjs/mapped-types": "*",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"@nestjs/swagger": "^11.2.6",
|
||||||
"@prisma/adapter-pg": "^7.5.0",
|
"@prisma/adapter-pg": "^7.5.0",
|
||||||
"@prisma/client": "^7.5.0",
|
"@prisma/client": "^7.5.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.15.1",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
|
|||||||
161
pnpm-lock.yaml
generated
161
pnpm-lock.yaml
generated
@ -10,22 +10,34 @@ importers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common':
|
'@nestjs/common':
|
||||||
specifier: ^11.0.1
|
specifier: ^11.0.1
|
||||||
version: 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
version: 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/config':
|
||||||
|
specifier: ^4.0.3
|
||||||
|
version: 4.0.3(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)
|
||||||
'@nestjs/core':
|
'@nestjs/core':
|
||||||
specifier: ^11.0.1
|
specifier: ^11.0.1
|
||||||
version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
'@nestjs/mapped-types':
|
'@nestjs/mapped-types':
|
||||||
specifier: '*'
|
specifier: '*'
|
||||||
version: 2.1.0(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)
|
version: 2.1.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)
|
||||||
'@nestjs/platform-express':
|
'@nestjs/platform-express':
|
||||||
specifier: ^11.0.1
|
specifier: ^11.0.1
|
||||||
version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
|
version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
|
||||||
|
'@nestjs/swagger':
|
||||||
|
specifier: ^11.2.6
|
||||||
|
version: 11.2.6(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)
|
||||||
'@prisma/adapter-pg':
|
'@prisma/adapter-pg':
|
||||||
specifier: ^7.5.0
|
specifier: ^7.5.0
|
||||||
version: 7.5.0
|
version: 7.5.0
|
||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^7.5.0
|
specifier: ^7.5.0
|
||||||
version: 7.5.0(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3)
|
version: 7.5.0(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3)
|
||||||
|
class-transformer:
|
||||||
|
specifier: ^0.5.1
|
||||||
|
version: 0.5.1
|
||||||
|
class-validator:
|
||||||
|
specifier: ^0.15.1
|
||||||
|
version: 0.15.1
|
||||||
pg:
|
pg:
|
||||||
specifier: ^8.20.0
|
specifier: ^8.20.0
|
||||||
version: 8.20.0
|
version: 8.20.0
|
||||||
@ -44,7 +56,7 @@ importers:
|
|||||||
version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3)
|
version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3)
|
||||||
'@nestjs/testing':
|
'@nestjs/testing':
|
||||||
specifier: ^11.0.1
|
specifier: ^11.0.1
|
||||||
version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)
|
version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)
|
||||||
'@types/express':
|
'@types/express':
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.0.6
|
version: 5.0.6
|
||||||
@ -332,6 +344,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
|
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
'@microsoft/tsdoc@0.16.0':
|
||||||
|
resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==}
|
||||||
|
|
||||||
'@mrleebo/prisma-ast@0.13.1':
|
'@mrleebo/prisma-ast@0.13.1':
|
||||||
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
|
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
@ -362,6 +377,12 @@ packages:
|
|||||||
class-validator:
|
class-validator:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@nestjs/config@4.0.3':
|
||||||
|
resolution: {integrity: sha512-FQ3M3Ohqfl+nHAn5tp7++wUQw0f2nAk+SFKe8EpNRnIifPqvfJP6JQxPKtFLMOHbyer4X646prFG4zSRYEssQQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@nestjs/common': ^10.0.0 || ^11.0.0
|
||||||
|
rxjs: ^7.1.0
|
||||||
|
|
||||||
'@nestjs/core@11.1.16':
|
'@nestjs/core@11.1.16':
|
||||||
resolution: {integrity: sha512-tXWXyCiqWthelJjrE0KLFjf0O98VEt+WPVx5CrqCf+059kIxJ8y1Vw7Cy7N4fwQafWNrmFL2AfN87DDMbVAY0w==}
|
resolution: {integrity: sha512-tXWXyCiqWthelJjrE0KLFjf0O98VEt+WPVx5CrqCf+059kIxJ8y1Vw7Cy7N4fwQafWNrmFL2AfN87DDMbVAY0w==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
@ -404,6 +425,23 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '>=4.8.2'
|
typescript: '>=4.8.2'
|
||||||
|
|
||||||
|
'@nestjs/swagger@11.2.6':
|
||||||
|
resolution: {integrity: sha512-oiXOxMQqDFyv1AKAqFzSo6JPvMEs4uA36Eyz/s2aloZLxUjcLfUMELSLSNQunr61xCPTpwEOShfmO7NIufKXdA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@fastify/static': ^8.0.0 || ^9.0.0
|
||||||
|
'@nestjs/common': ^11.0.1
|
||||||
|
'@nestjs/core': ^11.0.1
|
||||||
|
class-transformer: '*'
|
||||||
|
class-validator: '*'
|
||||||
|
reflect-metadata: ^0.1.12 || ^0.2.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@fastify/static':
|
||||||
|
optional: true
|
||||||
|
class-transformer:
|
||||||
|
optional: true
|
||||||
|
class-validator:
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@nestjs/testing@11.1.16':
|
'@nestjs/testing@11.1.16':
|
||||||
resolution: {integrity: sha512-E7/aUCxzeMSJV80L5GWGIuiMyR/1ncS7uOIetAImfbS4ATE1/h2GBafk0qpk+vjFtPIbtoh9BWDGICzUEU5jDA==}
|
resolution: {integrity: sha512-E7/aUCxzeMSJV80L5GWGIuiMyR/1ncS7uOIetAImfbS4ATE1/h2GBafk0qpk+vjFtPIbtoh9BWDGICzUEU5jDA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -490,6 +528,9 @@ packages:
|
|||||||
react: ^18.0.0 || ^19.0.0
|
react: ^18.0.0 || ^19.0.0
|
||||||
react-dom: ^18.0.0 || ^19.0.0
|
react-dom: ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
'@scarf/scarf@1.4.0':
|
||||||
|
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0':
|
'@standard-schema/spec@1.1.0':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
@ -572,6 +613,9 @@ packages:
|
|||||||
'@types/supertest@6.0.3':
|
'@types/supertest@6.0.3':
|
||||||
resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==}
|
resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==}
|
||||||
|
|
||||||
|
'@types/validator@13.15.10':
|
||||||
|
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
|
||||||
|
|
||||||
'@webassemblyjs/ast@1.14.1':
|
'@webassemblyjs/ast@1.14.1':
|
||||||
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
|
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
|
||||||
|
|
||||||
@ -814,6 +858,12 @@ packages:
|
|||||||
citty@0.2.1:
|
citty@0.2.1:
|
||||||
resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==}
|
resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==}
|
||||||
|
|
||||||
|
class-transformer@0.5.1:
|
||||||
|
resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==}
|
||||||
|
|
||||||
|
class-validator@0.15.1:
|
||||||
|
resolution: {integrity: sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==}
|
||||||
|
|
||||||
cli-cursor@3.1.0:
|
cli-cursor@3.1.0:
|
||||||
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
|
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -963,10 +1013,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==}
|
resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==}
|
||||||
engines: {node: '>=0.3.1'}
|
engines: {node: '>=0.3.1'}
|
||||||
|
|
||||||
|
dotenv-expand@12.0.3:
|
||||||
|
resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
dotenv@16.6.1:
|
dotenv@16.6.1:
|
||||||
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
dotenv@17.2.3:
|
||||||
|
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@ -1279,6 +1337,9 @@ packages:
|
|||||||
jsonfile@6.2.0:
|
jsonfile@6.2.0:
|
||||||
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
||||||
|
|
||||||
|
libphonenumber-js@1.12.39:
|
||||||
|
resolution: {integrity: sha512-MW79m7HuOqBk8mwytiXYTMELJiBbV3Zl9Y39dCCn1yC8K+WGNSq1QGvzywbylp5vGShEztMScCWHX/XFOS0rXg==}
|
||||||
|
|
||||||
lilconfig@2.1.0:
|
lilconfig@2.1.0:
|
||||||
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
|
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@ -1830,6 +1891,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
|
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
swagger-ui-dist@5.31.0:
|
||||||
|
resolution: {integrity: sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==}
|
||||||
|
|
||||||
symbol-observable@4.0.0:
|
symbol-observable@4.0.0:
|
||||||
resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==}
|
resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==}
|
||||||
engines: {node: '>=0.10'}
|
engines: {node: '>=0.10'}
|
||||||
@ -1965,6 +2029,10 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
validator@13.15.26:
|
||||||
|
resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==}
|
||||||
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
vary@1.1.2:
|
vary@1.1.2:
|
||||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@ -2293,6 +2361,8 @@ snapshots:
|
|||||||
|
|
||||||
'@lukeed/csprng@1.1.0': {}
|
'@lukeed/csprng@1.1.0': {}
|
||||||
|
|
||||||
|
'@microsoft/tsdoc@0.16.0': {}
|
||||||
|
|
||||||
'@mrleebo/prisma-ast@0.13.1':
|
'@mrleebo/prisma-ast@0.13.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
chevrotain: 10.5.0
|
chevrotain: 10.5.0
|
||||||
@ -2324,7 +2394,7 @@ snapshots:
|
|||||||
- uglify-js
|
- uglify-js
|
||||||
- webpack-cli
|
- webpack-cli
|
||||||
|
|
||||||
'@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
'@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
file-type: 21.3.0
|
file-type: 21.3.0
|
||||||
iterare: 1.2.1
|
iterare: 1.2.1
|
||||||
@ -2333,12 +2403,23 @@ snapshots:
|
|||||||
rxjs: 7.8.2
|
rxjs: 7.8.2
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
uid: 2.0.2
|
uid: 2.0.2
|
||||||
|
optionalDependencies:
|
||||||
|
class-transformer: 0.5.1
|
||||||
|
class-validator: 0.15.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@nestjs/core@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
'@nestjs/config@4.0.3(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
dotenv: 17.2.3
|
||||||
|
dotenv-expand: 12.0.3
|
||||||
|
lodash: 4.17.23
|
||||||
|
rxjs: 7.8.2
|
||||||
|
|
||||||
|
'@nestjs/core@11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||||
|
dependencies:
|
||||||
|
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
'@nuxt/opencollective': 0.4.1
|
'@nuxt/opencollective': 0.4.1
|
||||||
fast-safe-stringify: 2.1.1
|
fast-safe-stringify: 2.1.1
|
||||||
iterare: 1.2.1
|
iterare: 1.2.1
|
||||||
@ -2348,17 +2429,20 @@ snapshots:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
uid: 2.0.2
|
uid: 2.0.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
|
'@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
|
||||||
|
|
||||||
'@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)':
|
'@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
reflect-metadata: 0.2.2
|
reflect-metadata: 0.2.2
|
||||||
|
optionalDependencies:
|
||||||
|
class-transformer: 0.5.1
|
||||||
|
class-validator: 0.15.1
|
||||||
|
|
||||||
'@nestjs/platform-express@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)':
|
'@nestjs/platform-express@11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
cors: 2.8.6
|
cors: 2.8.6
|
||||||
express: 5.2.1
|
express: 5.2.1
|
||||||
multer: 2.1.1
|
multer: 2.1.1
|
||||||
@ -2378,13 +2462,28 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- chokidar
|
- chokidar
|
||||||
|
|
||||||
'@nestjs/testing@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)':
|
'@nestjs/swagger@11.2.6(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@microsoft/tsdoc': 0.16.0
|
||||||
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)
|
||||||
|
js-yaml: 4.1.1
|
||||||
|
lodash: 4.17.23
|
||||||
|
path-to-regexp: 8.3.0
|
||||||
|
reflect-metadata: 0.2.2
|
||||||
|
swagger-ui-dist: 5.31.0
|
||||||
|
optionalDependencies:
|
||||||
|
class-transformer: 0.5.1
|
||||||
|
class-validator: 0.15.1
|
||||||
|
|
||||||
|
'@nestjs/testing@11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)':
|
||||||
|
dependencies:
|
||||||
|
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
|
'@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
|
||||||
|
|
||||||
'@noble/hashes@1.8.0': {}
|
'@noble/hashes@1.8.0': {}
|
||||||
|
|
||||||
@ -2486,6 +2585,8 @@ snapshots:
|
|||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
|
||||||
|
'@scarf/scarf@1.4.0': {}
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
'@tokenizer/inflate@0.4.1':
|
'@tokenizer/inflate@0.4.1':
|
||||||
@ -2586,6 +2687,8 @@ snapshots:
|
|||||||
'@types/methods': 1.1.4
|
'@types/methods': 1.1.4
|
||||||
'@types/superagent': 8.1.9
|
'@types/superagent': 8.1.9
|
||||||
|
|
||||||
|
'@types/validator@13.15.10': {}
|
||||||
|
|
||||||
'@webassemblyjs/ast@1.14.1':
|
'@webassemblyjs/ast@1.14.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@webassemblyjs/helper-numbers': 1.13.2
|
'@webassemblyjs/helper-numbers': 1.13.2
|
||||||
@ -2862,6 +2965,14 @@ snapshots:
|
|||||||
|
|
||||||
citty@0.2.1: {}
|
citty@0.2.1: {}
|
||||||
|
|
||||||
|
class-transformer@0.5.1: {}
|
||||||
|
|
||||||
|
class-validator@0.15.1:
|
||||||
|
dependencies:
|
||||||
|
'@types/validator': 13.15.10
|
||||||
|
libphonenumber-js: 1.12.39
|
||||||
|
validator: 13.15.26
|
||||||
|
|
||||||
cli-cursor@3.1.0:
|
cli-cursor@3.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
restore-cursor: 3.1.0
|
restore-cursor: 3.1.0
|
||||||
@ -2978,8 +3089,14 @@ snapshots:
|
|||||||
|
|
||||||
diff@4.0.4: {}
|
diff@4.0.4: {}
|
||||||
|
|
||||||
|
dotenv-expand@12.0.3:
|
||||||
|
dependencies:
|
||||||
|
dotenv: 16.6.1
|
||||||
|
|
||||||
dotenv@16.6.1: {}
|
dotenv@16.6.1: {}
|
||||||
|
|
||||||
|
dotenv@17.2.3: {}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind-apply-helpers: 1.0.2
|
call-bind-apply-helpers: 1.0.2
|
||||||
@ -3309,6 +3426,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
|
|
||||||
|
libphonenumber-js@1.12.39: {}
|
||||||
|
|
||||||
lilconfig@2.1.0: {}
|
lilconfig@2.1.0: {}
|
||||||
|
|
||||||
lines-and-columns@1.2.4: {}
|
lines-and-columns@1.2.4: {}
|
||||||
@ -3839,6 +3958,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
has-flag: 4.0.0
|
has-flag: 4.0.0
|
||||||
|
|
||||||
|
swagger-ui-dist@5.31.0:
|
||||||
|
dependencies:
|
||||||
|
'@scarf/scarf': 1.4.0
|
||||||
|
|
||||||
symbol-observable@4.0.0: {}
|
symbol-observable@4.0.0: {}
|
||||||
|
|
||||||
tapable@2.3.0: {}
|
tapable@2.3.0: {}
|
||||||
@ -3960,6 +4083,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
validator@13.15.26: {}
|
||||||
|
|
||||||
vary@1.1.2: {}
|
vary@1.1.2: {}
|
||||||
|
|
||||||
watchpack@2.5.1:
|
watchpack@2.5.1:
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export default defineConfig({
|
|||||||
schema: "prisma/schema.prisma",
|
schema: "prisma/schema.prisma",
|
||||||
migrations: {
|
migrations: {
|
||||||
path: "prisma/migrations",
|
path: "prisma/migrations",
|
||||||
|
seed: "node --env-file=.env --loader ts-node/esm prisma/seed.ts",
|
||||||
},
|
},
|
||||||
datasource: {
|
datasource: {
|
||||||
url: process.env["DATABASE_URL"],
|
url: process.env["DATABASE_URL"],
|
||||||
|
|||||||
@ -1,25 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "User" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"email" TEXT NOT NULL,
|
|
||||||
"name" TEXT,
|
|
||||||
|
|
||||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "Post" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"title" TEXT NOT NULL,
|
|
||||||
"content" TEXT,
|
|
||||||
"published" BOOLEAN DEFAULT false,
|
|
||||||
"authorId" INTEGER,
|
|
||||||
|
|
||||||
CONSTRAINT "Post_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "Post" ADD CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
151
prisma/migrations/20260312092840/migration.sql
Normal file
151
prisma/migrations/20260312092840/migration.sql
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "UserRole" AS ENUM ('SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR', 'TEAM_LEAD', 'DOCTOR', 'ENGINEER');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Hospital" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Hospital_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Department" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"hospitalId" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Department_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MedicalGroup" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"departmentId" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "MedicalGroup_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"role" "UserRole" NOT NULL DEFAULT 'DOCTOR',
|
||||||
|
"hospitalId" INTEGER,
|
||||||
|
"departmentId" INTEGER,
|
||||||
|
"medicalGroupId" INTEGER,
|
||||||
|
"managerId" INTEGER,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Patient" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"hospitalId" INTEGER NOT NULL,
|
||||||
|
"departmentId" INTEGER,
|
||||||
|
"medicalGroupId" INTEGER,
|
||||||
|
"doctorId" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Patient_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "EngineerHospitalAssignment" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"hospitalId" INTEGER NOT NULL,
|
||||||
|
"engineerId" INTEGER NOT NULL,
|
||||||
|
"assignedById" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "EngineerHospitalAssignment_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Hospital_code_key" ON "Hospital"("code");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Department_hospitalId_name_key" ON "Department"("hospitalId", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "MedicalGroup_departmentId_name_key" ON "MedicalGroup"("departmentId", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "User_role_idx" ON "User"("role");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "User_hospitalId_idx" ON "User"("hospitalId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "User_managerId_idx" ON "User"("managerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Patient_doctorId_idx" ON "Patient"("doctorId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Patient_hospitalId_idx" ON "Patient"("hospitalId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "EngineerHospitalAssignment_engineerId_idx" ON "EngineerHospitalAssignment"("engineerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "EngineerHospitalAssignment_assignedById_idx" ON "EngineerHospitalAssignment"("assignedById");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "EngineerHospitalAssignment_hospitalId_engineerId_key" ON "EngineerHospitalAssignment"("hospitalId", "engineerId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Department" ADD CONSTRAINT "Department_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MedicalGroup" ADD CONSTRAINT "MedicalGroup_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "User" ADD CONSTRAINT "User_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "User" ADD CONSTRAINT "User_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "User" ADD CONSTRAINT "User_medicalGroupId_fkey" FOREIGN KEY ("medicalGroupId") REFERENCES "MedicalGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "User" ADD CONSTRAINT "User_managerId_fkey" FOREIGN KEY ("managerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Patient" ADD CONSTRAINT "Patient_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Patient" ADD CONSTRAINT "Patient_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Patient" ADD CONSTRAINT "Patient_medicalGroupId_fkey" FOREIGN KEY ("medicalGroupId") REFERENCES "MedicalGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Patient" ADD CONSTRAINT "Patient_doctorId_fkey" FOREIGN KEY ("doctorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "EngineerHospitalAssignment" ADD CONSTRAINT "EngineerHospitalAssignment_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "EngineerHospitalAssignment" ADD CONSTRAINT "EngineerHospitalAssignment_engineerId_fkey" FOREIGN KEY ("engineerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "EngineerHospitalAssignment" ADD CONSTRAINT "EngineerHospitalAssignment_assignedById_fkey" FOREIGN KEY ("assignedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
34
prisma/migrations/20260312095251/migration.sql
Normal file
34
prisma/migrations/20260312095251/migration.sql
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `email` on the `User` table. All the data in the column will be lost.
|
||||||
|
- A unique constraint covering the columns `[phone]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- A unique constraint covering the columns `[wechatMiniOpenId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- A unique constraint covering the columns `[wechatOfficialOpenId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- Added the required column `passwordHash` to the `User` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `phone` to the `User` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "User_email_key";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" DROP COLUMN "email",
|
||||||
|
ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN "lastLoginAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "passwordHash" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "phone" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "wechatMiniOpenId" TEXT,
|
||||||
|
ADD COLUMN "wechatOfficialOpenId" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_wechatMiniOpenId_key" ON "User"("wechatMiniOpenId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_wechatOfficialOpenId_key" ON "User"("wechatOfficialOpenId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "User_phone_isActive_idx" ON "User"("phone", "isActive");
|
||||||
@ -13,18 +13,207 @@ datasource db {
|
|||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
/// 统一角色枚举:
|
||||||
id Int @id @default(autoincrement())
|
/// - SYSTEM_ADMIN: 平台管理员
|
||||||
email String @unique
|
/// - HOSPITAL_ADMIN: 医院管理员
|
||||||
name String?
|
/// - DIRECTOR: 科室主任
|
||||||
posts Post[]
|
/// - TEAM_LEAD: 小组组长
|
||||||
|
/// - DOCTOR: 医生
|
||||||
|
/// - ENGINEER: 工程师
|
||||||
|
enum UserRole {
|
||||||
|
SYSTEM_ADMIN
|
||||||
|
HOSPITAL_ADMIN
|
||||||
|
DIRECTOR
|
||||||
|
TEAM_LEAD
|
||||||
|
DOCTOR
|
||||||
|
ENGINEER
|
||||||
}
|
}
|
||||||
|
|
||||||
model Post {
|
/// 医院实体:多医院租户的顶层边界。
|
||||||
id Int @id @default(autoincrement())
|
model Hospital {
|
||||||
title String
|
/// 主键 ID。
|
||||||
content String?
|
id Int @id @default(autoincrement())
|
||||||
published Boolean? @default(false)
|
/// 医院名称。
|
||||||
author User? @relation(fields: [authorId], references: [id])
|
name String
|
||||||
authorId Int?
|
/// 医院编码(唯一)。
|
||||||
|
code String @unique
|
||||||
|
/// 下属科室列表。
|
||||||
|
departments Department[]
|
||||||
|
/// 归属该医院的用户。
|
||||||
|
users User[]
|
||||||
|
/// 归属该医院的患者。
|
||||||
|
patients Patient[]
|
||||||
|
/// 该医院被分配的工程师任务关系。
|
||||||
|
engineerAssignments EngineerHospitalAssignment[]
|
||||||
|
/// 创建时间。
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
/// 更新时间。
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 科室实体:归属于某个医院。
|
||||||
|
model Department {
|
||||||
|
/// 主键 ID。
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
/// 科室名称。
|
||||||
|
name String
|
||||||
|
/// 所属医院 ID。
|
||||||
|
hospitalId Int
|
||||||
|
/// 医院外键关系。
|
||||||
|
hospital Hospital @relation(fields: [hospitalId], references: [id], onDelete: Cascade)
|
||||||
|
/// 下属小组。
|
||||||
|
medicalGroups MedicalGroup[]
|
||||||
|
/// 科室下用户。
|
||||||
|
users User[]
|
||||||
|
/// 科室下患者。
|
||||||
|
patients Patient[]
|
||||||
|
/// 创建时间。
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
/// 更新时间。
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
|
/// 同一家医院下科室名称唯一。
|
||||||
|
@@unique([hospitalId, name])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 医疗小组实体:归属于某个科室。
|
||||||
|
model MedicalGroup {
|
||||||
|
/// 主键 ID。
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
/// 小组名称。
|
||||||
|
name String
|
||||||
|
/// 所属科室 ID。
|
||||||
|
departmentId Int
|
||||||
|
/// 科室外键关系。
|
||||||
|
department Department @relation(fields: [departmentId], references: [id], onDelete: Cascade)
|
||||||
|
/// 小组用户。
|
||||||
|
users User[]
|
||||||
|
/// 小组患者。
|
||||||
|
patients Patient[]
|
||||||
|
/// 创建时间。
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
/// 更新时间。
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
|
/// 同一科室下小组名称唯一。
|
||||||
|
@@unique([departmentId, name])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户实体:统一承载组织关系、登录凭证与上下级结构。
|
||||||
|
model User {
|
||||||
|
/// 主键 ID。
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
/// 手机号(唯一登录名)。
|
||||||
|
phone String @unique
|
||||||
|
/// 密码哈希(禁止存明文)。
|
||||||
|
passwordHash String
|
||||||
|
/// 用户姓名。
|
||||||
|
name String?
|
||||||
|
/// 用户角色。
|
||||||
|
role UserRole @default(DOCTOR)
|
||||||
|
/// 归属医院 ID(可空,支持平台角色)。
|
||||||
|
hospitalId Int?
|
||||||
|
/// 归属科室 ID(可空)。
|
||||||
|
departmentId Int?
|
||||||
|
/// 归属小组 ID(可空)。
|
||||||
|
medicalGroupId Int?
|
||||||
|
/// 上级用户 ID(自关联层级)。
|
||||||
|
managerId Int?
|
||||||
|
/// 小程序 openId(可空,唯一)。
|
||||||
|
wechatMiniOpenId String? @unique
|
||||||
|
/// 服务号 openId(可空,唯一)。
|
||||||
|
wechatOfficialOpenId String? @unique
|
||||||
|
/// 账号是否启用。
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
/// 最近登录时间。
|
||||||
|
lastLoginAt DateTime?
|
||||||
|
/// 医院关系。
|
||||||
|
hospital Hospital? @relation(fields: [hospitalId], references: [id], onDelete: SetNull)
|
||||||
|
/// 科室关系。
|
||||||
|
department Department? @relation(fields: [departmentId], references: [id], onDelete: SetNull)
|
||||||
|
/// 小组关系。
|
||||||
|
medicalGroup MedicalGroup? @relation(fields: [medicalGroupId], references: [id], onDelete: SetNull)
|
||||||
|
/// 上级关系。
|
||||||
|
manager User? @relation("UserHierarchy", fields: [managerId], references: [id], onDelete: SetNull)
|
||||||
|
/// 下级关系。
|
||||||
|
subordinates User[] @relation("UserHierarchy")
|
||||||
|
/// 医生持有患者关系。
|
||||||
|
patients Patient[] @relation("DoctorPatients")
|
||||||
|
/// 工程师被分配医院关系。
|
||||||
|
engineerAssignments EngineerHospitalAssignment[] @relation("EngineerAssignments")
|
||||||
|
/// 系统管理员分配记录关系。
|
||||||
|
assignedEngineerHospitals EngineerHospitalAssignment[] @relation("SystemAdminAssignments")
|
||||||
|
/// 创建时间。
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
/// 更新时间。
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
|
/// 角色索引,便于权限查询。
|
||||||
|
@@index([role])
|
||||||
|
/// 医院索引,便于分院查询。
|
||||||
|
@@index([hospitalId])
|
||||||
|
/// 上级索引,便于层级查询。
|
||||||
|
@@index([managerId])
|
||||||
|
/// 手机号 + 启用状态联合索引,便于登录场景查询。
|
||||||
|
@@index([phone, isActive])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 患者实体:医生直接持有患者,上级通过层级可见性获取。
|
||||||
|
model Patient {
|
||||||
|
/// 主键 ID。
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
/// 患者姓名。
|
||||||
|
name String
|
||||||
|
/// 所属医院 ID。
|
||||||
|
hospitalId Int
|
||||||
|
/// 所属科室 ID(可空)。
|
||||||
|
departmentId Int?
|
||||||
|
/// 所属小组 ID(可空)。
|
||||||
|
medicalGroupId Int?
|
||||||
|
/// 负责医生 ID。
|
||||||
|
doctorId Int
|
||||||
|
/// 医院关系。
|
||||||
|
hospital Hospital @relation(fields: [hospitalId], references: [id], onDelete: Restrict)
|
||||||
|
/// 科室关系。
|
||||||
|
department Department? @relation(fields: [departmentId], references: [id], onDelete: SetNull)
|
||||||
|
/// 小组关系。
|
||||||
|
medicalGroup MedicalGroup? @relation(fields: [medicalGroupId], references: [id], onDelete: SetNull)
|
||||||
|
/// 负责医生关系。
|
||||||
|
doctor User @relation("DoctorPatients", fields: [doctorId], references: [id], onDelete: Restrict)
|
||||||
|
/// 创建时间。
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
/// 更新时间。
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
|
/// 医生索引,便于查“医生名下患者”。
|
||||||
|
@@index([doctorId])
|
||||||
|
/// 医院索引,便于查“全院患者”。
|
||||||
|
@@index([hospitalId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 工程师任务分配关系:只有系统管理员可以分配工程师到医院。
|
||||||
|
model EngineerHospitalAssignment {
|
||||||
|
/// 主键 ID。
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
/// 医院 ID。
|
||||||
|
hospitalId Int
|
||||||
|
/// 工程师用户 ID。
|
||||||
|
engineerId Int
|
||||||
|
/// 分配人(系统管理员)用户 ID。
|
||||||
|
assignedById Int
|
||||||
|
/// 医院关系。
|
||||||
|
hospital Hospital @relation(fields: [hospitalId], references: [id], onDelete: Cascade)
|
||||||
|
/// 工程师关系。
|
||||||
|
engineer User @relation("EngineerAssignments", fields: [engineerId], references: [id], onDelete: Restrict)
|
||||||
|
/// 分配人关系。
|
||||||
|
assignedBy User @relation("SystemAdminAssignments", fields: [assignedById], references: [id], onDelete: Restrict)
|
||||||
|
/// 创建时间。
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
/// 同一医院与工程师不能重复分配。
|
||||||
|
@@unique([hospitalId, engineerId])
|
||||||
|
/// 工程师索引。
|
||||||
|
@@index([engineerId])
|
||||||
|
/// 分配人索引。
|
||||||
|
@@index([assignedById])
|
||||||
}
|
}
|
||||||
|
|||||||
301
prisma/seed.ts
Normal file
301
prisma/seed.ts
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import { randomBytes, scrypt } from 'node:crypto';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { PrismaClient } from '../src/generated/prisma/client.js';
|
||||||
|
import { UserRole } from '../src/generated/prisma/enums.js';
|
||||||
|
|
||||||
|
const scryptAsync = promisify(scrypt);
|
||||||
|
|
||||||
|
const TEST_PASSWORD = 'Test123456';
|
||||||
|
const HOSPITAL_CODE = 'DEMO_HOSP_001';
|
||||||
|
const HOSPITAL_NAME = 'Demo Hospital';
|
||||||
|
const DEPARTMENT_NAME = 'Cardiology';
|
||||||
|
const MEDICAL_GROUP_NAME = 'Group A';
|
||||||
|
|
||||||
|
interface SeedUserInput {
|
||||||
|
phone: string;
|
||||||
|
name: string;
|
||||||
|
role: UserRole;
|
||||||
|
hospitalId: number | null;
|
||||||
|
departmentId: number | null;
|
||||||
|
medicalGroupId: number | null;
|
||||||
|
managerId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PatientInput {
|
||||||
|
name: string;
|
||||||
|
hospitalId: number;
|
||||||
|
departmentId: number | null;
|
||||||
|
medicalGroupId: number | null;
|
||||||
|
doctorId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireDatabaseUrl(): string {
|
||||||
|
const url = process.env.DATABASE_URL;
|
||||||
|
if (!url) {
|
||||||
|
throw new Error('DATABASE_URL is required. Run with `node --env-file=.env ...`.');
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hashPassword(password: string): Promise<string> {
|
||||||
|
const salt = randomBytes(16).toString('hex');
|
||||||
|
const derivedKey = (await scryptAsync(password, salt, 64)) as Buffer;
|
||||||
|
return `${salt}:${derivedKey.toString('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertUser(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
user: SeedUserInput,
|
||||||
|
passwordHash: string,
|
||||||
|
) {
|
||||||
|
return prisma.user.upsert({
|
||||||
|
where: { phone: user.phone },
|
||||||
|
create: {
|
||||||
|
phone: user.phone,
|
||||||
|
passwordHash,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
hospitalId: user.hospitalId,
|
||||||
|
departmentId: user.departmentId,
|
||||||
|
medicalGroupId: user.medicalGroupId,
|
||||||
|
managerId: user.managerId,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
passwordHash,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
hospitalId: user.hospitalId,
|
||||||
|
departmentId: user.departmentId,
|
||||||
|
medicalGroupId: user.medicalGroupId,
|
||||||
|
managerId: user.managerId,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
role: true,
|
||||||
|
phone: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertPatientByNaturalKey(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
patient: PatientInput,
|
||||||
|
) {
|
||||||
|
const existing = await prisma.patient.findFirst({
|
||||||
|
where: {
|
||||||
|
name: patient.name,
|
||||||
|
hospitalId: patient.hospitalId,
|
||||||
|
doctorId: patient.doctorId,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return prisma.patient.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
departmentId: patient.departmentId,
|
||||||
|
medicalGroupId: patient.medicalGroupId,
|
||||||
|
},
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.patient.create({
|
||||||
|
data: patient,
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const adapter = new PrismaPg({ connectionString: requireDatabaseUrl() });
|
||||||
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const passwordHash = await hashPassword(TEST_PASSWORD);
|
||||||
|
|
||||||
|
const hospital = await prisma.hospital.upsert({
|
||||||
|
where: { code: HOSPITAL_CODE },
|
||||||
|
create: {
|
||||||
|
code: HOSPITAL_CODE,
|
||||||
|
name: HOSPITAL_NAME,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
name: HOSPITAL_NAME,
|
||||||
|
},
|
||||||
|
select: { id: true, name: true, code: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const department = await prisma.department.upsert({
|
||||||
|
where: {
|
||||||
|
hospitalId_name: {
|
||||||
|
hospitalId: hospital.id,
|
||||||
|
name: DEPARTMENT_NAME,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
name: DEPARTMENT_NAME,
|
||||||
|
hospitalId: hospital.id,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const medicalGroup = await prisma.medicalGroup.upsert({
|
||||||
|
where: {
|
||||||
|
departmentId_name: {
|
||||||
|
departmentId: department.id,
|
||||||
|
name: MEDICAL_GROUP_NAME,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
name: MEDICAL_GROUP_NAME,
|
||||||
|
departmentId: department.id,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const systemAdmin = await upsertUser(
|
||||||
|
prisma,
|
||||||
|
{
|
||||||
|
phone: '+8613800000001',
|
||||||
|
name: 'System Admin',
|
||||||
|
role: UserRole.SYSTEM_ADMIN,
|
||||||
|
hospitalId: null,
|
||||||
|
departmentId: null,
|
||||||
|
medicalGroupId: null,
|
||||||
|
managerId: null,
|
||||||
|
},
|
||||||
|
passwordHash,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hospitalAdmin = await upsertUser(
|
||||||
|
prisma,
|
||||||
|
{
|
||||||
|
phone: '+8613800000002',
|
||||||
|
name: 'Hospital Admin',
|
||||||
|
role: UserRole.HOSPITAL_ADMIN,
|
||||||
|
hospitalId: hospital.id,
|
||||||
|
departmentId: null,
|
||||||
|
medicalGroupId: null,
|
||||||
|
managerId: null,
|
||||||
|
},
|
||||||
|
passwordHash,
|
||||||
|
);
|
||||||
|
|
||||||
|
const director = await upsertUser(
|
||||||
|
prisma,
|
||||||
|
{
|
||||||
|
phone: '+8613800000003',
|
||||||
|
name: 'Director',
|
||||||
|
role: UserRole.DIRECTOR,
|
||||||
|
hospitalId: hospital.id,
|
||||||
|
departmentId: department.id,
|
||||||
|
medicalGroupId: null,
|
||||||
|
managerId: hospitalAdmin.id,
|
||||||
|
},
|
||||||
|
passwordHash,
|
||||||
|
);
|
||||||
|
|
||||||
|
const teamLead = await upsertUser(
|
||||||
|
prisma,
|
||||||
|
{
|
||||||
|
phone: '+8613800000004',
|
||||||
|
name: 'Team Lead',
|
||||||
|
role: UserRole.TEAM_LEAD,
|
||||||
|
hospitalId: hospital.id,
|
||||||
|
departmentId: department.id,
|
||||||
|
medicalGroupId: medicalGroup.id,
|
||||||
|
managerId: director.id,
|
||||||
|
},
|
||||||
|
passwordHash,
|
||||||
|
);
|
||||||
|
|
||||||
|
const doctor = await upsertUser(
|
||||||
|
prisma,
|
||||||
|
{
|
||||||
|
phone: '+8613800000005',
|
||||||
|
name: 'Doctor',
|
||||||
|
role: UserRole.DOCTOR,
|
||||||
|
hospitalId: hospital.id,
|
||||||
|
departmentId: department.id,
|
||||||
|
medicalGroupId: medicalGroup.id,
|
||||||
|
managerId: teamLead.id,
|
||||||
|
},
|
||||||
|
passwordHash,
|
||||||
|
);
|
||||||
|
|
||||||
|
const engineer = await upsertUser(
|
||||||
|
prisma,
|
||||||
|
{
|
||||||
|
phone: '+8613800000006',
|
||||||
|
name: 'Engineer',
|
||||||
|
role: UserRole.ENGINEER,
|
||||||
|
hospitalId: hospital.id,
|
||||||
|
departmentId: null,
|
||||||
|
medicalGroupId: null,
|
||||||
|
managerId: hospitalAdmin.id,
|
||||||
|
},
|
||||||
|
passwordHash,
|
||||||
|
);
|
||||||
|
|
||||||
|
await upsertPatientByNaturalKey(prisma, {
|
||||||
|
name: 'Patient Alpha',
|
||||||
|
hospitalId: hospital.id,
|
||||||
|
departmentId: department.id,
|
||||||
|
medicalGroupId: medicalGroup.id,
|
||||||
|
doctorId: doctor.id,
|
||||||
|
});
|
||||||
|
await upsertPatientByNaturalKey(prisma, {
|
||||||
|
name: 'Patient Beta',
|
||||||
|
hospitalId: hospital.id,
|
||||||
|
departmentId: department.id,
|
||||||
|
medicalGroupId: medicalGroup.id,
|
||||||
|
doctorId: doctor.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.engineerHospitalAssignment.upsert({
|
||||||
|
where: {
|
||||||
|
hospitalId_engineerId: {
|
||||||
|
hospitalId: hospital.id,
|
||||||
|
engineerId: engineer.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
hospitalId: hospital.id,
|
||||||
|
engineerId: engineer.id,
|
||||||
|
assignedById: systemAdmin.id,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
assignedById: systemAdmin.id,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Seed completed for hospital ${hospital.code} (${hospital.name}).`);
|
||||||
|
console.log('Test password for all seeded users:', TEST_PASSWORD);
|
||||||
|
console.table(
|
||||||
|
[systemAdmin, hospitalAdmin, director, teamLead, doctor, engineer].map(
|
||||||
|
(user) => ({
|
||||||
|
role: user.role,
|
||||||
|
phone: user.phone,
|
||||||
|
name: user.name ?? '',
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log('No mini-program or official-account openId was pre-seeded.');
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error('Seed failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@ -1,7 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { UsersModule } from './users/users.module';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { AuthModule } from './auth/auth.module.js';
|
||||||
|
import { UsersModule } from './users/users.module.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UsersModule],
|
// ConfigModule 先加载,保证鉴权和数据库都可读取环境变量。
|
||||||
|
imports: [ConfigModule.forRoot(), AuthModule, UsersModule],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
70
src/auth/auth.controller.ts
Normal file
70
src/auth/auth.controller.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { Body, Controller, Get, Post } from '@nestjs/common';
|
||||||
|
import { CurrentUser } from './decorators/current-user.decorator.js';
|
||||||
|
import { Public } from './decorators/public.decorator.js';
|
||||||
|
import { AuthService } from './auth.service.js';
|
||||||
|
import { BindWechatDto } from './dto/bind-wechat.dto.js';
|
||||||
|
import { ChangePasswordDto } from './dto/change-password.dto.js';
|
||||||
|
import { LoginMiniProgramDto } from './dto/login-mini-program.dto.js';
|
||||||
|
import { LoginOfficialAccountDto } from './dto/login-official-account.dto.js';
|
||||||
|
import { LoginPhoneDto } from './dto/login-phone.dto.js';
|
||||||
|
import { RegisterDto } from './dto/register.dto.js';
|
||||||
|
import type { AuthUser } from './types/auth-user.type.js';
|
||||||
|
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
// 公开注册接口:手机号 + 密码。
|
||||||
|
@Public()
|
||||||
|
@Post('register')
|
||||||
|
register(@Body() registerDto: RegisterDto) {
|
||||||
|
return this.authService.register(registerDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公开登录接口:手机号 + 密码。
|
||||||
|
@Public()
|
||||||
|
@Post('login/phone')
|
||||||
|
loginWithPhone(@Body() loginPhoneDto: LoginPhoneDto) {
|
||||||
|
return this.authService.loginWithPhone(loginPhoneDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公开登录接口:小程序 openId。
|
||||||
|
@Public()
|
||||||
|
@Post('login/mini-program')
|
||||||
|
loginWithMiniProgram(@Body() loginMiniProgramDto: LoginMiniProgramDto) {
|
||||||
|
return this.authService.loginWithMiniProgram(loginMiniProgramDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公开登录接口:服务号 openId。
|
||||||
|
@Public()
|
||||||
|
@Post('login/official-account')
|
||||||
|
loginWithOfficialAccount(
|
||||||
|
@Body() loginOfficialAccountDto: LoginOfficialAccountDto,
|
||||||
|
) {
|
||||||
|
return this.authService.loginWithOfficialAccount(loginOfficialAccountDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录后获取当前用户信息。
|
||||||
|
@Get('me')
|
||||||
|
me(@CurrentUser() currentUser: AuthUser) {
|
||||||
|
return this.authService.me(currentUser.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录后绑定小程序/服务号账号。
|
||||||
|
@Post('bind/wechat')
|
||||||
|
bindWechat(
|
||||||
|
@CurrentUser() currentUser: AuthUser,
|
||||||
|
@Body() bindWechatDto: BindWechatDto,
|
||||||
|
) {
|
||||||
|
return this.authService.bindWechat(currentUser.id, bindWechatDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录后修改密码。
|
||||||
|
@Post('change-password')
|
||||||
|
changePassword(
|
||||||
|
@CurrentUser() currentUser: AuthUser,
|
||||||
|
@Body() changePasswordDto: ChangePasswordDto,
|
||||||
|
) {
|
||||||
|
return this.authService.changePassword(currentUser.id, changePasswordDto);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/auth/auth.module.ts
Normal file
31
src/auth/auth.module.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
import { PrismaModule } from '../prisma.module.js';
|
||||||
|
import { AuthController } from './auth.controller.js';
|
||||||
|
import { AuthService } from './auth.service.js';
|
||||||
|
import { PasswordService } from './password.service.js';
|
||||||
|
import { TokenService } from './token.service.js';
|
||||||
|
import { AuthGuard } from './guards/auth.guard.js';
|
||||||
|
import { RolesGuard } from './guards/roles.guard.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
PasswordService,
|
||||||
|
TokenService,
|
||||||
|
// 全局鉴权:默认所有接口都需要登录,除非显式标记 @Public。
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: AuthGuard,
|
||||||
|
},
|
||||||
|
// 全局角色守卫:只有使用 @Roles 的接口才会进行角色判断。
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: RolesGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [PasswordService],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
290
src/auth/auth.service.ts
Normal file
290
src/auth/auth.service.ts
Normal file
@ -0,0 +1,290 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { UserRole } from '../generated/prisma/enums.js';
|
||||||
|
import { PrismaService } from '../prisma.service.js';
|
||||||
|
import { BindWechatDto } from './dto/bind-wechat.dto.js';
|
||||||
|
import { ChangePasswordDto } from './dto/change-password.dto.js';
|
||||||
|
import { LoginMiniProgramDto } from './dto/login-mini-program.dto.js';
|
||||||
|
import { LoginOfficialAccountDto } from './dto/login-official-account.dto.js';
|
||||||
|
import { LoginPhoneDto } from './dto/login-phone.dto.js';
|
||||||
|
import { RegisterDto } from './dto/register.dto.js';
|
||||||
|
import { PasswordService } from './password.service.js';
|
||||||
|
import { TokenService } from './token.service.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
// 查询用户时统一选择字段,避免在多处重复定义。
|
||||||
|
private readonly userSelect = {
|
||||||
|
id: true,
|
||||||
|
phone: true,
|
||||||
|
name: true,
|
||||||
|
role: true,
|
||||||
|
hospitalId: true,
|
||||||
|
departmentId: true,
|
||||||
|
medicalGroupId: true,
|
||||||
|
managerId: true,
|
||||||
|
wechatMiniOpenId: true,
|
||||||
|
wechatOfficialOpenId: true,
|
||||||
|
isActive: true,
|
||||||
|
lastLoginAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly passwordService: PasswordService,
|
||||||
|
private readonly tokenService: TokenService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async register(registerDto: RegisterDto) {
|
||||||
|
// 自助注册只允许创建普通医生角色,防止越权注册管理员。
|
||||||
|
const existingUser = await this.prisma.user.findUnique({
|
||||||
|
where: { phone: registerDto.phone },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existingUser) {
|
||||||
|
throw new ConflictException('手机号已注册');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await this.passwordService.hashPassword(registerDto.password);
|
||||||
|
const user = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
phone: registerDto.phone,
|
||||||
|
passwordHash,
|
||||||
|
name: registerDto.name,
|
||||||
|
role: UserRole.DOCTOR,
|
||||||
|
hospitalId: registerDto.hospitalId,
|
||||||
|
departmentId: registerDto.departmentId,
|
||||||
|
medicalGroupId: registerDto.medicalGroupId,
|
||||||
|
managerId: registerDto.managerId,
|
||||||
|
wechatMiniOpenId: registerDto.wechatMiniOpenId,
|
||||||
|
wechatOfficialOpenId: registerDto.wechatOfficialOpenId,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: this.userSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.issueLoginResult(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginWithPhone(loginPhoneDto: LoginPhoneDto) {
|
||||||
|
// 手机号登录需要读出密码哈希并做安全校验。
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { phone: loginPhoneDto.phone },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
passwordHash: true,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!user || !user.isActive) {
|
||||||
|
throw new UnauthorizedException('手机号或密码错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPasswordValid = await this.passwordService.verifyPassword(
|
||||||
|
loginPhoneDto.password,
|
||||||
|
user.passwordHash,
|
||||||
|
);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw new UnauthorizedException('手机号或密码错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.loginByUserId(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginWithMiniProgram(loginMiniProgramDto: LoginMiniProgramDto) {
|
||||||
|
// 小程序登录通过 miniOpenId 直连用户。
|
||||||
|
const user = await this.prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
wechatMiniOpenId: loginMiniProgramDto.miniOpenId,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('小程序账号未绑定');
|
||||||
|
}
|
||||||
|
return this.loginByUserId(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginWithOfficialAccount(loginOfficialAccountDto: LoginOfficialAccountDto) {
|
||||||
|
// 服务号登录通过 officialOpenId 直连用户。
|
||||||
|
const user = await this.prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
wechatOfficialOpenId: loginOfficialAccountDto.officialOpenId,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('服务号账号未绑定');
|
||||||
|
}
|
||||||
|
return this.loginByUserId(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async bindWechat(userId: number, bindWechatDto: BindWechatDto) {
|
||||||
|
// 绑定之前先做冲突检查,确保一个 openId 只归属一个用户。
|
||||||
|
if (bindWechatDto.miniOpenId) {
|
||||||
|
const existingMini = await this.prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
wechatMiniOpenId: bindWechatDto.miniOpenId,
|
||||||
|
NOT: { id: userId },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existingMini) {
|
||||||
|
throw new ConflictException('小程序账号已被其他用户绑定');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bindWechatDto.officialOpenId) {
|
||||||
|
const existingOfficial = await this.prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
wechatOfficialOpenId: bindWechatDto.officialOpenId,
|
||||||
|
NOT: { id: userId },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existingOfficial) {
|
||||||
|
throw new ConflictException('服务号账号已被其他用户绑定');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
wechatMiniOpenId: bindWechatDto.miniOpenId ?? undefined,
|
||||||
|
wechatOfficialOpenId: bindWechatDto.officialOpenId ?? undefined,
|
||||||
|
},
|
||||||
|
select: this.userSelect,
|
||||||
|
});
|
||||||
|
return this.toUserView(updatedUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
async changePassword(userId: number, changePasswordDto: ChangePasswordDto) {
|
||||||
|
// 改密必须验证旧密码,防止被盗登录态直接改密。
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
isActive: true,
|
||||||
|
passwordHash: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!user || !user.isActive) {
|
||||||
|
throw new NotFoundException('用户不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOldPasswordValid = await this.passwordService.verifyPassword(
|
||||||
|
changePasswordDto.oldPassword,
|
||||||
|
user.passwordHash,
|
||||||
|
);
|
||||||
|
if (!isOldPasswordValid) {
|
||||||
|
throw new UnauthorizedException('旧密码不正确');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPasswordHash = await this.passwordService.hashPassword(
|
||||||
|
changePasswordDto.newPassword,
|
||||||
|
);
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { passwordHash: newPasswordHash },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: '密码修改成功' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async me(userId: number) {
|
||||||
|
// 读取当前用户公开信息,不返回密码和 openId 明文。
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: this.userSelect,
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('用户不存在');
|
||||||
|
}
|
||||||
|
return this.toUserView(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loginByUserId(userId: number) {
|
||||||
|
// 登录成功后更新最后登录时间,便于安全审计。
|
||||||
|
const user = await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { lastLoginAt: new Date() },
|
||||||
|
select: this.userSelect,
|
||||||
|
});
|
||||||
|
return this.issueLoginResult(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private issueLoginResult(user: {
|
||||||
|
id: number;
|
||||||
|
role: UserRole;
|
||||||
|
hospitalId: number | null;
|
||||||
|
wechatMiniOpenId: string | null;
|
||||||
|
wechatOfficialOpenId: string | null;
|
||||||
|
phone: string;
|
||||||
|
name: string | null;
|
||||||
|
departmentId: number | null;
|
||||||
|
medicalGroupId: number | null;
|
||||||
|
managerId: number | null;
|
||||||
|
isActive: boolean;
|
||||||
|
lastLoginAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}) {
|
||||||
|
// token 中保留最小必要信息,其余数据走数据库校验。
|
||||||
|
const accessToken = this.tokenService.sign({
|
||||||
|
sub: user.id,
|
||||||
|
role: user.role,
|
||||||
|
hospitalId: user.hospitalId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
accessToken,
|
||||||
|
expiresIn: this.tokenService.expiresInSeconds,
|
||||||
|
user: this.toUserView(user),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toUserView(user: {
|
||||||
|
id: number;
|
||||||
|
phone: string;
|
||||||
|
name: string | null;
|
||||||
|
role: UserRole;
|
||||||
|
hospitalId: number | null;
|
||||||
|
departmentId: number | null;
|
||||||
|
medicalGroupId: number | null;
|
||||||
|
managerId: number | null;
|
||||||
|
wechatMiniOpenId: string | null;
|
||||||
|
wechatOfficialOpenId: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
lastLoginAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}) {
|
||||||
|
// 输出层做脱敏:不回传 openId 原文,只回传是否已绑定。
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
phone: user.phone,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
hospitalId: user.hospitalId,
|
||||||
|
departmentId: user.departmentId,
|
||||||
|
medicalGroupId: user.medicalGroupId,
|
||||||
|
managerId: user.managerId,
|
||||||
|
isActive: user.isActive,
|
||||||
|
lastLoginAt: user.lastLoginAt,
|
||||||
|
wechatMiniLinked: Boolean(user.wechatMiniOpenId),
|
||||||
|
wechatOfficialLinked: Boolean(user.wechatOfficialOpenId),
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
updatedAt: user.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/auth/constants.ts
Normal file
2
src/auth/constants.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
11
src/auth/decorators/current-user.decorator.ts
Normal file
11
src/auth/decorators/current-user.decorator.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import type { AuthUser } from '../types/auth-user.type.js';
|
||||||
|
|
||||||
|
export const CurrentUser = createParamDecorator(
|
||||||
|
(_data: unknown, ctx: ExecutionContext): AuthUser => {
|
||||||
|
const request = ctx
|
||||||
|
.switchToHttp()
|
||||||
|
.getRequest<{ user: AuthUser }>();
|
||||||
|
return request.user;
|
||||||
|
},
|
||||||
|
);
|
||||||
4
src/auth/decorators/public.decorator.ts
Normal file
4
src/auth/decorators/public.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { IS_PUBLIC_KEY } from '../constants.js';
|
||||||
|
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
5
src/auth/decorators/roles.decorator.ts
Normal file
5
src/auth/decorators/roles.decorator.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import type { UserRole } from '../../generated/prisma/enums.js';
|
||||||
|
import { ROLES_KEY } from '../constants.js';
|
||||||
|
|
||||||
|
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
19
src/auth/dto/bind-wechat.dto.ts
Normal file
19
src/auth/dto/bind-wechat.dto.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { IsOptional, IsString, MaxLength, MinLength, ValidateIf } from 'class-validator';
|
||||||
|
|
||||||
|
export class BindWechatDto {
|
||||||
|
// 两个字段至少传一个:如果未传 officialOpenId,则 miniOpenId 必填。
|
||||||
|
@ValidateIf((o: BindWechatDto) => !o.officialOpenId)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6)
|
||||||
|
@MaxLength(128)
|
||||||
|
@IsOptional()
|
||||||
|
miniOpenId?: string;
|
||||||
|
|
||||||
|
// 两个字段至少传一个:如果未传 miniOpenId,则 officialOpenId 必填。
|
||||||
|
@ValidateIf((o: BindWechatDto) => !o.miniOpenId)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6)
|
||||||
|
@MaxLength(128)
|
||||||
|
@IsOptional()
|
||||||
|
officialOpenId?: string;
|
||||||
|
}
|
||||||
24
src/auth/dto/change-password.dto.ts
Normal file
24
src/auth/dto/change-password.dto.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Matches } from 'class-validator';
|
||||||
|
|
||||||
|
export class ChangePasswordDto {
|
||||||
|
// 老密码用于确认操作者身份。
|
||||||
|
@Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, {
|
||||||
|
message: '密码至少8位,且包含字母和数字',
|
||||||
|
})
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Current password of the account.',
|
||||||
|
example: 'Test123456',
|
||||||
|
})
|
||||||
|
oldPassword: string;
|
||||||
|
|
||||||
|
// 新密码使用与注册一致的安全策略。
|
||||||
|
@Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, {
|
||||||
|
message: '密码至少8位,且包含字母和数字',
|
||||||
|
})
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'New password, 8-64 chars with letters and numbers.',
|
||||||
|
example: 'NewTest123456',
|
||||||
|
})
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
9
src/auth/dto/login-mini-program.dto.ts
Normal file
9
src/auth/dto/login-mini-program.dto.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class LoginMiniProgramDto {
|
||||||
|
// 小程序 openId 由前端/网关在登录态中传入。
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6)
|
||||||
|
@MaxLength(128)
|
||||||
|
miniOpenId: string;
|
||||||
|
}
|
||||||
9
src/auth/dto/login-official-account.dto.ts
Normal file
9
src/auth/dto/login-official-account.dto.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class LoginOfficialAccountDto {
|
||||||
|
// 服务号 openId 由前端/网关在登录态中传入。
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6)
|
||||||
|
@MaxLength(128)
|
||||||
|
officialOpenId: string;
|
||||||
|
}
|
||||||
22
src/auth/dto/login-phone.dto.ts
Normal file
22
src/auth/dto/login-phone.dto.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Matches } from 'class-validator';
|
||||||
|
|
||||||
|
export class LoginPhoneDto {
|
||||||
|
// 手机号登录入口字段。
|
||||||
|
@Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' })
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Login phone number in E.164 format.',
|
||||||
|
example: '+8613800138000',
|
||||||
|
})
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
// 登录密码,规则与注册保持一致。
|
||||||
|
@Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, {
|
||||||
|
message: '密码至少8位,且包含字母和数字',
|
||||||
|
})
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Password must be 8-64 chars and include letters and numbers.',
|
||||||
|
example: 'Test123456',
|
||||||
|
})
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
99
src/auth/dto/register.dto.ts
Normal file
99
src/auth/dto/register.dto.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class RegisterDto {
|
||||||
|
// 手机号是主登录标识。
|
||||||
|
@Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' })
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Phone number used as login account (E.164 format).',
|
||||||
|
example: '+8613800138000',
|
||||||
|
})
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
// 注册密码强度策略。
|
||||||
|
@Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, {
|
||||||
|
message: '密码至少8位,且包含字母和数字',
|
||||||
|
})
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Password must be 8-64 chars and include letters and numbers.',
|
||||||
|
example: 'Test123456',
|
||||||
|
})
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
// 个人展示名称。
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(64)
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Display name.',
|
||||||
|
example: 'Demo Doctor',
|
||||||
|
})
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
// 组织归属:医院。
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Hospital id to bind during registration.',
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
|
hospitalId?: number;
|
||||||
|
|
||||||
|
// 组织归属:科室。
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Department id to bind during registration.',
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
|
departmentId?: number;
|
||||||
|
|
||||||
|
// 组织归属:小组。
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Medical group id to bind during registration.',
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
|
medicalGroupId?: number;
|
||||||
|
|
||||||
|
// 直属上级用户 ID。
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Direct manager user id.',
|
||||||
|
example: 2,
|
||||||
|
})
|
||||||
|
managerId?: number;
|
||||||
|
|
||||||
|
// 可选:注册时直接绑定小程序账号。
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(128)
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Optional mini-program openId.',
|
||||||
|
example: 'mini_open_id_xxx',
|
||||||
|
})
|
||||||
|
wechatMiniOpenId?: string;
|
||||||
|
|
||||||
|
// 可选:注册时直接绑定服务号账号。
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(128)
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Optional official-account openId.',
|
||||||
|
example: 'official_open_id_xxx',
|
||||||
|
})
|
||||||
|
wechatOfficialOpenId?: string;
|
||||||
|
}
|
||||||
84
src/auth/guards/auth.guard.ts
Normal file
84
src/auth/guards/auth.guard.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { PrismaService } from '../../prisma.service.js';
|
||||||
|
import { IS_PUBLIC_KEY } from '../constants.js';
|
||||||
|
import { TokenService } from '../token.service.js';
|
||||||
|
import type { AuthUser } from '../types/auth-user.type.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly tokenService: TokenService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
// 被 @Public 标记的接口直接放行。
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (isPublic) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取 Authorization: Bearer <token>。
|
||||||
|
const request = context
|
||||||
|
.switchToHttp()
|
||||||
|
.getRequest<Request & { user?: AuthUser }>();
|
||||||
|
const token = this.extractBearerToken(request.headers.authorization);
|
||||||
|
if (!token) {
|
||||||
|
throw new UnauthorizedException('未登录');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验签成功后仍需到数据库核验账号状态。
|
||||||
|
const payload = this.tokenService.verify(token);
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: payload.sub },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
role: true,
|
||||||
|
hospitalId: true,
|
||||||
|
departmentId: true,
|
||||||
|
medicalGroupId: true,
|
||||||
|
managerId: true,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.isActive) {
|
||||||
|
throw new UnauthorizedException('账号不可用');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把当前用户挂载到 request,供后续 decorator/业务层使用。
|
||||||
|
request.user = {
|
||||||
|
id: user.id,
|
||||||
|
role: user.role,
|
||||||
|
hospitalId: user.hospitalId,
|
||||||
|
departmentId: user.departmentId,
|
||||||
|
medicalGroupId: user.medicalGroupId,
|
||||||
|
managerId: user.managerId,
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractBearerToken(header?: string): string | null {
|
||||||
|
// Header 不存在直接视为未登录。
|
||||||
|
if (!header) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [type, token] = header.split(' ');
|
||||||
|
if (type !== 'Bearer' || !token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/auth/guards/roles.guard.ts
Normal file
41
src/auth/guards/roles.guard.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import type { UserRole } from '../../generated/prisma/enums.js';
|
||||||
|
import { ROLES_KEY } from '../constants.js';
|
||||||
|
import type { AuthUser } from '../types/auth-user.type.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RolesGuard implements CanActivate {
|
||||||
|
constructor(private readonly reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
// 未声明 @Roles 的接口不做角色限制。
|
||||||
|
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
|
||||||
|
ROLES_KEY,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
if (!requiredRoles || requiredRoles.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 角色守卫依赖 AuthGuard 注入 request.user。
|
||||||
|
const request = context
|
||||||
|
.switchToHttp()
|
||||||
|
.getRequest<{ user?: AuthUser }>();
|
||||||
|
const user = request.user;
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('未登录');
|
||||||
|
}
|
||||||
|
// 当前角色不在白名单则拒绝访问。
|
||||||
|
if (!requiredRoles.includes(user.role)) {
|
||||||
|
throw new ForbiddenException('权限不足');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/auth/password.service.ts
Normal file
33
src/auth/password.service.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { randomBytes, scrypt, timingSafeEqual } from 'crypto';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PasswordService {
|
||||||
|
// 使用 Node.js 原生 scrypt,避免引入额外原生依赖。
|
||||||
|
private readonly scryptAsync = promisify(scrypt);
|
||||||
|
|
||||||
|
async hashPassword(password: string): Promise<string> {
|
||||||
|
// 每个密码生成独立盐值,抵抗彩虹表攻击。
|
||||||
|
const salt = randomBytes(16).toString('hex');
|
||||||
|
const derivedKey = (await this.scryptAsync(password, salt, 64)) as Buffer;
|
||||||
|
// 持久化格式:salt:hash。
|
||||||
|
return `${salt}:${derivedKey.toString('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyPassword(password: string, hashedPassword: string): Promise<boolean> {
|
||||||
|
// 从数据库格式中拆出盐值与哈希。
|
||||||
|
const [salt, keyHex] = hashedPassword.split(':');
|
||||||
|
if (!salt || !keyHex) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用同样参数重新推导哈希并做常量时间比较。
|
||||||
|
const derivedKey = (await this.scryptAsync(password, salt, 64)) as Buffer;
|
||||||
|
const storedKey = Buffer.from(keyHex, 'hex');
|
||||||
|
if (storedKey.length !== derivedKey.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return timingSafeEqual(storedKey, derivedKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/auth/token.service.ts
Normal file
108
src/auth/token.service.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { createHmac, timingSafeEqual } from 'crypto';
|
||||||
|
import type { UserRole } from '../generated/prisma/enums.js';
|
||||||
|
|
||||||
|
interface JwtHeader {
|
||||||
|
alg: 'HS256';
|
||||||
|
typ: 'JWT';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SignTokenInput {
|
||||||
|
sub: number;
|
||||||
|
role: UserRole;
|
||||||
|
hospitalId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenPayload extends SignTokenInput {
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TokenService {
|
||||||
|
// 建议在生产环境通过环境变量覆盖,且长度不少于 32 位。
|
||||||
|
private readonly secret =
|
||||||
|
process.env.JWT_SECRET ??
|
||||||
|
'local-dev-insecure-secret-change-me-please-1234567890';
|
||||||
|
// 默认 24 小时过期,可通过环境变量调节。
|
||||||
|
readonly expiresInSeconds = Number.parseInt(
|
||||||
|
process.env.JWT_EXPIRES_IN_SECONDS ?? '86400',
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// 启动时校验配置,避免线上运行才暴露错误。
|
||||||
|
if (this.secret.length < 32) {
|
||||||
|
throw new Error('JWT_SECRET 长度至少32位');
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(this.expiresInSeconds) || this.expiresInSeconds <= 0) {
|
||||||
|
throw new Error('JWT_EXPIRES_IN_SECONDS 必须是正整数');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sign(input: SignTokenInput): string {
|
||||||
|
// 生成 iat/exp,形成完整 payload。
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const payload: TokenPayload = {
|
||||||
|
...input,
|
||||||
|
iat: now,
|
||||||
|
exp: now + this.expiresInSeconds,
|
||||||
|
};
|
||||||
|
const header: JwtHeader = {
|
||||||
|
alg: 'HS256',
|
||||||
|
typ: 'JWT',
|
||||||
|
};
|
||||||
|
|
||||||
|
// HMAC-SHA256 签名,输出标准三段式 token。
|
||||||
|
const encodedHeader = this.encodeObject(header);
|
||||||
|
const encodedPayload = this.encodeObject(payload);
|
||||||
|
const unsignedToken = `${encodedHeader}.${encodedPayload}`;
|
||||||
|
const signature = this.signRaw(unsignedToken);
|
||||||
|
return `${unsignedToken}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(token: string): TokenPayload {
|
||||||
|
// 必须是 header.payload.signature 三段。
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
throw new UnauthorizedException('无效 token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新计算签名并做常量时间比较,防止签名篡改。
|
||||||
|
const [encodedHeader, encodedPayload, signature] = parts;
|
||||||
|
const unsignedToken = `${encodedHeader}.${encodedPayload}`;
|
||||||
|
const expectedSignature = this.signRaw(unsignedToken);
|
||||||
|
if (
|
||||||
|
signature.length !== expectedSignature.length ||
|
||||||
|
!timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))
|
||||||
|
) {
|
||||||
|
throw new UnauthorizedException('token 签名错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 payload 并做过期检查。
|
||||||
|
const payload = this.decodeObject<TokenPayload>(encodedPayload);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (!payload.sub || !payload.role || !payload.exp || payload.exp <= now) {
|
||||||
|
throw new UnauthorizedException('token 已过期');
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private signRaw(content: string): string {
|
||||||
|
// 统一签名算法,便于后续切换实现。
|
||||||
|
return createHmac('sha256', this.secret).update(content).digest('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
private encodeObject(value: object): string {
|
||||||
|
return Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeObject<T>(encoded: string): T {
|
||||||
|
try {
|
||||||
|
return JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as T;
|
||||||
|
} catch {
|
||||||
|
// 解析异常统一转成鉴权异常,避免泄露内部细节。
|
||||||
|
throw new UnauthorizedException('token 解析失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/auth/types/auth-user.type.ts
Normal file
10
src/auth/types/auth-user.type.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import type { UserRole } from '../../generated/prisma/enums.js';
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: number;
|
||||||
|
role: UserRole;
|
||||||
|
hospitalId: number | null;
|
||||||
|
departmentId: number | null;
|
||||||
|
medicalGroupId: number | null;
|
||||||
|
managerId: number | null;
|
||||||
|
}
|
||||||
22
src/db-exception/db-exception.filter.ts
Normal file
22
src/db-exception/db-exception.filter.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
ArgumentsHost,
|
||||||
|
Catch,
|
||||||
|
ExceptionFilter,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaClientKnownRequestError } from '../generated/prisma/internal/prismaNamespace.js';
|
||||||
|
import { Response } from 'express';
|
||||||
|
|
||||||
|
@Catch(PrismaClientKnownRequestError)
|
||||||
|
export class DbExceptionFilter<T> implements ExceptionFilter {
|
||||||
|
catch(exception: T, host: ArgumentsHost) {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const response = ctx.getResponse<Response>();
|
||||||
|
if ((exception as PrismaClientKnownRequestError).code === 'P2002') {
|
||||||
|
response.status(HttpStatus.CONFLICT).json({
|
||||||
|
statusCode: HttpStatus.CONFLICT,
|
||||||
|
message: 'Unique constraint failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/main.ts
22
src/main.ts
@ -1,8 +1,30 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { AppModule } from './app.module.js';
|
import { AppModule } from './app.module.js';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { DbExceptionFilter } from './db-exception/db-exception.filter.js';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
transform: true,
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
app.useGlobalFilters(new DbExceptionFilter());
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('TYT example')
|
||||||
|
.setDescription('The TYT API description')
|
||||||
|
.setVersion('1.0')
|
||||||
|
.addServer('http://localhost:3000', 'localhost')
|
||||||
|
.addTag('TYT')
|
||||||
|
.build();
|
||||||
|
const documentFactory = () => SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup('api', app, documentFactory, {
|
||||||
|
jsonDocumentUrl: 'swagger/json',
|
||||||
|
});
|
||||||
await app.listen(process.env.PORT ?? 3000);
|
await app.listen(process.env.PORT ?? 3000);
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
10
src/prisma.module.ts
Normal file
10
src/prisma.module.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
// PrismaService 作为全局可复用数据访问层。
|
||||||
|
providers: [PrismaService],
|
||||||
|
// 导出后其它模块可直接注入 PrismaService。
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
@ -1 +1,101 @@
|
|||||||
export class CreateUserDto {}
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { UserRole } from '../../generated/prisma/enums.js';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateUserDto {
|
||||||
|
// 手机号作为唯一登录名。
|
||||||
|
@Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' })
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'User login phone number (E.164 format).',
|
||||||
|
example: '+8613800138000',
|
||||||
|
})
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
// 管理端创建用户时直接设置初始密码。
|
||||||
|
@Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, {
|
||||||
|
message: '密码至少8位,且包含字母和数字',
|
||||||
|
})
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Initial password, 8-64 chars with letters and numbers.',
|
||||||
|
example: 'Test123456',
|
||||||
|
})
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
// 真实姓名用于业务展示。
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(64)
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Display name.',
|
||||||
|
example: 'Demo User',
|
||||||
|
})
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
// 未传角色时由服务端默认成 DOCTOR。
|
||||||
|
@IsEnum(UserRole)
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Role of the user. Defaults to DOCTOR when omitted.',
|
||||||
|
enum: UserRole,
|
||||||
|
example: UserRole.DOCTOR,
|
||||||
|
})
|
||||||
|
role?: UserRole;
|
||||||
|
|
||||||
|
// 组织归属:医院。
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Hospital id.',
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
|
hospitalId?: number;
|
||||||
|
|
||||||
|
// 组织归属:科室。
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Department id.',
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
|
departmentId?: number;
|
||||||
|
|
||||||
|
// 组织归属:小组。
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Medical group id.',
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
|
medicalGroupId?: number;
|
||||||
|
|
||||||
|
// 上下级关系:直属上级用户 ID。
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Direct manager user id.',
|
||||||
|
example: 2,
|
||||||
|
})
|
||||||
|
managerId?: number;
|
||||||
|
|
||||||
|
// 是否启用账号,默认 true。
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Whether the account is active. Defaults to true.',
|
||||||
|
example: true,
|
||||||
|
})
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,92 @@
|
|||||||
import { PartialType } from '@nestjs/mapped-types';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { CreateUserDto } from './create-user.dto';
|
import { UserRole } from '../../generated/prisma/enums.js';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class UpdateUserDto extends PartialType(CreateUserDto) {}
|
export class UpdateUserDto {
|
||||||
|
// 修改手机号。
|
||||||
|
@Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' })
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'User login phone number (E.164 format).',
|
||||||
|
example: '+8613800138000',
|
||||||
|
})
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
// 修改姓名。
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(64)
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Display name.',
|
||||||
|
example: 'Updated User',
|
||||||
|
})
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
// 修改角色(仅管理员可用)。
|
||||||
|
@IsEnum(UserRole)
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Role of the user.',
|
||||||
|
enum: UserRole,
|
||||||
|
example: UserRole.DOCTOR,
|
||||||
|
})
|
||||||
|
role?: UserRole;
|
||||||
|
|
||||||
|
// 修改医院归属(仅管理员可用)。
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Hospital id.',
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
|
hospitalId?: number;
|
||||||
|
|
||||||
|
// 修改科室归属(仅管理员可用)。
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Department id.',
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
|
departmentId?: number;
|
||||||
|
|
||||||
|
// 修改小组归属(仅管理员可用)。
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Medical group id.',
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
|
medicalGroupId?: number;
|
||||||
|
|
||||||
|
// 修改直属上级(仅管理员可用)。
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Direct manager user id.',
|
||||||
|
example: 2,
|
||||||
|
})
|
||||||
|
managerId?: number;
|
||||||
|
|
||||||
|
// 启停账号(仅管理员可用)。
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Whether account is active.',
|
||||||
|
example: true,
|
||||||
|
})
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
export class User {}
|
|
||||||
@ -1,34 +1,67 @@
|
|||||||
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
|
import {
|
||||||
import { UsersService } from './users.service';
|
Body,
|
||||||
import { CreateUserDto } from './dto/create-user.dto';
|
Controller,
|
||||||
import { UpdateUserDto } from './dto/update-user.dto';
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
ParseIntPipe,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator.js';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator.js';
|
||||||
|
import type { AuthUser } from '../auth/types/auth-user.type.js';
|
||||||
|
import { UserRole } from '../generated/prisma/enums.js';
|
||||||
|
import { UsersService } from './users.service.js';
|
||||||
|
import { CreateUserDto } from './dto/create-user.dto.js';
|
||||||
|
import { UpdateUserDto } from './dto/update-user.dto.js';
|
||||||
|
|
||||||
@Controller('users')
|
@Controller('users')
|
||||||
export class UsersController {
|
export class UsersController {
|
||||||
constructor(private readonly usersService: UsersService) {}
|
constructor(private readonly usersService: UsersService) {}
|
||||||
|
|
||||||
|
// 仅系统管理员和医院管理员可以创建用户。
|
||||||
|
@Roles(UserRole.SYSTEM_ADMIN, UserRole.HOSPITAL_ADMIN)
|
||||||
@Post()
|
@Post()
|
||||||
create(@Body() createUserDto: CreateUserDto) {
|
create(
|
||||||
return this.usersService.create(createUserDto);
|
@CurrentUser() currentUser: AuthUser,
|
||||||
|
@Body() createUserDto: CreateUserDto,
|
||||||
|
) {
|
||||||
|
return this.usersService.create(currentUser, createUserDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 列表接口会根据当前角色自动过滤可见范围。
|
||||||
@Get()
|
@Get()
|
||||||
findAll() {
|
findAll(@CurrentUser() currentUser: AuthUser) {
|
||||||
return this.usersService.findAll();
|
return this.usersService.findAll(currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 单个详情同样走可见范围校验。
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(
|
||||||
return this.usersService.findOne(+id);
|
@CurrentUser() currentUser: AuthUser,
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
) {
|
||||||
|
return this.usersService.findOne(currentUser, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新接口支持管理员更新他人、普通用户更新自己(受字段限制)。
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
|
update(
|
||||||
return this.usersService.update(+id, updateUserDto);
|
@CurrentUser() currentUser: AuthUser,
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() updateUserDto: UpdateUserDto,
|
||||||
|
) {
|
||||||
|
return this.usersService.update(currentUser, id, updateUserDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 删除采用“禁用账号”方式,避免误删关联业务数据。
|
||||||
|
@Roles(UserRole.SYSTEM_ADMIN, UserRole.HOSPITAL_ADMIN)
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
remove(@Param('id') id: string) {
|
remove(
|
||||||
return this.usersService.remove(+id);
|
@CurrentUser() currentUser: AuthUser,
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
) {
|
||||||
|
return this.usersService.remove(currentUser, id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service.js';
|
||||||
import { UsersController } from './users.controller';
|
import { UsersController } from './users.controller.js';
|
||||||
|
import { PrismaModule } from '../prisma.module.js';
|
||||||
|
import { PasswordService } from '../auth/password.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
// 复用 Prisma 单例,避免每个模块重复实例化客户端。
|
||||||
|
imports: [PrismaModule],
|
||||||
controllers: [UsersController],
|
controllers: [UsersController],
|
||||||
providers: [UsersService],
|
// UsersService 依赖 PasswordService 来处理管理员创建用户时的密码哈希。
|
||||||
|
providers: [UsersService, PasswordService],
|
||||||
})
|
})
|
||||||
export class UsersModule {}
|
export class UsersModule {}
|
||||||
|
|||||||
@ -1,26 +1,405 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import {
|
||||||
import { CreateUserDto } from './dto/create-user.dto';
|
ConflictException,
|
||||||
import { UpdateUserDto } from './dto/update-user.dto';
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { AuthUser } from '../auth/types/auth-user.type.js';
|
||||||
|
import { PasswordService } from '../auth/password.service.js';
|
||||||
|
import { UserRole } from '../generated/prisma/enums.js';
|
||||||
|
import { CreateUserDto } from './dto/create-user.dto.js';
|
||||||
|
import { UpdateUserDto } from './dto/update-user.dto.js';
|
||||||
|
import { PrismaService } from '../prisma.service.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
create(createUserDto: CreateUserDto) {
|
// 统一定义用户输出字段,避免泄露密码哈希与 openId 明文。
|
||||||
return 'This action adds a new user';
|
private readonly userSelect = {
|
||||||
|
id: true,
|
||||||
|
phone: true,
|
||||||
|
name: true,
|
||||||
|
role: true,
|
||||||
|
hospitalId: true,
|
||||||
|
departmentId: true,
|
||||||
|
medicalGroupId: true,
|
||||||
|
managerId: true,
|
||||||
|
wechatMiniOpenId: true,
|
||||||
|
wechatOfficialOpenId: true,
|
||||||
|
isActive: true,
|
||||||
|
lastLoginAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly passwordService: PasswordService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(currentUser: AuthUser, createUserDto: CreateUserDto) {
|
||||||
|
// 只有系统管理员和医院管理员可以创建用户。
|
||||||
|
if (!this.isAdmin(currentUser.role)) {
|
||||||
|
throw new ForbiddenException('无创建用户权限');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 医院管理员创建用户时强制绑定本院,防止跨院越权。
|
||||||
|
const role = createUserDto.role ?? UserRole.DOCTOR;
|
||||||
|
let hospitalId = createUserDto.hospitalId;
|
||||||
|
if (currentUser.role === UserRole.HOSPITAL_ADMIN) {
|
||||||
|
if (!currentUser.hospitalId) {
|
||||||
|
throw new ForbiddenException('当前管理员未绑定医院');
|
||||||
|
}
|
||||||
|
if (hospitalId !== undefined && hospitalId !== currentUser.hospitalId) {
|
||||||
|
throw new ForbiddenException('医院管理员不能跨院创建用户');
|
||||||
|
}
|
||||||
|
if (role === UserRole.SYSTEM_ADMIN) {
|
||||||
|
throw new ForbiddenException('医院管理员不能创建系统管理员');
|
||||||
|
}
|
||||||
|
hospitalId = currentUser.hospitalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手机号唯一约束用显式检查提升错误可读性。
|
||||||
|
const existingUser = await this.prisma.user.findUnique({
|
||||||
|
where: { phone: createUserDto.phone },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existingUser) {
|
||||||
|
throw new ConflictException('手机号已存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新建用户时密码必须做哈希存储,禁止明文落库。
|
||||||
|
const passwordHash = await this.passwordService.hashPassword(
|
||||||
|
createUserDto.password,
|
||||||
|
);
|
||||||
|
await this.assertManagerValid(createUserDto.managerId, hospitalId ?? null);
|
||||||
|
|
||||||
|
const createdUser = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
phone: createUserDto.phone,
|
||||||
|
passwordHash,
|
||||||
|
name: createUserDto.name,
|
||||||
|
role,
|
||||||
|
hospitalId,
|
||||||
|
departmentId: createUserDto.departmentId,
|
||||||
|
medicalGroupId: createUserDto.medicalGroupId,
|
||||||
|
managerId: createUserDto.managerId,
|
||||||
|
isActive: createUserDto.isActive ?? true,
|
||||||
|
},
|
||||||
|
select: this.userSelect,
|
||||||
|
});
|
||||||
|
return this.toUserView(createdUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
findAll() {
|
async findAll(currentUser: AuthUser) {
|
||||||
return `This action returns all users`;
|
// 按角色动态构造可见范围,避免在 controller 中散落权限判断。
|
||||||
|
const where = await this.buildVisibilityWhere(currentUser);
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where,
|
||||||
|
select: this.userSelect,
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
return users.map((user) => this.toUserView(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
findOne(id: number) {
|
async findOne(currentUser: AuthUser, id: number) {
|
||||||
return `This action returns a #${id} user`;
|
// 先查再判,便于区分不存在和无权限两类错误。
|
||||||
|
const targetUser = await this.prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: this.userSelect,
|
||||||
|
});
|
||||||
|
if (!targetUser) {
|
||||||
|
throw new NotFoundException('用户不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
const canAccess = await this.canAccessUser(currentUser, targetUser);
|
||||||
|
if (!canAccess) {
|
||||||
|
throw new ForbiddenException('无访问权限');
|
||||||
|
}
|
||||||
|
return this.toUserView(targetUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(id: number, updateUserDto: UpdateUserDto) {
|
async update(currentUser: AuthUser, id: number, updateUserDto: UpdateUserDto) {
|
||||||
return `This action updates a #${id} user`;
|
// 读取目标用户,用于后续做跨院和角色越权判断。
|
||||||
|
const targetUser = await this.prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: this.userSelect,
|
||||||
|
});
|
||||||
|
if (!targetUser) {
|
||||||
|
throw new NotFoundException('用户不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
const canAccess = await this.canAccessUser(currentUser, targetUser);
|
||||||
|
if (!canAccess) {
|
||||||
|
throw new ForbiddenException('无更新权限');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非管理员只允许修改自己,且只能改基础资料。
|
||||||
|
if (!this.isAdmin(currentUser.role)) {
|
||||||
|
if (currentUser.id !== id) {
|
||||||
|
throw new ForbiddenException('只能修改自己的资料');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
updateUserDto.role !== undefined ||
|
||||||
|
updateUserDto.hospitalId !== undefined ||
|
||||||
|
updateUserDto.departmentId !== undefined ||
|
||||||
|
updateUserDto.medicalGroupId !== undefined ||
|
||||||
|
updateUserDto.managerId !== undefined ||
|
||||||
|
updateUserDto.isActive !== undefined
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException('无权修改组织和角色信息');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 医院管理员不允许操作系统管理员,且不能把用户迁移到其他医院。
|
||||||
|
if (currentUser.role === UserRole.HOSPITAL_ADMIN) {
|
||||||
|
if (targetUser.role === UserRole.SYSTEM_ADMIN) {
|
||||||
|
throw new ForbiddenException('医院管理员不能操作系统管理员');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
updateUserDto.hospitalId !== undefined &&
|
||||||
|
updateUserDto.hospitalId !== currentUser.hospitalId
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException('医院管理员不能跨院修改');
|
||||||
|
}
|
||||||
|
if (updateUserDto.role === UserRole.SYSTEM_ADMIN) {
|
||||||
|
throw new ForbiddenException('不能提升为系统管理员');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅写入传入字段,避免误覆盖数据库已有值。
|
||||||
|
const data: {
|
||||||
|
phone?: string;
|
||||||
|
name?: string | null;
|
||||||
|
role?: UserRole;
|
||||||
|
hospitalId?: number | null;
|
||||||
|
departmentId?: number | null;
|
||||||
|
medicalGroupId?: number | null;
|
||||||
|
managerId?: number | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
if (updateUserDto.phone !== undefined) {
|
||||||
|
data.phone = updateUserDto.phone;
|
||||||
|
}
|
||||||
|
if (updateUserDto.name !== undefined) {
|
||||||
|
data.name = updateUserDto.name;
|
||||||
|
}
|
||||||
|
if (updateUserDto.role !== undefined) {
|
||||||
|
data.role = updateUserDto.role;
|
||||||
|
}
|
||||||
|
if (updateUserDto.hospitalId !== undefined) {
|
||||||
|
data.hospitalId = updateUserDto.hospitalId;
|
||||||
|
}
|
||||||
|
if (updateUserDto.departmentId !== undefined) {
|
||||||
|
data.departmentId = updateUserDto.departmentId;
|
||||||
|
}
|
||||||
|
if (updateUserDto.medicalGroupId !== undefined) {
|
||||||
|
data.medicalGroupId = updateUserDto.medicalGroupId;
|
||||||
|
}
|
||||||
|
if (updateUserDto.managerId !== undefined) {
|
||||||
|
data.managerId = updateUserDto.managerId;
|
||||||
|
}
|
||||||
|
if (updateUserDto.isActive !== undefined) {
|
||||||
|
data.isActive = updateUserDto.isActive;
|
||||||
|
}
|
||||||
|
if (data.managerId !== undefined && data.managerId === id) {
|
||||||
|
throw new ForbiddenException('不能把自己设置为上级');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果变更了上级,需要校验上级存在且组织关系合法。
|
||||||
|
const nextHospitalId = data.hospitalId ?? targetUser.hospitalId;
|
||||||
|
await this.assertManagerValid(data.managerId, nextHospitalId);
|
||||||
|
|
||||||
|
const updatedUser = await this.prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
select: this.userSelect,
|
||||||
|
});
|
||||||
|
return this.toUserView(updatedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(id: number) {
|
async remove(currentUser: AuthUser, id: number) {
|
||||||
return `This action removes a #${id} user`;
|
// 删除接口采用“停用账号”实现,保留审计和业务关联。
|
||||||
|
if (!this.isAdmin(currentUser.role)) {
|
||||||
|
throw new ForbiddenException('无删除权限');
|
||||||
|
}
|
||||||
|
if (currentUser.id === id) {
|
||||||
|
throw new ForbiddenException('不能停用自己');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUser = await this.prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: this.userSelect,
|
||||||
|
});
|
||||||
|
if (!targetUser) {
|
||||||
|
throw new NotFoundException('用户不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
const canAccess = await this.canAccessUser(currentUser, targetUser);
|
||||||
|
if (!canAccess) {
|
||||||
|
throw new ForbiddenException('无删除权限');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
currentUser.role === UserRole.HOSPITAL_ADMIN &&
|
||||||
|
targetUser.role === UserRole.SYSTEM_ADMIN
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException('医院管理员不能停用系统管理员');
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabledUser = await this.prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isActive: false },
|
||||||
|
select: this.userSelect,
|
||||||
|
});
|
||||||
|
return this.toUserView(disabledUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildVisibilityWhere(currentUser: AuthUser) {
|
||||||
|
// 系统管理员可见全量用户。
|
||||||
|
if (currentUser.role === UserRole.SYSTEM_ADMIN) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 医院管理员仅可见本院用户。
|
||||||
|
if (currentUser.role === UserRole.HOSPITAL_ADMIN) {
|
||||||
|
if (!currentUser.hospitalId) {
|
||||||
|
return { id: -1 };
|
||||||
|
}
|
||||||
|
return { hospitalId: currentUser.hospitalId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主任/组长可见自己和所有下级。
|
||||||
|
if (
|
||||||
|
currentUser.role === UserRole.DIRECTOR ||
|
||||||
|
currentUser.role === UserRole.TEAM_LEAD
|
||||||
|
) {
|
||||||
|
const subordinateIds = await this.getSubordinateIds(currentUser.id);
|
||||||
|
return { id: { in: [currentUser.id, ...subordinateIds] } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 医生、工程师默认只可见自己。
|
||||||
|
return { id: currentUser.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async canAccessUser(
|
||||||
|
currentUser: AuthUser,
|
||||||
|
targetUser: { id: number; role: UserRole; hospitalId: number | null },
|
||||||
|
): Promise<boolean> {
|
||||||
|
// 系统管理员总是有权限。
|
||||||
|
if (currentUser.role === UserRole.SYSTEM_ADMIN) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 医院管理员限制在本院范围。
|
||||||
|
if (currentUser.role === UserRole.HOSPITAL_ADMIN) {
|
||||||
|
return (
|
||||||
|
currentUser.hospitalId !== null &&
|
||||||
|
targetUser.hospitalId === currentUser.hospitalId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主任/组长需要命中自己或下级链路。
|
||||||
|
if (
|
||||||
|
currentUser.role === UserRole.DIRECTOR ||
|
||||||
|
currentUser.role === UserRole.TEAM_LEAD
|
||||||
|
) {
|
||||||
|
if (targetUser.id === currentUser.id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const subordinateIds = await this.getSubordinateIds(currentUser.id);
|
||||||
|
return subordinateIds.includes(targetUser.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通角色仅能访问自己。
|
||||||
|
return targetUser.id === currentUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSubordinateIds(rootUserId: number) {
|
||||||
|
// 使用 BFS 逐层展开下级,支持任意深度上下级关系。
|
||||||
|
const visited = new Set<number>();
|
||||||
|
let frontier = [rootUserId];
|
||||||
|
|
||||||
|
while (frontier.length > 0) {
|
||||||
|
const rows = await this.prisma.user.findMany({
|
||||||
|
where: { managerId: { in: frontier } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextFrontier: number[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
if (visited.has(row.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
visited.add(row.id);
|
||||||
|
nextFrontier.push(row.id);
|
||||||
|
}
|
||||||
|
frontier = nextFrontier;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(visited);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAdmin(role: UserRole) {
|
||||||
|
// 系统管理员和医院管理员都属于平台管理角色。
|
||||||
|
return role === UserRole.SYSTEM_ADMIN || role === UserRole.HOSPITAL_ADMIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertManagerValid(
|
||||||
|
managerId: number | null | undefined,
|
||||||
|
hospitalId: number | null,
|
||||||
|
) {
|
||||||
|
// 未设置上级时无需校验。
|
||||||
|
if (managerId === undefined || managerId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上级用户必须存在且处于启用状态。
|
||||||
|
const manager = await this.prisma.user.findUnique({
|
||||||
|
where: { id: managerId },
|
||||||
|
select: { id: true, hospitalId: true, isActive: true },
|
||||||
|
});
|
||||||
|
if (!manager || !manager.isActive) {
|
||||||
|
throw new NotFoundException('上级用户不存在或已停用');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若当前用户绑定了医院,上级也必须在同一医院。
|
||||||
|
if (hospitalId !== null && manager.hospitalId !== hospitalId) {
|
||||||
|
throw new ForbiddenException('上级必须与用户在同一医院');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toUserView(user: {
|
||||||
|
id: number;
|
||||||
|
phone: string;
|
||||||
|
name: string | null;
|
||||||
|
role: UserRole;
|
||||||
|
hospitalId: number | null;
|
||||||
|
departmentId: number | null;
|
||||||
|
medicalGroupId: number | null;
|
||||||
|
managerId: number | null;
|
||||||
|
wechatMiniOpenId: string | null;
|
||||||
|
wechatOfficialOpenId: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
lastLoginAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}) {
|
||||||
|
// 返回 API 时只透出是否绑定,不透出 openId 实值。
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
phone: user.phone,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
hospitalId: user.hospitalId,
|
||||||
|
departmentId: user.departmentId,
|
||||||
|
medicalGroupId: user.medicalGroupId,
|
||||||
|
managerId: user.managerId,
|
||||||
|
isActive: user.isActive,
|
||||||
|
lastLoginAt: user.lastLoginAt,
|
||||||
|
wechatMiniLinked: Boolean(user.wechatMiniOpenId),
|
||||||
|
wechatOfficialLinked: Boolean(user.wechatOfficialOpenId),
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
updatedAt: user.updatedAt,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user