From e8f05513fd0299e774880c1a9699347b86b4a57c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=9B=E5=85=AE?= Date: Tue, 26 May 2026 18:01:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=91=98=E5=B7=A5?= =?UTF-8?q?=E9=97=A8=E5=BA=97=E8=BD=AF=E5=88=A0=E9=99=A4=E4=B8=8E=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 62 +++++-- docs/API.md | 159 ++++++++++++++++-- ...add_soft_delete_to_roles_and_relations.sql | 35 ++++ src/modules/auth/auth.repository.ts | 65 ++++++- src/modules/auth/auth.service.ts | 26 ++- src/modules/auth/auth.types.ts | 2 + src/modules/auth/password.ts | 3 + src/modules/catalog/catalog.controller.ts | 23 ++- src/modules/catalog/catalog.repository.ts | 26 ++- src/modules/catalog/catalog.schema.ts | 5 + src/modules/catalog/catalog.service.ts | 23 +-- src/modules/catalog/catalog.types.ts | 6 + src/modules/employees/employee.controller.ts | 22 +++ src/modules/employees/employee.repository.ts | 106 +++++++++++- src/modules/employees/employee.schema.ts | 5 + src/modules/employees/employee.service.ts | 53 +++++- src/modules/employees/employee.types.ts | 19 +++ src/modules/permissions/permission.policy.ts | 8 +- .../permissions/permission.repository.ts | 22 ++- 19 files changed, 597 insertions(+), 73 deletions(-) create mode 100644 migrations/007_add_soft_delete_to_roles_and_relations.sql diff --git a/README.md b/README.md index de02c90..856f483 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ ## 项目能力 -- 门店管理:查询、新增、修改、软删除门店。 -- 角色管理:拥有 `role:manage` 的账号可新增、修改、删除自定义角色,服务端内置角色不可变更。 -- 员工管理:分页查询、新增、修改、启用/停用、软删除员工。 +- 门店管理:查询、新增、修改、停用、软删除门店,门店详情可查看员工。 +- 角色管理:拥有 `role:manage` 的账号可新增、修改、软删除自定义角色,服务端内置角色不可变更。 +- 员工管理:分页查询、新增、修改、启用/停用、修改密码、重置初始密码、移除和软删除员工。 - 员工角色:一个员工可以绑定多个角色。 - 登录账号:超级管理员和员工都可以登录。 - 后台权限:超级管理员拥有所有权限;角色权限由 `role_permissions` 动态分配。 @@ -48,7 +48,8 @@ │ ├── 003_create_super_admins.sql # 创建超级管理员表和默认账号 │ ├── 004_add_employee_login_fields.sql # 给员工补充登录字段 │ ├── 005_refine_employee_login_and_role_policy.sql # 调整员工默认密码、手机号唯一和系统角色 -│ └── 006_create_role_permissions.sql # 创建角色权限关系表并初始化默认权限 +│ ├── 006_create_role_permissions.sql # 创建角色权限关系表并初始化默认权限 +│ └── 007_add_soft_delete_to_roles_and_relations.sql # 给角色和关系表补充逻辑删除字段并移除级联删除 ├── src/ │ ├── app.ts # 创建 Fastify 应用、注册路由、统一错误处理 │ ├── server.ts # 启动 HTTP 服务和优雅停机 @@ -88,8 +89,8 @@ | `src/db/migrate.ts` | 执行 `migrations/*.sql`,并用 `schema_migrations` 记录已执行迁移。 | | `src/db/pool.ts` | 创建 MySQL 连接池,提供数据库健康检查和关闭连接的方法。 | | `src/modules/auth/` | 登录鉴权模块,负责后台登录、员工端登录、密码校验、JWT 签发、当前用户查询和权限 guard。 | -| `src/modules/catalog/` | 门店和角色模块,负责基础资料接口。 | -| `src/modules/employees/` | 员工模块,负责员工分页、详情、新增、修改、状态变更和软删除。 | +| `src/modules/catalog/` | 门店和角色模块,负责基础资料接口、门店详情员工列表和门店移除员工入口。 | +| `src/modules/employees/` | 员工模块,负责员工分页、详情、新增、修改、状态变更、密码维护和软删除。 | | `src/modules/permissions/` | 权限模块,维护权限点定义、角色权限分配、当前用户菜单动作权限和权限策略说明。 | | `src/shared/` | 跨模块复用的响应结构和业务错误类型。 | | `docker-compose.yml` | 本地开发用 MySQL 容器配置。 | @@ -314,6 +315,7 @@ curl -X POST http://localhost:3500/api/auth/employee/login \ 响应里的 `data.token` 就是后续接口要使用的 JWT。 响应里的 `data.user.permissions` 是服务端按角色动态计算出的权限点;菜单和按钮动作以 `/api/permissions/me` 返回结果为准。 +登录时会先校验账号和密码;如果密码正确但账号已停用,会返回“账号已被禁用”;如果所属门店已停用,会返回“所属门店已被禁用”。 为了方便测试,可以先把 token 保存成 shell 变量: @@ -371,7 +373,7 @@ curl -X PUT http://localhost:3500/api/permissions/roles/5 \ | 菜单 | 超级管理员 | 默认管理员 `admin` | 默认店长 `store_manager` | 其他角色 | | --- | --- | --- | --- | --- | | 门店管理 | 查看、新增、修改、删除 | 查看、新增、修改、删除 | 不可见 | 不可见 | -| 角色管理 | 查看、新增、修改、删除自定义角色 | 查看、新增、修改、删除自定义角色 | 不可见 | 按角色权限决定 | +| 角色管理 | 查看、新增、修改、软删除自定义角色 | 查看、新增、修改、软删除自定义角色 | 不可见 | 按角色权限决定 | | 员工管理 | 查看全部、新增、修改、删除 | 查看全部、新增、修改、删除 | 仅查看当前门店员工 | 不可见 | | 权限管理 | 查看、分配 | 查看、分配 | 不可见 | 按角色权限决定 | @@ -391,6 +393,13 @@ curl 'http://localhost:3500/api/stores?includeInactive=true' \ -H "Authorization: Bearer $TOKEN" ``` +查询门店详情和门店员工: + +```bash +curl http://localhost:3500/api/stores/1 \ + -H "Authorization: Bearer $TOKEN" +``` + 新增门店: ```bash @@ -423,7 +432,14 @@ curl -X DELETE http://localhost:3500/api/stores/1 \ -H "Authorization: Bearer $TOKEN" ``` -门店下还有员工时,不能停用或删除门店。 +从门店详情中移除员工: + +```bash +curl -X DELETE http://localhost:3500/api/stores/1/employees/2 \ + -H "Authorization: Bearer $TOKEN" +``` + +门店下还有员工时,可以停用门店;该门店员工会返回 `storeStatus: "INACTIVE"` 和“门店被禁用”状态标签。门店下还有员工时,仍不能删除门店。 ## 角色接口示例 @@ -516,6 +532,22 @@ curl -X PATCH http://localhost:3500/api/employees/1/status \ -d '{"status": "INACTIVE"}' ``` +修改员工密码: + +```bash +curl -X PATCH http://localhost:3500/api/employees/1/password \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"oldPassword": "pw111111", "newPassword": "NewPw111111"}' +``` + +重置员工为初始密码: + +```bash +curl -X PATCH http://localhost:3500/api/employees/1/password/reset \ + -H "Authorization: Bearer $TOKEN" +``` + 软删除员工: ```bash @@ -523,7 +555,7 @@ curl -X DELETE http://localhost:3500/api/employees/1 \ -H "Authorization: Bearer $TOKEN" ``` -员工手机号就是登录账号,未删除员工的手机号不能重复。员工软删除后,可以重新录入相同手机号。 +员工手机号就是登录账号,未删除员工的手机号不能重复。员工软删除后,可以重新录入相同手机号。员工返回结构包含 `statusTags`;所属门店停用时会额外包含“门店被禁用”标签。 ## 数据库迁移说明 @@ -537,6 +569,7 @@ curl -X DELETE http://localhost:3500/api/employees/1 \ - [004_add_employee_login_fields.sql](./migrations/004_add_employee_login_fields.sql):给员工补充登录密码哈希和最后登录时间。 - [005_refine_employee_login_and_role_policy.sql](./migrations/005_refine_employee_login_and_role_policy.sql):员工默认密码改为 `pw111111`,手机号改为全局唯一,并标记服务端内置角色。 - [006_create_role_permissions.sql](./migrations/006_create_role_permissions.sql):创建角色权限关系表,并初始化 `admin` 和 `store_manager` 的默认权限。 +- [007_add_soft_delete_to_roles_and_relations.sql](./migrations/007_add_soft_delete_to_roles_and_relations.sql):给角色、员工角色关系和角色权限关系补充逻辑删除字段,移除关系表旧的 `ON DELETE CASCADE` 级联删除语义。 执行 `pnpm db:migrate` 时,脚本会: @@ -555,11 +588,12 @@ migrations/003_add_employee_email.sql ## 学习重点 -- `stores.deleted_at` 和 `employees.deleted_at` 用于软删除。 +- `stores.deleted_at`、`employees.deleted_at`、`roles.deleted_at` 用于软删除。 - `employees.active_phone` 是生成列,用来实现“未删除员工手机号全局唯一”。 +- `roles.active_code` 是生成列,用来实现“未删除角色编码唯一”。 - `employees.password_hash` 让员工也能登录,默认本地密码是 `pw111111`。 -- `employee_roles` 是多对多关系表。 -- `role_permissions` 保存角色和权限点的多对多关系,权限分配保存后会在接口鉴权时实时生效。 +- `employee_roles` 是员工和角色的多对多关系表,解绑时写入 `deleted_at`。 +- `role_permissions` 保存角色和权限点的多对多关系,权限解绑时写入 `deleted_at`,权限分配保存后会在接口鉴权时实时生效。 - `super_admins` 保存超级管理员账号,密码使用 PBKDF2 哈希,禁止存明文。 - 权限点定义由 `src/modules/permissions/` 固定,角色拥有的权限点由 `role_permissions` 动态决定。 - 前端根据 `/api/permissions/me` 渲染菜单和按钮,根据 `/api/permissions/definitions` 渲染可分配权限点。 @@ -604,9 +638,9 @@ docker compose ps `src/db/migrate.ts` 会把执行过的文件名写入 `schema_migrations`。再次运行时,如果文件名已存在,就会跳过,避免重复建表或重复插入数据。 -### 删除员工后,为什么数据库里还有记录 +### 删除员工或角色后,为什么数据库里还有记录 -这是软删除。删除接口会把 `deleted_at` 设置为当前时间,并把状态改成 `INACTIVE`。这样可以保留历史数据,同时普通查询会过滤掉已删除记录。 +这是软删除。员工和门店删除接口会把 `deleted_at` 设置为当前时间,并把状态改成 `INACTIVE`;角色删除接口会写入 `roles.deleted_at`。员工角色关系、角色权限关系的解绑也会写入各自的 `deleted_at`,迁移也会移除旧关系表上的级联物理删除约束。这样可以保留历史数据,同时普通查询会过滤掉已删除记录。 ### 为什么不使用 ORM diff --git a/docs/API.md b/docs/API.md index 9197b24..c17f6e8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -107,13 +107,13 @@ pw111111 | 权限码 | 说明 | | --- | --- | | `*` | 超级管理员,拥有全部权限 | -| `store:view` | 查看门店 | -| `store:manage` | 新增、修改、删除门店 | +| `store:view` | 查看门店和门店下员工 | +| `store:manage` | 新增、修改、停用、软删除门店 | | `role:view` | 查看角色 | -| `role:manage` | 新增、修改、删除自定义角色 | +| `role:manage` | 新增、修改、软删除自定义角色 | | `employee:view:all` | 查看全部门店员工 | | `employee:view:store` | 查看当前门店员工 | -| `employee:manage` | 新增、修改、删除员工 | +| `employee:manage` | 新增、修改、启停、移除、软删除员工和维护密码 | | `permission:view` | 查看权限策略 | | `permission:manage` | 分配角色权限 | @@ -192,14 +192,23 @@ interface Role extends RoleOption { ```ts type EmployeeStatus = "ACTIVE" | "INACTIVE"; +type EmployeeStoreStatus = "ACTIVE" | "INACTIVE"; + +interface EmployeeStatusTag { + code: "EMPLOYEE_ACTIVE" | "EMPLOYEE_INACTIVE" | "STORE_INACTIVE"; + label: string; + variant: "success" | "warning" | "default"; +} interface Employee { id: number; storeId: number; storeName: string; + storeStatus: EmployeeStoreStatus; name: string; phone: string; status: EmployeeStatus; + statusTags: EmployeeStatusTag[]; remark: string | null; roles: Array<{ id: number; @@ -238,20 +247,24 @@ interface PermissionMenu { | `PUT` | `/api/permissions/roles/:roleId` | 是 | `permission:manage` | 更新角色权限 | | `GET` | `/api/stores` | 是 | `store:view` | 门店列表或门店下拉选项 | | `GET` | `/api/stores/:id` | 是 | `store:view` | 门店详情 | +| `GET` | `/api/stores/:id/employees` | 是 | `store:view` | 门店员工列表 | | `POST` | `/api/stores` | 是 | `store:manage` | 新增门店 | | `PATCH` | `/api/stores/:id` | 是 | `store:manage` | 修改门店 | -| `DELETE` | `/api/stores/:id` | 是 | `store:manage` | 删除门店 | +| `DELETE` | `/api/stores/:storeId/employees/:employeeId` | 是 | `employee:manage` | 从门店移除员工 | +| `DELETE` | `/api/stores/:id` | 是 | `store:manage` | 软删除门店 | | `GET` | `/api/roles` | 是 | `role:view` | 角色列表 | | `GET` | `/api/roles/:id` | 是 | `role:view` | 角色详情 | | `POST` | `/api/roles` | 是 | `role:manage` | 新增自定义角色 | | `PATCH` | `/api/roles/:id` | 是 | `role:manage` | 修改自定义角色 | -| `DELETE` | `/api/roles/:id` | 是 | `role:manage` | 删除自定义角色 | +| `DELETE` | `/api/roles/:id` | 是 | `role:manage` | 软删除自定义角色 | | `GET` | `/api/employees` | 是 | `employee:view:all` 或 `employee:view:store` | 员工分页列表 | | `GET` | `/api/employees/:id` | 是 | `employee:view:all` 或当前门店 `employee:view:store` | 员工详情 | | `POST` | `/api/employees` | 是 | `employee:manage` | 新增员工 | | `PATCH` | `/api/employees/:id` | 是 | `employee:manage` | 修改员工 | | `PATCH` | `/api/employees/:id/status` | 是 | `employee:manage` | 修改员工状态 | -| `DELETE` | `/api/employees/:id` | 是 | `employee:manage` | 删除员工 | +| `PATCH` | `/api/employees/:id/password` | 是 | `employee:manage` | 修改员工密码 | +| `PATCH` | `/api/employees/:id/password/reset` | 是 | `employee:manage` | 重置员工为初始密码 | +| `DELETE` | `/api/employees/:id` | 是 | `employee:manage` | 软删除员工 | ## 健康检查 @@ -326,6 +339,8 @@ interface PermissionMenu { - 超级管理员使用 `super_admins.username` 登录。 - 员工使用手机号登录。 +- 登录会先校验密码;密码正确但账号停用时返回 `401 UNAUTHORIZED`,消息为 `账号已被禁用`。 +- 员工所属门店停用时返回 `401 UNAUTHORIZED`,消息为 `所属门店已被禁用`。 - 员工必须拥有后台菜单权限,否则返回 `401 UNAUTHORIZED`,消息为 `当前账号没有后台登录权限`。 - `cashier`、`kitchen`、`part_time` 默认没有后台登录权限。 @@ -336,6 +351,7 @@ interface PermissionMenu { ### POST /api/auth/employee/login 员工端登录入口。员工使用手机号和密码登录,不要求后台管理权限。 +密码正确但员工账号停用时返回 `账号已被禁用`;所属门店停用时返回 `所属门店已被禁用`。 请求体: @@ -555,7 +571,7 @@ Authorization: Bearer 更新指定角色拥有的权限点。需要 `permission:manage`。 -后端只接受 `GET /api/permissions/definitions` 返回的权限码。保存时会自动补齐依赖权限,例如提交 `permission:manage` 会自动保留 `permission:view`。 +后端只接受 `GET /api/permissions/definitions` 返回的权限码。保存时会自动补齐依赖权限,例如提交 `permission:manage` 会自动保留 `permission:view`。本次保存中被移除的权限关系会写入 `role_permissions.deleted_at`,不会物理删除关系行。 请求: @@ -694,7 +710,24 @@ Authorization: Bearer | --- | --- | --- | | `id` | `number` | 正整数 | -响应 `data` 为 `Store`。 +响应 `data` 为门店详情,结构是在 `Store` 基础上增加 `employees: Employee[]`。 + +门店停用后,员工仍保留在该门店下。员工对象会返回: + +- `storeStatus: "INACTIVE"` +- `statusTags` 中包含 `{ "code": "STORE_INACTIVE", "label": "门店被禁用", "variant": "warning" }` + +### GET /api/stores/:id/employees + +查询某个门店下的员工。需要 `store:view`。 + +路径参数: + +| 参数 | 类型 | 约束 | +| --- | --- | --- | +| `id` | `number` | 正整数 | + +响应 `data` 为 `Employee[]`。员工对象会包含 `storeStatus` 和 `statusTags`,供前端直接展示员工状态和门店禁用标签。 ### POST /api/stores @@ -760,7 +793,33 @@ Authorization: Bearer - `404 NOT_FOUND`:门店不存在。 - `409 CONFLICT`:门店名称已存在。 -- `409 CONFLICT`:门店下还有员工,不能停用。 + +业务规则: + +- 门店下还有员工时,允许把门店状态改为 `INACTIVE`。 +- 停用门店后,该门店员工仍可在员工列表和门店详情中查看,并通过 `statusTags` 标识“门店被禁用”。 + +### DELETE /api/stores/:storeId/employees/:employeeId + +从门店详情中移除员工。需要 `employee:manage`。 + +路径参数: + +| 参数 | 类型 | 约束 | +| --- | --- | --- | +| `storeId` | `number` | 正整数 | +| `employeeId` | `number` | 正整数 | + +成功响应:`204 No Content`。 + +业务规则: + +- 只有员工属于该门店时才会移除。 +- 移除沿用员工软删除规则:员工状态会变为 `INACTIVE`,`deleted_at` 写入删除时间,手机号唯一约束释放。 + +可能的业务错误: + +- `404 NOT_FOUND`:门店员工不存在。 ### DELETE /api/stores/:id @@ -889,10 +948,17 @@ Authorization: Bearer ### DELETE /api/roles/:id -删除自定义角色。需要 `role:manage`。 +软删除自定义角色。需要 `role:manage`。 成功响应:`204 No Content`。 +删除后: + +- `roles.deleted_at` 会写入删除时间。 +- 角色不会再出现在角色列表、角色下拉选项和权限策略中。 +- 该角色编码的唯一约束会释放,之后可以重新创建同编码角色。 +- 员工角色关系不会被物理删除,解绑和重新绑定通过 `employee_roles.deleted_at` 记录当前关系状态。 + 可能的业务错误: - `404 NOT_FOUND`:角色不存在。 @@ -938,9 +1004,17 @@ Authorization: Bearer "id": 1, "storeId": 1, "storeName": "示例门店", + "storeStatus": "ACTIVE", "name": "张三", "phone": "13800000001", "status": "ACTIVE", + "statusTags": [ + { + "code": "EMPLOYEE_ACTIVE", + "label": "员工启用", + "variant": "success" + } + ], "remark": null, "roles": [ { @@ -963,6 +1037,8 @@ Authorization: Bearer } ``` +如果员工所属门店已停用,`storeStatus` 会返回 `"INACTIVE"`,且 `statusTags` 会同时包含员工自身状态标签和“门店被禁用”标签。 + ### GET /api/employees/:id 查询员工详情。需要员工查看权限。 @@ -1019,13 +1095,13 @@ Authorization: Bearer - `storeId` 必须对应启用且未删除门店。 - `phone` 在未删除员工范围内全局唯一。 -- `roleIds` 中的角色必须存在;自定义角色可先通过角色接口创建,再通过权限接口分配权限。 +- `roleIds` 中的角色必须存在且未软删除;自定义角色可先通过角色接口创建,再通过权限接口分配权限。 - 新员工默认密码为 `pw111111`。 可能的业务错误: - `400 BAD_REQUEST`:门店不存在或已停用。 -- `400 BAD_REQUEST`:提交的角色包含不存在的角色。 +- `400 BAD_REQUEST`:提交的角色包含不存在或已删除的角色。 - `409 CONFLICT`:员工手机号已存在。 ### PATCH /api/employees/:id @@ -1036,12 +1112,12 @@ Authorization: Bearer | 字段 | 类型 | 约束 | 说明 | | --- | --- | --- | --- | -| `storeId` | `number` | 正整数 | 新门店必须启用且未删除 | +| `storeId` | `number` | 正整数 | 更换门店时,新门店必须启用且未删除 | | `name` | `string` | trim 后 1-50 字符 | 员工姓名 | | `phone` | `string` | 中国大陆手机号,`/^1[3-9]\d{9}$/` | 员工手机号,未删除员工范围内全局唯一 | | `status` | `"ACTIVE" \| "INACTIVE"` | 枚举 | 员工状态 | | `remark` | `string \| null` | trim 后最多 500 字符 | 备注 | -| `roleIds` | `number[]` | 正整数数组,最多 20 个 | 不传表示不修改角色,传空数组表示清空角色 | +| `roleIds` | `number[]` | 正整数数组,最多 20 个 | 不传表示不修改角色,传空数组表示清空角色;解绑会写入 `employee_roles.deleted_at` | 请求示例: @@ -1057,8 +1133,8 @@ Authorization: Bearer 可能的业务错误: - `404 NOT_FOUND`:员工不存在。 -- `400 BAD_REQUEST`:门店不存在或已停用。 -- `400 BAD_REQUEST`:提交的角色包含不存在的角色。 +- `400 BAD_REQUEST`:更换后的门店不存在或已停用。 +- `400 BAD_REQUEST`:提交的角色包含不存在或已删除的角色。 - `409 CONFLICT`:员工手机号已存在。 ### PATCH /api/employees/:id/status @@ -1081,6 +1157,55 @@ Authorization: Bearer 响应 `data` 为 `Employee`。 +### PATCH /api/employees/:id/password + +修改员工密码。需要 `employee:manage`。 + +路径参数: + +| 参数 | 类型 | 约束 | +| --- | --- | --- | +| `id` | `number` | 正整数 | + +请求体: + +| 字段 | 类型 | 必填 | 约束 | +| --- | --- | --- | --- | +| `oldPassword` | `string` | 是 | 8-128 字符 | +| `newPassword` | `string` | 是 | 8-128 字符 | + +请求示例: + +```json +{ + "oldPassword": "pw111111", + "newPassword": "NewPw111111" +} +``` + +后端会先校验旧密码,旧密码不正确时不会写入新密码。 + +响应 `data` 为 `Employee`,不会返回密码或密码哈希。 + +可能的业务错误: + +- `404 NOT_FOUND`:员工不存在。 +- `400 BAD_REQUEST`:旧密码不正确。 + +### PATCH /api/employees/:id/password/reset + +重置员工密码为初始密码 `pw111111`。需要 `employee:manage`。 + +路径参数: + +| 参数 | 类型 | 约束 | +| --- | --- | --- | +| `id` | `number` | 正整数 | + +请求体:不需要请求体。 + +响应 `data` 为 `Employee`,不会返回密码或密码哈希。 + ### DELETE /api/employees/:id 软删除员工。需要 `employee:manage`。 diff --git a/migrations/007_add_soft_delete_to_roles_and_relations.sql b/migrations/007_add_soft_delete_to_roles_and_relations.sql new file mode 100644 index 0000000..1b7b36c --- /dev/null +++ b/migrations/007_add_soft_delete_to_roles_and_relations.sql @@ -0,0 +1,35 @@ +-- 007_add_soft_delete_to_roles_and_relations.sql +-- 统一删除语义:角色、员工角色关系、角色权限关系都使用逻辑删除。 + +ALTER TABLE roles + ADD COLUMN deleted_at DATETIME(3) NULL COMMENT '软删除时间,NULL 表示未删除' AFTER updated_at, + ADD KEY idx_roles_deleted_at (deleted_at); + +ALTER TABLE roles + DROP INDEX uk_roles_code, + ADD COLUMN active_code VARCHAR(50) GENERATED ALWAYS AS ( + CASE WHEN deleted_at IS NULL THEN code ELSE NULL END + ) STORED COMMENT '仅用于保证未删除角色编码唯一' AFTER code, + ADD UNIQUE KEY uk_roles_active_code (active_code); + +ALTER TABLE employee_roles + DROP FOREIGN KEY fk_employee_roles_employee_id; + +ALTER TABLE employee_roles + ADD CONSTRAINT fk_employee_roles_employee_id + FOREIGN KEY (employee_id) REFERENCES employees (id); + +ALTER TABLE employee_roles + ADD COLUMN deleted_at DATETIME(3) NULL COMMENT '逻辑解绑时间,NULL 表示当前仍绑定' AFTER created_at, + ADD KEY idx_employee_roles_deleted_at (deleted_at); + +ALTER TABLE role_permissions + DROP FOREIGN KEY fk_role_permissions_role_id; + +ALTER TABLE role_permissions + ADD CONSTRAINT fk_role_permissions_role_id + FOREIGN KEY (role_id) REFERENCES roles (id); + +ALTER TABLE role_permissions + ADD COLUMN deleted_at DATETIME(3) NULL COMMENT '逻辑解绑时间,NULL 表示当前仍授权' AFTER created_at, + ADD KEY idx_role_permissions_deleted_at (deleted_at); diff --git a/src/modules/auth/auth.repository.ts b/src/modules/auth/auth.repository.ts index d672ed5..1c3be00 100644 --- a/src/modules/auth/auth.repository.ts +++ b/src/modules/auth/auth.repository.ts @@ -22,8 +22,10 @@ interface EmployeeLoginRow extends RowDataPacket { username: string; password_hash: string; display_name: string; + status: "ACTIVE" | "INACTIVE"; store_id: number; store_name: string; + store_status: "ACTIVE" | "INACTIVE"; } interface EmployeeRoleRow extends RowDataPacket { @@ -67,6 +69,22 @@ export const authRepository = { return rows[0] ? toSuperAdmin(rows[0]) : null; }, + async findByUsername( + username: string, + ): Promise { + const [rows] = await pool.execute( + ` + SELECT id, username, password_hash, display_name, status, last_login_at, created_at, updated_at + FROM super_admins + WHERE username = ? + LIMIT 1 + `, + [username], + ); + + return rows[0] ? toSuperAdmin(rows[0]) : null; + }, + async findActiveById(id: number): Promise { const [rows] = await pool.execute( ` @@ -102,8 +120,10 @@ export const authRepository = { e.phone AS username, e.password_hash, e.name AS display_name, + e.status, e.store_id, - s.name AS store_name + s.name AS store_name, + s.status AS store_status FROM employees e INNER JOIN stores s ON s.id = e.store_id WHERE e.phone = ? @@ -127,6 +147,41 @@ export const authRepository = { ); }, + async findEmployeeByPhoneForLogin( + phone: string, + ): Promise { + const [rows] = await pool.execute( + ` + SELECT + e.id, + e.phone AS username, + e.password_hash, + e.name AS display_name, + e.status, + e.store_id, + s.name AS store_name, + s.status AS store_status + FROM employees e + INNER JOIN stores s ON s.id = e.store_id + WHERE e.phone = ? + AND e.deleted_at IS NULL + AND s.deleted_at IS NULL + LIMIT 1 + `, + [phone], + ); + + if (!rows[0]) { + return null; + } + + const rolesByEmployeeId = await this.findRolesByEmployeeIds([rows[0].id]); + return toEmployeeLoginAccount( + rows[0], + rolesByEmployeeId.get(rows[0].id) ?? [], + ); + }, + async findActiveEmployeeById( id: number, ): Promise { @@ -137,8 +192,10 @@ export const authRepository = { e.phone AS username, e.password_hash, e.name AS display_name, + e.status, e.store_id, - s.name AS store_name + s.name AS store_name, + s.status AS store_status FROM employees e INNER JOIN stores s ON s.id = e.store_id WHERE e.id = ? @@ -189,6 +246,8 @@ export const authRepository = { FROM employee_roles er INNER JOIN roles r ON r.id = er.role_id WHERE er.employee_id IN (${placeholders}) + AND er.deleted_at IS NULL + AND r.deleted_at IS NULL ORDER BY r.id ASC `, employeeIds, @@ -217,8 +276,10 @@ function toEmployeeLoginAccount( username: row.username, passwordHash: row.password_hash, displayName: row.display_name, + status: row.status, storeId: row.store_id, storeName: row.store_name, + storeStatus: row.store_status, roles, }; } diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 4afe08e..742607b 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -83,7 +83,7 @@ export const authService = { user: AuthUser; payload: AuthJwtPayload; }> { - const admin = await authRepository.findActiveByUsername(input.username); + const admin = await authRepository.findByUsername(input.username); if (admin) { const passwordMatched = await verifyPassword( @@ -95,6 +95,10 @@ export const authService = { throw unauthorized("用户名或密码错误"); } + if (admin.status === "INACTIVE") { + throw unauthorized("账号已被禁用"); + } + await authRepository.updateLastLoginAt(admin.id); const user = await toAuthUser(admin); @@ -105,7 +109,7 @@ export const authService = { }; } - const employee = await authRepository.findActiveEmployeeByPhone( + const employee = await authRepository.findEmployeeByPhoneForLogin( input.username, ); @@ -122,6 +126,14 @@ export const authService = { throw unauthorized("用户名或密码错误"); } + if (employee.status === "INACTIVE") { + throw unauthorized("账号已被禁用"); + } + + if (employee.storeStatus === "INACTIVE") { + throw unauthorized("所属门店已被禁用"); + } + await authRepository.updateEmployeeLastLoginAt(employee.id); const user = await toEmployeeAuthUser(employee); @@ -140,7 +152,7 @@ export const authService = { user: AuthUser; payload: AuthJwtPayload; }> { - const employee = await authRepository.findActiveEmployeeByPhone( + const employee = await authRepository.findEmployeeByPhoneForLogin( input.username, ); @@ -157,6 +169,14 @@ export const authService = { throw unauthorized("用户名或密码错误"); } + if (employee.status === "INACTIVE") { + throw unauthorized("账号已被禁用"); + } + + if (employee.storeStatus === "INACTIVE") { + throw unauthorized("所属门店已被禁用"); + } + await authRepository.updateEmployeeLastLoginAt(employee.id); const user = await toEmployeeAuthUser(employee); diff --git a/src/modules/auth/auth.types.ts b/src/modules/auth/auth.types.ts index 126659b..a280785 100644 --- a/src/modules/auth/auth.types.ts +++ b/src/modules/auth/auth.types.ts @@ -28,8 +28,10 @@ export interface EmployeeLoginAccount { username: string; passwordHash: string; displayName: string; + status: "ACTIVE" | "INACTIVE"; storeId: number; storeName: string; + storeStatus: "ACTIVE" | "INACTIVE"; roles: Array<{ id: number; code: string; diff --git a/src/modules/auth/password.ts b/src/modules/auth/password.ts index a86042e..21a89d1 100644 --- a/src/modules/auth/password.ts +++ b/src/modules/auth/password.ts @@ -6,6 +6,9 @@ const HASH_SCHEME = "pbkdf2"; const HASH_ALGORITHM = "sha256"; const ITERATIONS = 310000; const KEY_LENGTH = 32; +export const INITIAL_EMPLOYEE_PASSWORD = "pw111111"; +export const INITIAL_EMPLOYEE_PASSWORD_HASH = + "pbkdf2$sha256$310000$QnXyjrpm0QzcGYLEPdunWg$CfR-CywGl1c_Omh_3PyOWPmo93EcbMY1FEjjd5MDjFo"; function toBase64Url(buffer: Buffer): string { return buffer.toString("base64url"); diff --git a/src/modules/catalog/catalog.controller.ts b/src/modules/catalog/catalog.controller.ts index a2a9214..361fae2 100644 --- a/src/modules/catalog/catalog.controller.ts +++ b/src/modules/catalog/catalog.controller.ts @@ -1,6 +1,7 @@ import type { FastifyInstance } from "fastify"; import { created, ok, paginated } from "../../shared/response"; import { permissionGuard } from "../auth/auth.guard"; +import { employeeService } from "../employees/employee.service"; import { PERMISSIONS } from "../permissions/permission.policy"; import { catalogService } from "./catalog.service"; import { @@ -9,6 +10,7 @@ import { idParamSchema, listRolesQuerySchema, listStoresQuerySchema, + storeEmployeeParamSchema, updateRoleBodySchema, updateStoreBodySchema, } from "./catalog.schema"; @@ -58,9 +60,17 @@ export async function catalogRoutes(app: FastifyInstance): Promise { return ok(stores); }); + app.get("/stores/:id/employees", { preHandler: permissionGuard(PERMISSIONS.STORE_VIEW) }, async (request) => { + const params = idParamSchema.parse(request.params); + await catalogService.getStoreById(params.id); + const employees = await employeeService.listByStoreId(params.id); + + return ok(employees); + }); + app.get("/stores/:id", { preHandler: permissionGuard(PERMISSIONS.STORE_VIEW) }, async (request) => { const params = idParamSchema.parse(request.params); - const store = await catalogService.getStoreById(params.id); + const store = await catalogService.getStoreDetailById(params.id); return ok(store); }); @@ -80,6 +90,17 @@ export async function catalogRoutes(app: FastifyInstance): Promise { return ok(store); }); + app.delete( + "/stores/:storeId/employees/:employeeId", + { preHandler: permissionGuard(PERMISSIONS.EMPLOYEE_MANAGE) }, + async (request, reply) => { + const params = storeEmployeeParamSchema.parse(request.params); + await employeeService.removeFromStore(params.storeId, params.employeeId); + + return reply.code(204).send(); + } + ); + app.delete("/stores/:id", { preHandler: permissionGuard(PERMISSIONS.STORE_MANAGE) }, async (request, reply) => { const params = idParamSchema.parse(request.params); await catalogService.deleteStore(params.id); diff --git a/src/modules/catalog/catalog.repository.ts b/src/modules/catalog/catalog.repository.ts index cb1ce29..7eadc19 100644 --- a/src/modules/catalog/catalog.repository.ts +++ b/src/modules/catalog/catalog.repository.ts @@ -136,7 +136,7 @@ function buildRoleListWhere(query: ListRolesQuery): { whereSql: string; params: SqlParam[]; } { - const where: string[] = []; + const where: string[] = ["deleted_at IS NULL"]; const params: SqlParam[] = []; if (query.isSystem !== undefined) { @@ -154,7 +154,7 @@ function buildRoleListWhere(query: ListRolesQuery): { } return { - whereSql: where.length > 0 ? where.join(" AND ") : "1 = 1", + whereSql: where.join(" AND "), params, }; } @@ -342,6 +342,7 @@ export const catalogRepository = { ` SELECT id, code, name, description, is_system, created_at, updated_at FROM roles + WHERE deleted_at IS NULL ORDER BY id ASC `, ); @@ -386,6 +387,7 @@ export const catalogRepository = { ` SELECT id, code, name, description, is_system, created_at, updated_at FROM roles + WHERE deleted_at IS NULL ORDER BY id ASC `, ); @@ -399,6 +401,7 @@ export const catalogRepository = { SELECT id, code, name, description, is_system, created_at, updated_at FROM roles WHERE id = ? + AND deleted_at IS NULL LIMIT 1 `, [id], @@ -425,6 +428,7 @@ export const catalogRepository = { SELECT id, code, name, description, is_system, created_at, updated_at FROM roles WHERE code = ? + AND deleted_at IS NULL ${excludeSql} LIMIT 1 `, @@ -474,22 +478,32 @@ export const catalogRepository = { ` UPDATE roles SET ${sets.join(", ")} - WHERE id = ? + WHERE id = ? AND deleted_at IS NULL `, params, ); }, async deleteRole(id: number): Promise { - await pool.execute("DELETE FROM roles WHERE id = ?", [id]); + await pool.execute( + ` + UPDATE roles + SET deleted_at = CURRENT_TIMESTAMP(3) + WHERE id = ? AND deleted_at IS NULL + `, + [id], + ); }, async countEmployeesByRole(roleId: number): Promise { const [rows] = await pool.execute( ` SELECT COUNT(*) AS total - FROM employee_roles - WHERE role_id = ? + FROM employee_roles er + INNER JOIN employees e ON e.id = er.employee_id + WHERE er.role_id = ? + AND er.deleted_at IS NULL + AND e.deleted_at IS NULL `, [roleId], ); diff --git a/src/modules/catalog/catalog.schema.ts b/src/modules/catalog/catalog.schema.ts index 218361b..a9ea529 100644 --- a/src/modules/catalog/catalog.schema.ts +++ b/src/modules/catalog/catalog.schema.ts @@ -34,6 +34,11 @@ export const idParamSchema = z.object({ id: z.coerce.number().int().positive(), }); +export const storeEmployeeParamSchema = z.object({ + storeId: z.coerce.number().int().positive(), + employeeId: z.coerce.number().int().positive(), +}); + export const listStoresQuerySchema = z.object({ includeInactive: z.preprocess( (value) => stringToBoolean(emptyStringToUndefined(value)), diff --git a/src/modules/catalog/catalog.service.ts b/src/modules/catalog/catalog.service.ts index 8c202bc..4e2923b 100644 --- a/src/modules/catalog/catalog.service.ts +++ b/src/modules/catalog/catalog.service.ts @@ -1,4 +1,5 @@ import { conflict, notFound } from "../../shared/http-error"; +import { employeeService } from "../employees/employee.service"; import { catalogRepository } from "./catalog.repository"; import type { CreateRoleInput, @@ -8,6 +9,7 @@ import type { Role, RoleOption, Store, + StoreDetail, StoreOption, UpdateRoleInput, UpdateStoreInput, @@ -40,6 +42,16 @@ export const catalogService = { return store; }, + async getStoreDetailById(id: number): Promise { + const store = await this.getStoreById(id); + const employees = await employeeService.listByStoreId(id); + + return { + ...store, + employees, + }; + }, + async createStore(input: CreateStoreInput): Promise { // 门店名称在“未删除门店”范围内保持唯一,避免管理后台出现两个同名门店。 const duplicatedStore = await catalogRepository.findActiveStoreByName( @@ -56,7 +68,7 @@ export const catalogService = { }, async updateStore(id: number, input: UpdateStoreInput): Promise { - const currentStore = await this.getStoreById(id); + await this.getStoreById(id); if (input.name !== undefined) { const duplicatedStore = await catalogRepository.findActiveStoreByName( @@ -69,15 +81,6 @@ export const catalogService = { } } - // 如果门店下还有员工,停用门店会让员工数据失去可用归属,所以这里先阻止。 - if (currentStore.status === "ACTIVE" && input.status === "INACTIVE") { - const employeeCount = await catalogRepository.countEmployeesByStore(id); - - if (employeeCount > 0) { - throw conflict("门店下还有员工,不能停用"); - } - } - await catalogRepository.updateStore(id, input); return this.getStoreById(id); }, diff --git a/src/modules/catalog/catalog.types.ts b/src/modules/catalog/catalog.types.ts index 550fe46..4269ddc 100644 --- a/src/modules/catalog/catalog.types.ts +++ b/src/modules/catalog/catalog.types.ts @@ -1,3 +1,5 @@ +import type { Employee } from "../employees/employee.types"; + export const STORE_STATUS = ["ACTIVE", "INACTIVE"] as const; export const FIXED_ROLE_DEFINITIONS = [ @@ -57,6 +59,10 @@ export interface Store extends StoreOption { updatedAt: string; } +export interface StoreDetail extends Store { + employees: Employee[]; +} + export interface Role extends RoleOption { createdAt: string; updatedAt: string; diff --git a/src/modules/employees/employee.controller.ts b/src/modules/employees/employee.controller.ts index 7e7b2ab..59ff176 100644 --- a/src/modules/employees/employee.controller.ts +++ b/src/modules/employees/employee.controller.ts @@ -13,6 +13,7 @@ import { idParamSchema, listEmployeesQuerySchema, updateEmployeeBodySchema, + updateEmployeePasswordBodySchema, updateEmployeeStatusBodySchema } from "./employee.schema"; import type { AuthUser } from "../auth/auth.types"; @@ -123,6 +124,27 @@ export async function employeeRoutes(app: FastifyInstance): Promise { return ok(employee); }); + app.patch("/employees/:id/password", async (request) => { + const user = await authService.getCurrentUser(request.user); + assertCanManageEmployees(user); + + const params = idParamSchema.parse(request.params); + const body = updateEmployeePasswordBodySchema.parse(request.body); + const employee = await employeeService.updatePassword(params.id, body); + + return ok(employee); + }); + + app.patch("/employees/:id/password/reset", async (request) => { + const user = await authService.getCurrentUser(request.user); + assertCanManageEmployees(user); + + const params = idParamSchema.parse(request.params); + const employee = await employeeService.resetPassword(params.id); + + return ok(employee); + }); + app.delete("/employees/:id", async (request, reply) => { const user = await authService.getCurrentUser(request.user); assertCanManageEmployees(user); diff --git a/src/modules/employees/employee.repository.ts b/src/modules/employees/employee.repository.ts index 739bc38..581f248 100644 --- a/src/modules/employees/employee.repository.ts +++ b/src/modules/employees/employee.repository.ts @@ -1,9 +1,12 @@ import type { PoolConnection, ResultSetHeader, RowDataPacket } from "mysql2/promise"; import { pool } from "../../db/pool"; +import { INITIAL_EMPLOYEE_PASSWORD_HASH } from "../auth/password"; import type { CreateEmployeeInput, Employee, + EmployeeStatusTag, EmployeeStatus, + EmployeeStoreStatus, ListEmployeesQuery, RoleSummary, UpdateEmployeeInput @@ -11,13 +14,12 @@ import type { type DbExecutor = typeof pool | PoolConnection; type SqlParam = string | number | boolean | Date | null; -const DEFAULT_EMPLOYEE_PASSWORD_HASH = - "pbkdf2$sha256$310000$QnXyjrpm0QzcGYLEPdunWg$CfR-CywGl1c_Omh_3PyOWPmo93EcbMY1FEjjd5MDjFo"; interface EmployeeRow extends RowDataPacket { id: number; store_id: number; store_name: string; + store_status: EmployeeStoreStatus; name: string; phone: string; status: EmployeeStatus; @@ -37,20 +39,52 @@ interface CountRow extends RowDataPacket { total: number; } +interface EmployeePasswordRow extends RowDataPacket { + password_hash: string; +} + // 数据库层统一把 Date 转成字符串,避免响应里混入运行时对象。 function toIso(value: Date): string { return value.toISOString(); } +function buildStatusTags(row: Pick): EmployeeStatusTag[] { + const tags: EmployeeStatusTag[] = [ + row.status === "ACTIVE" + ? { + code: "EMPLOYEE_ACTIVE", + label: "员工启用", + variant: "success", + } + : { + code: "EMPLOYEE_INACTIVE", + label: "员工停用", + variant: "default", + }, + ]; + + if (row.store_status === "INACTIVE") { + tags.push({ + code: "STORE_INACTIVE", + label: "门店被禁用", + variant: "warning", + }); + } + + return tags; +} + // 把 JOIN 查询出来的数据库行转换成接口返回结构。 function toEmployee(row: EmployeeRow, roles: RoleSummary[] = []): Employee { return { id: row.id, storeId: row.store_id, storeName: row.store_name, + storeStatus: row.store_status, name: row.name, phone: row.phone, status: row.status, + statusTags: buildStatusTags(row), remark: row.remark, roles, createdAt: toIso(row.created_at), @@ -103,7 +137,7 @@ export const employeeRepository = { }, async storeExists(storeId: number, db: DbExecutor = pool): Promise { - // 员工只能绑定到启用且未删除的门店。 + // 新建或调整员工归属时,只允许选择启用且未删除的门店。 const [rows] = await db.execute( "SELECT COUNT(*) AS total FROM stores WHERE id = ? AND status = 'ACTIVE' AND deleted_at IS NULL", [storeId] @@ -124,6 +158,7 @@ export const employeeRepository = { SELECT id FROM roles WHERE id IN (${placeholders}) + AND deleted_at IS NULL `, roleIds ); @@ -147,7 +182,7 @@ export const employeeRepository = { const [rows] = await db.execute( ` - SELECT e.*, s.name AS store_name + SELECT e.*, s.name AS store_name, s.status AS store_status FROM employees e INNER JOIN stores s ON s.id = e.store_id WHERE e.phone = ? @@ -179,7 +214,7 @@ export const employeeRepository = { // LIMIT/OFFSET 已经过 zod 校验成受控正整数;这里直接拼接数字,避免部分 MySQL 驱动对分页占位符的兼容问题。 const [rows] = await pool.execute( ` - SELECT e.*, s.name AS store_name + SELECT e.*, s.name AS store_name, s.status AS store_status FROM employees e INNER JOIN stores s ON s.id = e.store_id WHERE ${whereSql} @@ -198,10 +233,26 @@ export const employeeRepository = { }; }, + async listByStoreId(storeId: number, db: DbExecutor = pool): Promise { + const [rows] = await db.execute( + ` + SELECT e.*, s.name AS store_name, s.status AS store_status + FROM employees e + INNER JOIN stores s ON s.id = e.store_id + WHERE e.store_id = ? AND e.deleted_at IS NULL + ORDER BY e.id DESC + `, + [storeId] + ); + + const rolesByEmployeeId = await this.findRolesByEmployeeIds(rows.map((row) => row.id), db); + return rows.map((row) => toEmployee(row, rolesByEmployeeId.get(row.id) ?? [])); + }, + async findById(id: number, db: DbExecutor = pool): Promise { const [rows] = await db.execute( ` - SELECT e.*, s.name AS store_name + SELECT e.*, s.name AS store_name, s.status AS store_status FROM employees e INNER JOIN stores s ON s.id = e.store_id WHERE e.id = ? AND e.deleted_at IS NULL @@ -228,7 +279,7 @@ export const employeeRepository = { input.storeId, input.name, input.phone, - DEFAULT_EMPLOYEE_PASSWORD_HASH, + INITIAL_EMPLOYEE_PASSWORD_HASH, input.status, input.remark ?? null ] @@ -273,6 +324,31 @@ export const employeeRepository = { ); }, + async updatePasswordHash(id: number, passwordHash: string, db: DbExecutor = pool): Promise { + await db.execute( + ` + UPDATE employees + SET password_hash = ? + WHERE id = ? AND deleted_at IS NULL + `, + [passwordHash, id] + ); + }, + + async findPasswordHashById(id: number, db: DbExecutor = pool): Promise { + const [rows] = await db.execute( + ` + SELECT password_hash + FROM employees + WHERE id = ? AND deleted_at IS NULL + LIMIT 1 + `, + [id] + ); + + return rows[0]?.password_hash ?? null; + }, + async softDelete(id: number): Promise { // 员工删除采用软删除:保留历史记录,同时将状态置为 INACTIVE。 await pool.execute( @@ -286,8 +362,15 @@ export const employeeRepository = { }, async replaceRoles(employeeId: number, roleIds: number[], db: DbExecutor = pool): Promise { - // 简化多对多更新:先删旧关系,再批量插入新关系。调用方用事务包住它。 - await db.execute("DELETE FROM employee_roles WHERE employee_id = ?", [employeeId]); + // 关系解绑也使用逻辑删除;重新绑定同一角色时通过 upsert 恢复 deleted_at。 + await db.execute( + ` + UPDATE employee_roles + SET deleted_at = CURRENT_TIMESTAMP(3) + WHERE employee_id = ? AND deleted_at IS NULL + `, + [employeeId] + ); if (roleIds.length === 0) { return; @@ -297,6 +380,9 @@ export const employeeRepository = { ` INSERT INTO employee_roles (employee_id, role_id) VALUES ${roleIds.map(() => "(?, ?)").join(", ")} + ON DUPLICATE KEY UPDATE + deleted_at = NULL, + created_at = CURRENT_TIMESTAMP(3) `, roleIds.flatMap((roleId) => [employeeId, roleId]) ); @@ -320,6 +406,8 @@ export const employeeRepository = { FROM employee_roles er INNER JOIN roles r ON r.id = er.role_id WHERE er.employee_id IN (${placeholders}) + AND er.deleted_at IS NULL + AND r.deleted_at IS NULL ORDER BY r.id ASC `, employeeIds diff --git a/src/modules/employees/employee.schema.ts b/src/modules/employees/employee.schema.ts index 469ae23..47b78cb 100644 --- a/src/modules/employees/employee.schema.ts +++ b/src/modules/employees/employee.schema.ts @@ -61,3 +61,8 @@ export const updateEmployeeBodySchema = z export const updateEmployeeStatusBodySchema = z.object({ status: z.enum(EMPLOYEE_STATUS) }); + +export const updateEmployeePasswordBodySchema = z.object({ + oldPassword: z.string().min(8).max(128), + newPassword: z.string().min(8).max(128) +}); diff --git a/src/modules/employees/employee.service.ts b/src/modules/employees/employee.service.ts index 32690f2..0ad6cdb 100644 --- a/src/modules/employees/employee.service.ts +++ b/src/modules/employees/employee.service.ts @@ -1,13 +1,20 @@ import { badRequest, conflict, notFound } from "../../shared/http-error"; +import { hashPassword, INITIAL_EMPLOYEE_PASSWORD_HASH, verifyPassword } from "../auth/password"; import { employeeRepository } from "./employee.repository"; -import type { CreateEmployeeInput, Employee, ListEmployeesQuery, UpdateEmployeeInput } from "./employee.types"; +import type { + CreateEmployeeInput, + Employee, + ListEmployeesQuery, + UpdateEmployeeInput, + UpdateEmployeePasswordInput +} from "./employee.types"; // 角色 id 可能从前端重复提交;进入数据库前先去重,减少无意义 SQL 和主键冲突。 function uniqueIds(ids: number[]): number[] { return [...new Set(ids)]; } -// 创建或修改员工时,必须保证门店存在且处于启用状态。 +// 创建员工或更换员工门店时,必须保证目标门店存在且处于启用状态。 async function assertStoreExists(storeId: number): Promise { const exists = await employeeRepository.storeExists(storeId); @@ -22,7 +29,7 @@ async function assertRolesExist(roleIds: number[]): Promise { const existingRoleIds = await employeeRepository.existingRoleIds(dedupedRoleIds); if (existingRoleIds.length !== dedupedRoleIds.length) { - throw badRequest("提交的角色包含不存在的角色"); + throw badRequest("提交的角色包含不存在或已删除的角色"); } return dedupedRoleIds; @@ -34,6 +41,10 @@ export const employeeService = { return employeeRepository.list(query); }, + async listByStoreId(storeId: number): Promise { + return employeeRepository.listByStoreId(storeId); + }, + async getById(id: number): Promise { const employee = await employeeRepository.findById(id); @@ -68,7 +79,7 @@ export const employeeService = { async update(id: number, input: UpdateEmployeeInput): Promise { const currentEmployee = await this.getById(id); - if (input.storeId !== undefined) { + if (input.storeId !== undefined && input.storeId !== currentEmployee.storeId) { await assertStoreExists(input.storeId); } @@ -106,6 +117,40 @@ export const employeeService = { return this.getById(id); }, + async updatePassword(id: number, input: UpdateEmployeePasswordInput): Promise { + const currentPasswordHash = await employeeRepository.findPasswordHashById(id); + + if (!currentPasswordHash) { + throw notFound("员工不存在"); + } + + const passwordMatched = await verifyPassword(input.oldPassword, currentPasswordHash); + + if (!passwordMatched) { + throw badRequest("旧密码不正确"); + } + + const passwordHash = await hashPassword(input.newPassword); + await employeeRepository.updatePasswordHash(id, passwordHash); + return this.getById(id); + }, + + async resetPassword(id: number): Promise { + await this.getById(id); + await employeeRepository.updatePasswordHash(id, INITIAL_EMPLOYEE_PASSWORD_HASH); + return this.getById(id); + }, + + async removeFromStore(storeId: number, employeeId: number): Promise { + const employee = await this.getById(employeeId); + + if (employee.storeId !== storeId) { + throw notFound("门店员工不存在"); + } + + await employeeRepository.softDelete(employeeId); + }, + async delete(id: number): Promise { await this.getById(id); // 软删除保留历史数据,也能配合 active_phone 释放手机号唯一约束。 diff --git a/src/modules/employees/employee.types.ts b/src/modules/employees/employee.types.ts index f94c4cc..895743a 100644 --- a/src/modules/employees/employee.types.ts +++ b/src/modules/employees/employee.types.ts @@ -2,6 +2,12 @@ export const EMPLOYEE_STATUS = ["ACTIVE", "INACTIVE"] as const; // 从状态常量推导类型,确保 schema 校验和 TypeScript 类型保持一致。 export type EmployeeStatus = (typeof EMPLOYEE_STATUS)[number]; +export type EmployeeStoreStatus = "ACTIVE" | "INACTIVE"; +export type EmployeeStatusTagCode = + | "EMPLOYEE_ACTIVE" + | "EMPLOYEE_INACTIVE" + | "STORE_INACTIVE"; +export type EmployeeStatusTagVariant = "success" | "warning" | "default"; // 员工详情里只需要角色摘要,完整角色管理由 catalog 模块负责。 export interface RoleSummary { @@ -10,14 +16,22 @@ export interface RoleSummary { name: string; } +export interface EmployeeStatusTag { + code: EmployeeStatusTagCode; + label: string; + variant: EmployeeStatusTagVariant; +} + // Employee 是接口返回给前端的员工结构,字段名使用 camelCase。 export interface Employee { id: number; storeId: number; storeName: string; + storeStatus: EmployeeStoreStatus; name: string; phone: string; status: EmployeeStatus; + statusTags: EmployeeStatusTag[]; remark: string | null; roles: RoleSummary[]; createdAt: string; @@ -49,3 +63,8 @@ export interface UpdateEmployeeInput { remark?: string | null; roleIds?: number[]; } + +export interface UpdateEmployeePasswordInput { + oldPassword: string; + newPassword: string; +} diff --git a/src/modules/permissions/permission.policy.ts b/src/modules/permissions/permission.policy.ts index e7b550f..c6b41ed 100644 --- a/src/modules/permissions/permission.policy.ts +++ b/src/modules/permissions/permission.policy.ts @@ -75,14 +75,14 @@ const PERMISSION_DEFINITIONS: PermissionDefinition[] = [ { code: PERMISSIONS.STORE_VIEW, title: "查看门店", - description: "查看门店列表、门店详情和门店下拉选项。", + description: "查看门店列表、门店详情、门店下员工和门店下拉选项。", groupKey: "stores", groupTitle: "门店管理", }, { code: PERMISSIONS.STORE_MANAGE, title: "管理门店", - description: "新增、编辑、停用和删除门店。", + description: "新增、编辑、停用和软删除门店。", groupKey: "stores", groupTitle: "门店管理", }, @@ -96,7 +96,7 @@ const PERMISSION_DEFINITIONS: PermissionDefinition[] = [ { code: PERMISSIONS.ROLE_MANAGE, title: "管理角色", - description: "新增、编辑和删除非系统角色。", + description: "新增、编辑和软删除非系统角色。", groupKey: "roles", groupTitle: "角色管理", }, @@ -117,7 +117,7 @@ const PERMISSION_DEFINITIONS: PermissionDefinition[] = [ { code: PERMISSIONS.EMPLOYEE_MANAGE, title: "管理员工", - description: "新增、编辑、启停和删除员工,并维护员工角色。", + description: "新增、编辑、启停、移除、软删除员工,并维护员工角色和密码。", groupKey: "employees", groupTitle: "员工管理", }, diff --git a/src/modules/permissions/permission.repository.ts b/src/modules/permissions/permission.repository.ts index 17d9e0a..d2a3078 100644 --- a/src/modules/permissions/permission.repository.ts +++ b/src/modules/permissions/permission.repository.ts @@ -82,7 +82,9 @@ export const permissionRepository = { r.is_system, rp.permission_code FROM roles r - LEFT JOIN role_permissions rp ON rp.role_id = r.id + LEFT JOIN role_permissions rp + ON rp.role_id = r.id AND rp.deleted_at IS NULL + WHERE r.deleted_at IS NULL ORDER BY r.id ASC, rp.permission_code ASC `, ); @@ -104,8 +106,10 @@ export const permissionRepository = { r.is_system, rp.permission_code FROM roles r - LEFT JOIN role_permissions rp ON rp.role_id = r.id + LEFT JOIN role_permissions rp + ON rp.role_id = r.id AND rp.deleted_at IS NULL WHERE r.id = ? + AND r.deleted_at IS NULL ORDER BY rp.permission_code ASC `, [roleId], @@ -126,6 +130,8 @@ export const permissionRepository = { FROM role_permissions rp INNER JOIN roles r ON r.id = rp.role_id WHERE r.code IN (${placeholders}) + AND r.deleted_at IS NULL + AND rp.deleted_at IS NULL ORDER BY rp.permission_code ASC `, roleCodes, @@ -139,7 +145,14 @@ export const permissionRepository = { permissionCodes: string[], db: DbExecutor = pool, ): Promise { - await db.execute("DELETE FROM role_permissions WHERE role_id = ?", [roleId]); + await db.execute( + ` + UPDATE role_permissions + SET deleted_at = CURRENT_TIMESTAMP(3) + WHERE role_id = ? AND deleted_at IS NULL + `, + [roleId], + ); if (permissionCodes.length === 0) { return; @@ -149,6 +162,9 @@ export const permissionRepository = { ` INSERT INTO role_permissions (role_id, permission_code) VALUES ${permissionCodes.map(() => "(?, ?)").join(", ")} + ON DUPLICATE KEY UPDATE + deleted_at = NULL, + created_at = CURRENT_TIMESTAMP(3) `, permissionCodes.flatMap((permissionCode) => [roleId, permissionCode]), );