Compare commits

...

6 Commits

Author SHA1 Message Date
EL
569d827b78 prisma.config.ts 新增 seed 命令:node --env-file=.env --loader ts-node/esm prisma/seed.ts
各 DTO 增加 Swagger ApiProperty/ApiPropertyOptional 描述与示例(尤其 phone/password)
seed.ts 为新增完整 seed 脚本(幂等 upsert + 角色样例 + 患者 + 工程师分配)
2026-03-12 18:50:03 +08:00
EL
0024562863 feat: 手机号与微信登录鉴权改造,完善用户CRUD与权限控制 2026-03-12 18:10:06 +08:00
EL
3cd7a044ca 变更统计是 4 个已纳入 diff 的文件,共 137 行新增、40 行删除。核心内容是把 Prisma schema 从原本的 User/Post 扩展为医院、科室、医疗组、患者、工程师分配等模型,同时同步调整了用户创建 DTO 和 service 的入参映射,并新增了一份对应迁移、删除了一份旧迁移。 2026-03-12 17:33:11 +08:00
EL
f22469d400 更改了接口文档详情 2026-03-12 17:07:33 +08:00
EL
48a6cb99db 增加全局 ValidationPipe,完善用户 DTO 校验。
接入 Swagger(含编译插件)与 ConfigModule。
实现用户创建及邮箱重复拦截,新增 Prisma P2002 全局异常过滤。
2026-03-12 16:55:36 +08:00
EL
2812832fa5 变更背景:
原用户查询仍为占位实现,未读取数据库
影响范围:

用户列表查询逻辑
用户模块依赖注入配置
相关 TypeScript/ESM 导入路径规范化

feat(users): 接入 Prisma 用户查询并统一 ESM 导入路径

在用户模块中注册 PrismaService 作为 provider,完善依赖注入链路
将 UsersService 的 findAll 实现改为通过 Prisma 查询用户列表
为控制器查询接口补充明确的返回类型定义
统一相关导入语句为 .js 后缀,适配当前 ESM 导入规范
删除已不再使用的用户实体占位文件
2026-03-12 16:00:17 +08:00
37 changed files with 2406 additions and 93 deletions

View File

@ -3,6 +3,7 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"plugins": ["@nestjs/swagger"]
}
}

View File

@ -16,11 +16,15 @@
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
"@nestjs/mapped-types": "*",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.2.6",
"@prisma/adapter-pg": "^7.5.0",
"@prisma/client": "^7.5.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"

161
pnpm-lock.yaml generated
View File

@ -10,22 +10,34 @@ importers:
dependencies:
'@nestjs/common':
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':
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':
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':
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':
specifier: ^7.5.0
version: 7.5.0
'@prisma/client':
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)
class-transformer:
specifier: ^0.5.1
version: 0.5.1
class-validator:
specifier: ^0.15.1
version: 0.15.1
pg:
specifier: ^8.20.0
version: 8.20.0
@ -44,7 +56,7 @@ importers:
version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3)
'@nestjs/testing':
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':
specifier: ^5.0.0
version: 5.0.6
@ -332,6 +344,9 @@ packages:
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
engines: {node: '>=8'}
'@microsoft/tsdoc@0.16.0':
resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==}
'@mrleebo/prisma-ast@0.13.1':
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
engines: {node: '>=16'}
@ -362,6 +377,12 @@ packages:
class-validator:
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':
resolution: {integrity: sha512-tXWXyCiqWthelJjrE0KLFjf0O98VEt+WPVx5CrqCf+059kIxJ8y1Vw7Cy7N4fwQafWNrmFL2AfN87DDMbVAY0w==}
engines: {node: '>= 20'}
@ -404,6 +425,23 @@ packages:
peerDependencies:
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':
resolution: {integrity: sha512-E7/aUCxzeMSJV80L5GWGIuiMyR/1ncS7uOIetAImfbS4ATE1/h2GBafk0qpk+vjFtPIbtoh9BWDGICzUEU5jDA==}
peerDependencies:
@ -490,6 +528,9 @@ packages:
react: ^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':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@ -572,6 +613,9 @@ packages:
'@types/supertest@6.0.3':
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':
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
@ -814,6 +858,12 @@ packages:
citty@0.2.1:
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:
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
engines: {node: '>=8'}
@ -963,10 +1013,18 @@ packages:
resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==}
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:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
dotenv@17.2.3:
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
engines: {node: '>=12'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@ -1279,6 +1337,9 @@ packages:
jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
libphonenumber-js@1.12.39:
resolution: {integrity: sha512-MW79m7HuOqBk8mwytiXYTMELJiBbV3Zl9Y39dCCn1yC8K+WGNSq1QGvzywbylp5vGShEztMScCWHX/XFOS0rXg==}
lilconfig@2.1.0:
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
engines: {node: '>=10'}
@ -1830,6 +1891,9 @@ packages:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
swagger-ui-dist@5.31.0:
resolution: {integrity: sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==}
symbol-observable@4.0.0:
resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==}
engines: {node: '>=0.10'}
@ -1965,6 +2029,10 @@ packages:
typescript:
optional: true
validator@13.15.26:
resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==}
engines: {node: '>= 0.10'}
vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
@ -2293,6 +2361,8 @@ snapshots:
'@lukeed/csprng@1.1.0': {}
'@microsoft/tsdoc@0.16.0': {}
'@mrleebo/prisma-ast@0.13.1':
dependencies:
chevrotain: 10.5.0
@ -2324,7 +2394,7 @@ snapshots:
- uglify-js
- 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:
file-type: 21.3.0
iterare: 1.2.1
@ -2333,12 +2403,23 @@ snapshots:
rxjs: 7.8.2
tslib: 2.8.1
uid: 2.0.2
optionalDependencies:
class-transformer: 0.5.1
class-validator: 0.15.1
transitivePeerDependencies:
- 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:
'@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
fast-safe-stringify: 2.1.1
iterare: 1.2.1
@ -2348,17 +2429,20 @@ snapshots:
tslib: 2.8.1
uid: 2.0.2
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:
'@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
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:
'@nestjs/common': 11.1.16(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/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)
cors: 2.8.6
express: 5.2.1
multer: 2.1.1
@ -2378,13 +2462,28 @@ snapshots:
transitivePeerDependencies:
- 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:
'@nestjs/common': 11.1.16(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)
'@microsoft/tsdoc': 0.16.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)
'@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
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': {}
@ -2486,6 +2585,8 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@scarf/scarf@1.4.0': {}
'@standard-schema/spec@1.1.0': {}
'@tokenizer/inflate@0.4.1':
@ -2586,6 +2687,8 @@ snapshots:
'@types/methods': 1.1.4
'@types/superagent': 8.1.9
'@types/validator@13.15.10': {}
'@webassemblyjs/ast@1.14.1':
dependencies:
'@webassemblyjs/helper-numbers': 1.13.2
@ -2862,6 +2965,14 @@ snapshots:
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:
dependencies:
restore-cursor: 3.1.0
@ -2978,8 +3089,14 @@ snapshots:
diff@4.0.4: {}
dotenv-expand@12.0.3:
dependencies:
dotenv: 16.6.1
dotenv@16.6.1: {}
dotenv@17.2.3: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@ -3309,6 +3426,8 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
libphonenumber-js@1.12.39: {}
lilconfig@2.1.0: {}
lines-and-columns@1.2.4: {}
@ -3839,6 +3958,10 @@ snapshots:
dependencies:
has-flag: 4.0.0
swagger-ui-dist@5.31.0:
dependencies:
'@scarf/scarf': 1.4.0
symbol-observable@4.0.0: {}
tapable@2.3.0: {}
@ -3960,6 +4083,8 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
validator@13.15.26: {}
vary@1.1.2: {}
watchpack@2.5.1:

View File

@ -7,6 +7,7 @@ export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "node --env-file=.env --loader ts-node/esm prisma/seed.ts",
},
datasource: {
url: process.env["DATABASE_URL"],

View File

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

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

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

View File

@ -13,18 +13,207 @@ datasource db {
provider = "postgresql"
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
/// 统一角色枚举:
/// - SYSTEM_ADMIN: 平台管理员
/// - HOSPITAL_ADMIN: 医院管理员
/// - DIRECTOR: 科室主任
/// - TEAM_LEAD: 小组组长
/// - DOCTOR: 医生
/// - ENGINEER: 工程师
enum UserRole {
SYSTEM_ADMIN
HOSPITAL_ADMIN
DIRECTOR
TEAM_LEAD
DOCTOR
ENGINEER
}
model Post {
/// 医院实体:多医院租户的顶层边界。
model Hospital {
/// 主键 ID。
id Int @id @default(autoincrement())
title String
content String?
published Boolean? @default(false)
author User? @relation(fields: [authorId], references: [id])
authorId Int?
/// 医院名称。
name String
/// 医院编码(唯一)。
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
View 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);
});

View File

@ -1,7 +1,10 @@
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({
imports: [UsersModule],
// ConfigModule 先加载,保证鉴权和数据库都可读取环境变量。
imports: [ConfigModule.forRoot(), AuthModule, UsersModule],
})
export class AppModule {}

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

@ -0,0 +1,2 @@
export const IS_PUBLIC_KEY = 'isPublic';
export const ROLES_KEY = 'roles';

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

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

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

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

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

View File

@ -0,0 +1,9 @@
import { IsString, MaxLength, MinLength } from 'class-validator';
export class LoginMiniProgramDto {
// 小程序 openId 由前端/网关在登录态中传入。
@IsString()
@MinLength(6)
@MaxLength(128)
miniOpenId: string;
}

View File

@ -0,0 +1,9 @@
import { IsString, MaxLength, MinLength } from 'class-validator';
export class LoginOfficialAccountDto {
// 服务号 openId 由前端/网关在登录态中传入。
@IsString()
@MinLength(6)
@MaxLength(128)
officialOpenId: string;
}

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

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

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

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

View 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
View 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 解析失败');
}
}
}

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

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

View File

@ -1,8 +1,30 @@
import { NestFactory } from '@nestjs/core';
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() {
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);
}
bootstrap();

10
src/prisma.module.ts Normal file
View 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 {}

View File

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

View File

@ -1,4 +1,92 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
import { 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 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;
}

View File

@ -1 +0,0 @@
export class User {}

View File

@ -1,34 +1,67 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import {
Body,
Controller,
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')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
// 仅系统管理员和医院管理员可以创建用户。
@Roles(UserRole.SYSTEM_ADMIN, UserRole.HOSPITAL_ADMIN)
@Post()
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
create(
@CurrentUser() currentUser: AuthUser,
@Body() createUserDto: CreateUserDto,
) {
return this.usersService.create(currentUser, createUserDto);
}
// 列表接口会根据当前角色自动过滤可见范围。
@Get()
findAll() {
return this.usersService.findAll();
findAll(@CurrentUser() currentUser: AuthUser) {
return this.usersService.findAll(currentUser);
}
// 单个详情同样走可见范围校验。
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findOne(+id);
findOne(
@CurrentUser() currentUser: AuthUser,
@Param('id', ParseIntPipe) id: number,
) {
return this.usersService.findOne(currentUser, id);
}
// 更新接口支持管理员更新他人、普通用户更新自己(受字段限制)。
@Patch(':id')
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
return this.usersService.update(+id, updateUserDto);
update(
@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')
remove(@Param('id') id: string) {
return this.usersService.remove(+id);
remove(
@CurrentUser() currentUser: AuthUser,
@Param('id', ParseIntPipe) id: number,
) {
return this.usersService.remove(currentUser, id);
}
}

View File

@ -1,9 +1,14 @@
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { UsersService } from './users.service.js';
import { UsersController } from './users.controller.js';
import { PrismaModule } from '../prisma.module.js';
import { PasswordService } from '../auth/password.service.js';
@Module({
// 复用 Prisma 单例,避免每个模块重复实例化客户端。
imports: [PrismaModule],
controllers: [UsersController],
providers: [UsersService],
// UsersService 依赖 PasswordService 来处理管理员创建用户时的密码哈希。
providers: [UsersService, PasswordService],
})
export class UsersModule {}

View File

@ -1,26 +1,405 @@
import { Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import {
ConflictException,
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()
export class UsersService {
create(createUserDto: CreateUserDto) {
return 'This action adds a new user';
// 统一定义用户输出字段,避免泄露密码哈希与 openId 明文。
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('无创建用户权限');
}
findAll() {
return `This action returns all users`;
// 医院管理员创建用户时强制绑定本院,防止跨院越权。
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;
}
findOne(id: number) {
return `This action returns a #${id} user`;
// 手机号唯一约束用显式检查提升错误可读性。
const existingUser = await this.prisma.user.findUnique({
where: { phone: createUserDto.phone },
select: { id: true },
});
if (existingUser) {
throw new ConflictException('手机号已存在');
}
update(id: number, updateUserDto: UpdateUserDto) {
return `This action updates a #${id} user`;
// 新建用户时密码必须做哈希存储,禁止明文落库。
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);
}
remove(id: number) {
return `This action removes a #${id} user`;
async findAll(currentUser: AuthUser) {
// 按角色动态构造可见范围,避免在 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));
}
async findOne(currentUser: AuthUser, id: number) {
// 先查再判,便于区分不存在和无权限两类错误。
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);
}
async update(currentUser: AuthUser, id: number, updateUserDto: UpdateUserDto) {
// 读取目标用户,用于后续做跨院和角色越权判断。
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);
}
async remove(currentUser: AuthUser, id: number) {
// 删除接口采用“停用账号”实现,保留审计和业务关联。
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,
};
}
}