feat: 完善员工门店软删除与密码管理
This commit is contained in:
@@ -16,9 +16,9 @@
|
|||||||
|
|
||||||
## 项目能力
|
## 项目能力
|
||||||
|
|
||||||
- 门店管理:查询、新增、修改、软删除门店。
|
- 门店管理:查询、新增、修改、停用、软删除门店,门店详情可查看员工。
|
||||||
- 角色管理:拥有 `role:manage` 的账号可新增、修改、删除自定义角色,服务端内置角色不可变更。
|
- 角色管理:拥有 `role:manage` 的账号可新增、修改、软删除自定义角色,服务端内置角色不可变更。
|
||||||
- 员工管理:分页查询、新增、修改、启用/停用、软删除员工。
|
- 员工管理:分页查询、新增、修改、启用/停用、修改密码、重置初始密码、移除和软删除员工。
|
||||||
- 员工角色:一个员工可以绑定多个角色。
|
- 员工角色:一个员工可以绑定多个角色。
|
||||||
- 登录账号:超级管理员和员工都可以登录。
|
- 登录账号:超级管理员和员工都可以登录。
|
||||||
- 后台权限:超级管理员拥有所有权限;角色权限由 `role_permissions` 动态分配。
|
- 后台权限:超级管理员拥有所有权限;角色权限由 `role_permissions` 动态分配。
|
||||||
@@ -48,7 +48,8 @@
|
|||||||
│ ├── 003_create_super_admins.sql # 创建超级管理员表和默认账号
|
│ ├── 003_create_super_admins.sql # 创建超级管理员表和默认账号
|
||||||
│ ├── 004_add_employee_login_fields.sql # 给员工补充登录字段
|
│ ├── 004_add_employee_login_fields.sql # 给员工补充登录字段
|
||||||
│ ├── 005_refine_employee_login_and_role_policy.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/
|
├── src/
|
||||||
│ ├── app.ts # 创建 Fastify 应用、注册路由、统一错误处理
|
│ ├── app.ts # 创建 Fastify 应用、注册路由、统一错误处理
|
||||||
│ ├── server.ts # 启动 HTTP 服务和优雅停机
|
│ ├── server.ts # 启动 HTTP 服务和优雅停机
|
||||||
@@ -88,8 +89,8 @@
|
|||||||
| `src/db/migrate.ts` | 执行 `migrations/*.sql`,并用 `schema_migrations` 记录已执行迁移。 |
|
| `src/db/migrate.ts` | 执行 `migrations/*.sql`,并用 `schema_migrations` 记录已执行迁移。 |
|
||||||
| `src/db/pool.ts` | 创建 MySQL 连接池,提供数据库健康检查和关闭连接的方法。 |
|
| `src/db/pool.ts` | 创建 MySQL 连接池,提供数据库健康检查和关闭连接的方法。 |
|
||||||
| `src/modules/auth/` | 登录鉴权模块,负责后台登录、员工端登录、密码校验、JWT 签发、当前用户查询和权限 guard。 |
|
| `src/modules/auth/` | 登录鉴权模块,负责后台登录、员工端登录、密码校验、JWT 签发、当前用户查询和权限 guard。 |
|
||||||
| `src/modules/catalog/` | 门店和角色模块,负责基础资料接口。 |
|
| `src/modules/catalog/` | 门店和角色模块,负责基础资料接口、门店详情员工列表和门店移除员工入口。 |
|
||||||
| `src/modules/employees/` | 员工模块,负责员工分页、详情、新增、修改、状态变更和软删除。 |
|
| `src/modules/employees/` | 员工模块,负责员工分页、详情、新增、修改、状态变更、密码维护和软删除。 |
|
||||||
| `src/modules/permissions/` | 权限模块,维护权限点定义、角色权限分配、当前用户菜单动作权限和权限策略说明。 |
|
| `src/modules/permissions/` | 权限模块,维护权限点定义、角色权限分配、当前用户菜单动作权限和权限策略说明。 |
|
||||||
| `src/shared/` | 跨模块复用的响应结构和业务错误类型。 |
|
| `src/shared/` | 跨模块复用的响应结构和业务错误类型。 |
|
||||||
| `docker-compose.yml` | 本地开发用 MySQL 容器配置。 |
|
| `docker-compose.yml` | 本地开发用 MySQL 容器配置。 |
|
||||||
@@ -314,6 +315,7 @@ curl -X POST http://localhost:3500/api/auth/employee/login \
|
|||||||
|
|
||||||
响应里的 `data.token` 就是后续接口要使用的 JWT。
|
响应里的 `data.token` 就是后续接口要使用的 JWT。
|
||||||
响应里的 `data.user.permissions` 是服务端按角色动态计算出的权限点;菜单和按钮动作以 `/api/permissions/me` 返回结果为准。
|
响应里的 `data.user.permissions` 是服务端按角色动态计算出的权限点;菜单和按钮动作以 `/api/permissions/me` 返回结果为准。
|
||||||
|
登录时会先校验账号和密码;如果密码正确但账号已停用,会返回“账号已被禁用”;如果所属门店已停用,会返回“所属门店已被禁用”。
|
||||||
|
|
||||||
为了方便测试,可以先把 token 保存成 shell 变量:
|
为了方便测试,可以先把 token 保存成 shell 变量:
|
||||||
|
|
||||||
@@ -371,7 +373,7 @@ curl -X PUT http://localhost:3500/api/permissions/roles/5 \
|
|||||||
| 菜单 | 超级管理员 | 默认管理员 `admin` | 默认店长 `store_manager` | 其他角色 |
|
| 菜单 | 超级管理员 | 默认管理员 `admin` | 默认店长 `store_manager` | 其他角色 |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| 门店管理 | 查看、新增、修改、删除 | 查看、新增、修改、删除 | 不可见 | 不可见 |
|
| 门店管理 | 查看、新增、修改、删除 | 查看、新增、修改、删除 | 不可见 | 不可见 |
|
||||||
| 角色管理 | 查看、新增、修改、删除自定义角色 | 查看、新增、修改、删除自定义角色 | 不可见 | 按角色权限决定 |
|
| 角色管理 | 查看、新增、修改、软删除自定义角色 | 查看、新增、修改、软删除自定义角色 | 不可见 | 按角色权限决定 |
|
||||||
| 员工管理 | 查看全部、新增、修改、删除 | 查看全部、新增、修改、删除 | 仅查看当前门店员工 | 不可见 |
|
| 员工管理 | 查看全部、新增、修改、删除 | 查看全部、新增、修改、删除 | 仅查看当前门店员工 | 不可见 |
|
||||||
| 权限管理 | 查看、分配 | 查看、分配 | 不可见 | 按角色权限决定 |
|
| 权限管理 | 查看、分配 | 查看、分配 | 不可见 | 按角色权限决定 |
|
||||||
|
|
||||||
@@ -391,6 +393,13 @@ curl 'http://localhost:3500/api/stores?includeInactive=true' \
|
|||||||
-H "Authorization: Bearer $TOKEN"
|
-H "Authorization: Bearer $TOKEN"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
查询门店详情和门店员工:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3500/api/stores/1 \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
新增门店:
|
新增门店:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -423,7 +432,14 @@ curl -X DELETE http://localhost:3500/api/stores/1 \
|
|||||||
-H "Authorization: Bearer $TOKEN"
|
-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"}'
|
-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
|
```bash
|
||||||
@@ -523,7 +555,7 @@ curl -X DELETE http://localhost:3500/api/employees/1 \
|
|||||||
-H "Authorization: Bearer $TOKEN"
|
-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):给员工补充登录密码哈希和最后登录时间。
|
- [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`,手机号改为全局唯一,并标记服务端内置角色。
|
- [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` 的默认权限。
|
- [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` 时,脚本会:
|
执行 `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` 是生成列,用来实现“未删除员工手机号全局唯一”。
|
- `employees.active_phone` 是生成列,用来实现“未删除员工手机号全局唯一”。
|
||||||
|
- `roles.active_code` 是生成列,用来实现“未删除角色编码唯一”。
|
||||||
- `employees.password_hash` 让员工也能登录,默认本地密码是 `pw111111`。
|
- `employees.password_hash` 让员工也能登录,默认本地密码是 `pw111111`。
|
||||||
- `employee_roles` 是多对多关系表。
|
- `employee_roles` 是员工和角色的多对多关系表,解绑时写入 `deleted_at`。
|
||||||
- `role_permissions` 保存角色和权限点的多对多关系,权限分配保存后会在接口鉴权时实时生效。
|
- `role_permissions` 保存角色和权限点的多对多关系,权限解绑时写入 `deleted_at`,权限分配保存后会在接口鉴权时实时生效。
|
||||||
- `super_admins` 保存超级管理员账号,密码使用 PBKDF2 哈希,禁止存明文。
|
- `super_admins` 保存超级管理员账号,密码使用 PBKDF2 哈希,禁止存明文。
|
||||||
- 权限点定义由 `src/modules/permissions/` 固定,角色拥有的权限点由 `role_permissions` 动态决定。
|
- 权限点定义由 `src/modules/permissions/` 固定,角色拥有的权限点由 `role_permissions` 动态决定。
|
||||||
- 前端根据 `/api/permissions/me` 渲染菜单和按钮,根据 `/api/permissions/definitions` 渲染可分配权限点。
|
- 前端根据 `/api/permissions/me` 渲染菜单和按钮,根据 `/api/permissions/definitions` 渲染可分配权限点。
|
||||||
@@ -604,9 +638,9 @@ docker compose ps
|
|||||||
|
|
||||||
`src/db/migrate.ts` 会把执行过的文件名写入 `schema_migrations`。再次运行时,如果文件名已存在,就会跳过,避免重复建表或重复插入数据。
|
`src/db/migrate.ts` 会把执行过的文件名写入 `schema_migrations`。再次运行时,如果文件名已存在,就会跳过,避免重复建表或重复插入数据。
|
||||||
|
|
||||||
### 删除员工后,为什么数据库里还有记录
|
### 删除员工或角色后,为什么数据库里还有记录
|
||||||
|
|
||||||
这是软删除。删除接口会把 `deleted_at` 设置为当前时间,并把状态改成 `INACTIVE`。这样可以保留历史数据,同时普通查询会过滤掉已删除记录。
|
这是软删除。员工和门店删除接口会把 `deleted_at` 设置为当前时间,并把状态改成 `INACTIVE`;角色删除接口会写入 `roles.deleted_at`。员工角色关系、角色权限关系的解绑也会写入各自的 `deleted_at`,迁移也会移除旧关系表上的级联物理删除约束。这样可以保留历史数据,同时普通查询会过滤掉已删除记录。
|
||||||
|
|
||||||
### 为什么不使用 ORM
|
### 为什么不使用 ORM
|
||||||
|
|
||||||
|
|||||||
+142
-17
@@ -107,13 +107,13 @@ pw111111
|
|||||||
| 权限码 | 说明 |
|
| 权限码 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `*` | 超级管理员,拥有全部权限 |
|
| `*` | 超级管理员,拥有全部权限 |
|
||||||
| `store:view` | 查看门店 |
|
| `store:view` | 查看门店和门店下员工 |
|
||||||
| `store:manage` | 新增、修改、删除门店 |
|
| `store:manage` | 新增、修改、停用、软删除门店 |
|
||||||
| `role:view` | 查看角色 |
|
| `role:view` | 查看角色 |
|
||||||
| `role:manage` | 新增、修改、删除自定义角色 |
|
| `role:manage` | 新增、修改、软删除自定义角色 |
|
||||||
| `employee:view:all` | 查看全部门店员工 |
|
| `employee:view:all` | 查看全部门店员工 |
|
||||||
| `employee:view:store` | 查看当前门店员工 |
|
| `employee:view:store` | 查看当前门店员工 |
|
||||||
| `employee:manage` | 新增、修改、删除员工 |
|
| `employee:manage` | 新增、修改、启停、移除、软删除员工和维护密码 |
|
||||||
| `permission:view` | 查看权限策略 |
|
| `permission:view` | 查看权限策略 |
|
||||||
| `permission:manage` | 分配角色权限 |
|
| `permission:manage` | 分配角色权限 |
|
||||||
|
|
||||||
@@ -192,14 +192,23 @@ interface Role extends RoleOption {
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
type EmployeeStatus = "ACTIVE" | "INACTIVE";
|
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 {
|
interface Employee {
|
||||||
id: number;
|
id: number;
|
||||||
storeId: number;
|
storeId: number;
|
||||||
storeName: string;
|
storeName: string;
|
||||||
|
storeStatus: EmployeeStoreStatus;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
status: EmployeeStatus;
|
status: EmployeeStatus;
|
||||||
|
statusTags: EmployeeStatusTag[];
|
||||||
remark: string | null;
|
remark: string | null;
|
||||||
roles: Array<{
|
roles: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
@@ -238,20 +247,24 @@ interface PermissionMenu {
|
|||||||
| `PUT` | `/api/permissions/roles/:roleId` | 是 | `permission:manage` | 更新角色权限 |
|
| `PUT` | `/api/permissions/roles/:roleId` | 是 | `permission:manage` | 更新角色权限 |
|
||||||
| `GET` | `/api/stores` | 是 | `store:view` | 门店列表或门店下拉选项 |
|
| `GET` | `/api/stores` | 是 | `store:view` | 门店列表或门店下拉选项 |
|
||||||
| `GET` | `/api/stores/:id` | 是 | `store:view` | 门店详情 |
|
| `GET` | `/api/stores/:id` | 是 | `store:view` | 门店详情 |
|
||||||
|
| `GET` | `/api/stores/:id/employees` | 是 | `store:view` | 门店员工列表 |
|
||||||
| `POST` | `/api/stores` | 是 | `store:manage` | 新增门店 |
|
| `POST` | `/api/stores` | 是 | `store:manage` | 新增门店 |
|
||||||
| `PATCH` | `/api/stores/:id` | 是 | `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` | 是 | `role:view` | 角色列表 |
|
||||||
| `GET` | `/api/roles/:id` | 是 | `role:view` | 角色详情 |
|
| `GET` | `/api/roles/:id` | 是 | `role:view` | 角色详情 |
|
||||||
| `POST` | `/api/roles` | 是 | `role:manage` | 新增自定义角色 |
|
| `POST` | `/api/roles` | 是 | `role:manage` | 新增自定义角色 |
|
||||||
| `PATCH` | `/api/roles/:id` | 是 | `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` | 是 | `employee:view:all` 或 `employee:view:store` | 员工分页列表 |
|
||||||
| `GET` | `/api/employees/:id` | 是 | `employee:view:all` 或当前门店 `employee:view:store` | 员工详情 |
|
| `GET` | `/api/employees/:id` | 是 | `employee:view:all` 或当前门店 `employee:view:store` | 员工详情 |
|
||||||
| `POST` | `/api/employees` | 是 | `employee:manage` | 新增员工 |
|
| `POST` | `/api/employees` | 是 | `employee:manage` | 新增员工 |
|
||||||
| `PATCH` | `/api/employees/:id` | 是 | `employee:manage` | 修改员工 |
|
| `PATCH` | `/api/employees/:id` | 是 | `employee:manage` | 修改员工 |
|
||||||
| `PATCH` | `/api/employees/:id/status` | 是 | `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` 登录。
|
- 超级管理员使用 `super_admins.username` 登录。
|
||||||
- 员工使用手机号登录。
|
- 员工使用手机号登录。
|
||||||
|
- 登录会先校验密码;密码正确但账号停用时返回 `401 UNAUTHORIZED`,消息为 `账号已被禁用`。
|
||||||
|
- 员工所属门店停用时返回 `401 UNAUTHORIZED`,消息为 `所属门店已被禁用`。
|
||||||
- 员工必须拥有后台菜单权限,否则返回 `401 UNAUTHORIZED`,消息为 `当前账号没有后台登录权限`。
|
- 员工必须拥有后台菜单权限,否则返回 `401 UNAUTHORIZED`,消息为 `当前账号没有后台登录权限`。
|
||||||
- `cashier`、`kitchen`、`part_time` 默认没有后台登录权限。
|
- `cashier`、`kitchen`、`part_time` 默认没有后台登录权限。
|
||||||
|
|
||||||
@@ -336,6 +351,7 @@ interface PermissionMenu {
|
|||||||
### POST /api/auth/employee/login
|
### POST /api/auth/employee/login
|
||||||
|
|
||||||
员工端登录入口。员工使用手机号和密码登录,不要求后台管理权限。
|
员工端登录入口。员工使用手机号和密码登录,不要求后台管理权限。
|
||||||
|
密码正确但员工账号停用时返回 `账号已被禁用`;所属门店停用时返回 `所属门店已被禁用`。
|
||||||
|
|
||||||
请求体:
|
请求体:
|
||||||
|
|
||||||
@@ -555,7 +571,7 @@ Authorization: Bearer <token>
|
|||||||
|
|
||||||
更新指定角色拥有的权限点。需要 `permission:manage`。
|
更新指定角色拥有的权限点。需要 `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 <token>
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `id` | `number` | 正整数 |
|
| `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
|
### POST /api/stores
|
||||||
|
|
||||||
@@ -760,7 +793,33 @@ Authorization: Bearer <token>
|
|||||||
|
|
||||||
- `404 NOT_FOUND`:门店不存在。
|
- `404 NOT_FOUND`:门店不存在。
|
||||||
- `409 CONFLICT`:门店名称已存在。
|
- `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
|
### DELETE /api/stores/:id
|
||||||
|
|
||||||
@@ -889,10 +948,17 @@ Authorization: Bearer <token>
|
|||||||
|
|
||||||
### DELETE /api/roles/:id
|
### DELETE /api/roles/:id
|
||||||
|
|
||||||
删除自定义角色。需要 `role:manage`。
|
软删除自定义角色。需要 `role:manage`。
|
||||||
|
|
||||||
成功响应:`204 No Content`。
|
成功响应:`204 No Content`。
|
||||||
|
|
||||||
|
删除后:
|
||||||
|
|
||||||
|
- `roles.deleted_at` 会写入删除时间。
|
||||||
|
- 角色不会再出现在角色列表、角色下拉选项和权限策略中。
|
||||||
|
- 该角色编码的唯一约束会释放,之后可以重新创建同编码角色。
|
||||||
|
- 员工角色关系不会被物理删除,解绑和重新绑定通过 `employee_roles.deleted_at` 记录当前关系状态。
|
||||||
|
|
||||||
可能的业务错误:
|
可能的业务错误:
|
||||||
|
|
||||||
- `404 NOT_FOUND`:角色不存在。
|
- `404 NOT_FOUND`:角色不存在。
|
||||||
@@ -938,9 +1004,17 @@ Authorization: Bearer <token>
|
|||||||
"id": 1,
|
"id": 1,
|
||||||
"storeId": 1,
|
"storeId": 1,
|
||||||
"storeName": "示例门店",
|
"storeName": "示例门店",
|
||||||
|
"storeStatus": "ACTIVE",
|
||||||
"name": "张三",
|
"name": "张三",
|
||||||
"phone": "13800000001",
|
"phone": "13800000001",
|
||||||
"status": "ACTIVE",
|
"status": "ACTIVE",
|
||||||
|
"statusTags": [
|
||||||
|
{
|
||||||
|
"code": "EMPLOYEE_ACTIVE",
|
||||||
|
"label": "员工启用",
|
||||||
|
"variant": "success"
|
||||||
|
}
|
||||||
|
],
|
||||||
"remark": null,
|
"remark": null,
|
||||||
"roles": [
|
"roles": [
|
||||||
{
|
{
|
||||||
@@ -963,6 +1037,8 @@ Authorization: Bearer <token>
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
如果员工所属门店已停用,`storeStatus` 会返回 `"INACTIVE"`,且 `statusTags` 会同时包含员工自身状态标签和“门店被禁用”标签。
|
||||||
|
|
||||||
### GET /api/employees/:id
|
### GET /api/employees/:id
|
||||||
|
|
||||||
查询员工详情。需要员工查看权限。
|
查询员工详情。需要员工查看权限。
|
||||||
@@ -1019,13 +1095,13 @@ Authorization: Bearer <token>
|
|||||||
|
|
||||||
- `storeId` 必须对应启用且未删除门店。
|
- `storeId` 必须对应启用且未删除门店。
|
||||||
- `phone` 在未删除员工范围内全局唯一。
|
- `phone` 在未删除员工范围内全局唯一。
|
||||||
- `roleIds` 中的角色必须存在;自定义角色可先通过角色接口创建,再通过权限接口分配权限。
|
- `roleIds` 中的角色必须存在且未软删除;自定义角色可先通过角色接口创建,再通过权限接口分配权限。
|
||||||
- 新员工默认密码为 `pw111111`。
|
- 新员工默认密码为 `pw111111`。
|
||||||
|
|
||||||
可能的业务错误:
|
可能的业务错误:
|
||||||
|
|
||||||
- `400 BAD_REQUEST`:门店不存在或已停用。
|
- `400 BAD_REQUEST`:门店不存在或已停用。
|
||||||
- `400 BAD_REQUEST`:提交的角色包含不存在的角色。
|
- `400 BAD_REQUEST`:提交的角色包含不存在或已删除的角色。
|
||||||
- `409 CONFLICT`:员工手机号已存在。
|
- `409 CONFLICT`:员工手机号已存在。
|
||||||
|
|
||||||
### PATCH /api/employees/:id
|
### PATCH /api/employees/:id
|
||||||
@@ -1036,12 +1112,12 @@ Authorization: Bearer <token>
|
|||||||
|
|
||||||
| 字段 | 类型 | 约束 | 说明 |
|
| 字段 | 类型 | 约束 | 说明 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `storeId` | `number` | 正整数 | 新门店必须启用且未删除 |
|
| `storeId` | `number` | 正整数 | 更换门店时,新门店必须启用且未删除 |
|
||||||
| `name` | `string` | trim 后 1-50 字符 | 员工姓名 |
|
| `name` | `string` | trim 后 1-50 字符 | 员工姓名 |
|
||||||
| `phone` | `string` | 中国大陆手机号,`/^1[3-9]\d{9}$/` | 员工手机号,未删除员工范围内全局唯一 |
|
| `phone` | `string` | 中国大陆手机号,`/^1[3-9]\d{9}$/` | 员工手机号,未删除员工范围内全局唯一 |
|
||||||
| `status` | `"ACTIVE" \| "INACTIVE"` | 枚举 | 员工状态 |
|
| `status` | `"ACTIVE" \| "INACTIVE"` | 枚举 | 员工状态 |
|
||||||
| `remark` | `string \| null` | trim 后最多 500 字符 | 备注 |
|
| `remark` | `string \| null` | trim 后最多 500 字符 | 备注 |
|
||||||
| `roleIds` | `number[]` | 正整数数组,最多 20 个 | 不传表示不修改角色,传空数组表示清空角色 |
|
| `roleIds` | `number[]` | 正整数数组,最多 20 个 | 不传表示不修改角色,传空数组表示清空角色;解绑会写入 `employee_roles.deleted_at` |
|
||||||
|
|
||||||
请求示例:
|
请求示例:
|
||||||
|
|
||||||
@@ -1057,8 +1133,8 @@ Authorization: Bearer <token>
|
|||||||
可能的业务错误:
|
可能的业务错误:
|
||||||
|
|
||||||
- `404 NOT_FOUND`:员工不存在。
|
- `404 NOT_FOUND`:员工不存在。
|
||||||
- `400 BAD_REQUEST`:门店不存在或已停用。
|
- `400 BAD_REQUEST`:更换后的门店不存在或已停用。
|
||||||
- `400 BAD_REQUEST`:提交的角色包含不存在的角色。
|
- `400 BAD_REQUEST`:提交的角色包含不存在或已删除的角色。
|
||||||
- `409 CONFLICT`:员工手机号已存在。
|
- `409 CONFLICT`:员工手机号已存在。
|
||||||
|
|
||||||
### PATCH /api/employees/:id/status
|
### PATCH /api/employees/:id/status
|
||||||
@@ -1081,6 +1157,55 @@ Authorization: Bearer <token>
|
|||||||
|
|
||||||
响应 `data` 为 `Employee`。
|
响应 `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
|
### DELETE /api/employees/:id
|
||||||
|
|
||||||
软删除员工。需要 `employee:manage`。
|
软删除员工。需要 `employee:manage`。
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -22,8 +22,10 @@ interface EmployeeLoginRow extends RowDataPacket {
|
|||||||
username: string;
|
username: string;
|
||||||
password_hash: string;
|
password_hash: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
|
status: "ACTIVE" | "INACTIVE";
|
||||||
store_id: number;
|
store_id: number;
|
||||||
store_name: string;
|
store_name: string;
|
||||||
|
store_status: "ACTIVE" | "INACTIVE";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EmployeeRoleRow extends RowDataPacket {
|
interface EmployeeRoleRow extends RowDataPacket {
|
||||||
@@ -67,6 +69,22 @@ export const authRepository = {
|
|||||||
return rows[0] ? toSuperAdmin(rows[0]) : null;
|
return rows[0] ? toSuperAdmin(rows[0]) : null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async findByUsername(
|
||||||
|
username: string,
|
||||||
|
): Promise<SuperAdminWithPassword | null> {
|
||||||
|
const [rows] = await pool.execute<SuperAdminRow[]>(
|
||||||
|
`
|
||||||
|
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<SuperAdminWithPassword | null> {
|
async findActiveById(id: number): Promise<SuperAdminWithPassword | null> {
|
||||||
const [rows] = await pool.execute<SuperAdminRow[]>(
|
const [rows] = await pool.execute<SuperAdminRow[]>(
|
||||||
`
|
`
|
||||||
@@ -102,8 +120,10 @@ export const authRepository = {
|
|||||||
e.phone AS username,
|
e.phone AS username,
|
||||||
e.password_hash,
|
e.password_hash,
|
||||||
e.name AS display_name,
|
e.name AS display_name,
|
||||||
|
e.status,
|
||||||
e.store_id,
|
e.store_id,
|
||||||
s.name AS store_name
|
s.name AS store_name,
|
||||||
|
s.status AS store_status
|
||||||
FROM employees e
|
FROM employees e
|
||||||
INNER JOIN stores s ON s.id = e.store_id
|
INNER JOIN stores s ON s.id = e.store_id
|
||||||
WHERE e.phone = ?
|
WHERE e.phone = ?
|
||||||
@@ -127,6 +147,41 @@ export const authRepository = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async findEmployeeByPhoneForLogin(
|
||||||
|
phone: string,
|
||||||
|
): Promise<EmployeeLoginAccount | null> {
|
||||||
|
const [rows] = await pool.execute<EmployeeLoginRow[]>(
|
||||||
|
`
|
||||||
|
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(
|
async findActiveEmployeeById(
|
||||||
id: number,
|
id: number,
|
||||||
): Promise<EmployeeLoginAccount | null> {
|
): Promise<EmployeeLoginAccount | null> {
|
||||||
@@ -137,8 +192,10 @@ export const authRepository = {
|
|||||||
e.phone AS username,
|
e.phone AS username,
|
||||||
e.password_hash,
|
e.password_hash,
|
||||||
e.name AS display_name,
|
e.name AS display_name,
|
||||||
|
e.status,
|
||||||
e.store_id,
|
e.store_id,
|
||||||
s.name AS store_name
|
s.name AS store_name,
|
||||||
|
s.status AS store_status
|
||||||
FROM employees e
|
FROM employees e
|
||||||
INNER JOIN stores s ON s.id = e.store_id
|
INNER JOIN stores s ON s.id = e.store_id
|
||||||
WHERE e.id = ?
|
WHERE e.id = ?
|
||||||
@@ -189,6 +246,8 @@ export const authRepository = {
|
|||||||
FROM employee_roles er
|
FROM employee_roles er
|
||||||
INNER JOIN roles r ON r.id = er.role_id
|
INNER JOIN roles r ON r.id = er.role_id
|
||||||
WHERE er.employee_id IN (${placeholders})
|
WHERE er.employee_id IN (${placeholders})
|
||||||
|
AND er.deleted_at IS NULL
|
||||||
|
AND r.deleted_at IS NULL
|
||||||
ORDER BY r.id ASC
|
ORDER BY r.id ASC
|
||||||
`,
|
`,
|
||||||
employeeIds,
|
employeeIds,
|
||||||
@@ -217,8 +276,10 @@ function toEmployeeLoginAccount(
|
|||||||
username: row.username,
|
username: row.username,
|
||||||
passwordHash: row.password_hash,
|
passwordHash: row.password_hash,
|
||||||
displayName: row.display_name,
|
displayName: row.display_name,
|
||||||
|
status: row.status,
|
||||||
storeId: row.store_id,
|
storeId: row.store_id,
|
||||||
storeName: row.store_name,
|
storeName: row.store_name,
|
||||||
|
storeStatus: row.store_status,
|
||||||
roles,
|
roles,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export const authService = {
|
|||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
payload: AuthJwtPayload;
|
payload: AuthJwtPayload;
|
||||||
}> {
|
}> {
|
||||||
const admin = await authRepository.findActiveByUsername(input.username);
|
const admin = await authRepository.findByUsername(input.username);
|
||||||
|
|
||||||
if (admin) {
|
if (admin) {
|
||||||
const passwordMatched = await verifyPassword(
|
const passwordMatched = await verifyPassword(
|
||||||
@@ -95,6 +95,10 @@ export const authService = {
|
|||||||
throw unauthorized("用户名或密码错误");
|
throw unauthorized("用户名或密码错误");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (admin.status === "INACTIVE") {
|
||||||
|
throw unauthorized("账号已被禁用");
|
||||||
|
}
|
||||||
|
|
||||||
await authRepository.updateLastLoginAt(admin.id);
|
await authRepository.updateLastLoginAt(admin.id);
|
||||||
|
|
||||||
const user = await toAuthUser(admin);
|
const user = await toAuthUser(admin);
|
||||||
@@ -105,7 +109,7 @@ export const authService = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const employee = await authRepository.findActiveEmployeeByPhone(
|
const employee = await authRepository.findEmployeeByPhoneForLogin(
|
||||||
input.username,
|
input.username,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -122,6 +126,14 @@ export const authService = {
|
|||||||
throw unauthorized("用户名或密码错误");
|
throw unauthorized("用户名或密码错误");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (employee.status === "INACTIVE") {
|
||||||
|
throw unauthorized("账号已被禁用");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (employee.storeStatus === "INACTIVE") {
|
||||||
|
throw unauthorized("所属门店已被禁用");
|
||||||
|
}
|
||||||
|
|
||||||
await authRepository.updateEmployeeLastLoginAt(employee.id);
|
await authRepository.updateEmployeeLastLoginAt(employee.id);
|
||||||
|
|
||||||
const user = await toEmployeeAuthUser(employee);
|
const user = await toEmployeeAuthUser(employee);
|
||||||
@@ -140,7 +152,7 @@ export const authService = {
|
|||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
payload: AuthJwtPayload;
|
payload: AuthJwtPayload;
|
||||||
}> {
|
}> {
|
||||||
const employee = await authRepository.findActiveEmployeeByPhone(
|
const employee = await authRepository.findEmployeeByPhoneForLogin(
|
||||||
input.username,
|
input.username,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -157,6 +169,14 @@ export const authService = {
|
|||||||
throw unauthorized("用户名或密码错误");
|
throw unauthorized("用户名或密码错误");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (employee.status === "INACTIVE") {
|
||||||
|
throw unauthorized("账号已被禁用");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (employee.storeStatus === "INACTIVE") {
|
||||||
|
throw unauthorized("所属门店已被禁用");
|
||||||
|
}
|
||||||
|
|
||||||
await authRepository.updateEmployeeLastLoginAt(employee.id);
|
await authRepository.updateEmployeeLastLoginAt(employee.id);
|
||||||
|
|
||||||
const user = await toEmployeeAuthUser(employee);
|
const user = await toEmployeeAuthUser(employee);
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ export interface EmployeeLoginAccount {
|
|||||||
username: string;
|
username: string;
|
||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
status: "ACTIVE" | "INACTIVE";
|
||||||
storeId: number;
|
storeId: number;
|
||||||
storeName: string;
|
storeName: string;
|
||||||
|
storeStatus: "ACTIVE" | "INACTIVE";
|
||||||
roles: Array<{
|
roles: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
code: string;
|
code: string;
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ const HASH_SCHEME = "pbkdf2";
|
|||||||
const HASH_ALGORITHM = "sha256";
|
const HASH_ALGORITHM = "sha256";
|
||||||
const ITERATIONS = 310000;
|
const ITERATIONS = 310000;
|
||||||
const KEY_LENGTH = 32;
|
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 {
|
function toBase64Url(buffer: Buffer): string {
|
||||||
return buffer.toString("base64url");
|
return buffer.toString("base64url");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { created, ok, paginated } from "../../shared/response";
|
import { created, ok, paginated } from "../../shared/response";
|
||||||
import { permissionGuard } from "../auth/auth.guard";
|
import { permissionGuard } from "../auth/auth.guard";
|
||||||
|
import { employeeService } from "../employees/employee.service";
|
||||||
import { PERMISSIONS } from "../permissions/permission.policy";
|
import { PERMISSIONS } from "../permissions/permission.policy";
|
||||||
import { catalogService } from "./catalog.service";
|
import { catalogService } from "./catalog.service";
|
||||||
import {
|
import {
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
idParamSchema,
|
idParamSchema,
|
||||||
listRolesQuerySchema,
|
listRolesQuerySchema,
|
||||||
listStoresQuerySchema,
|
listStoresQuerySchema,
|
||||||
|
storeEmployeeParamSchema,
|
||||||
updateRoleBodySchema,
|
updateRoleBodySchema,
|
||||||
updateStoreBodySchema,
|
updateStoreBodySchema,
|
||||||
} from "./catalog.schema";
|
} from "./catalog.schema";
|
||||||
@@ -58,9 +60,17 @@ export async function catalogRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return ok(stores);
|
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) => {
|
app.get("/stores/:id", { preHandler: permissionGuard(PERMISSIONS.STORE_VIEW) }, async (request) => {
|
||||||
const params = idParamSchema.parse(request.params);
|
const params = idParamSchema.parse(request.params);
|
||||||
const store = await catalogService.getStoreById(params.id);
|
const store = await catalogService.getStoreDetailById(params.id);
|
||||||
|
|
||||||
return ok(store);
|
return ok(store);
|
||||||
});
|
});
|
||||||
@@ -80,6 +90,17 @@ export async function catalogRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return ok(store);
|
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) => {
|
app.delete("/stores/:id", { preHandler: permissionGuard(PERMISSIONS.STORE_MANAGE) }, async (request, reply) => {
|
||||||
const params = idParamSchema.parse(request.params);
|
const params = idParamSchema.parse(request.params);
|
||||||
await catalogService.deleteStore(params.id);
|
await catalogService.deleteStore(params.id);
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ function buildRoleListWhere(query: ListRolesQuery): {
|
|||||||
whereSql: string;
|
whereSql: string;
|
||||||
params: SqlParam[];
|
params: SqlParam[];
|
||||||
} {
|
} {
|
||||||
const where: string[] = [];
|
const where: string[] = ["deleted_at IS NULL"];
|
||||||
const params: SqlParam[] = [];
|
const params: SqlParam[] = [];
|
||||||
|
|
||||||
if (query.isSystem !== undefined) {
|
if (query.isSystem !== undefined) {
|
||||||
@@ -154,7 +154,7 @@ function buildRoleListWhere(query: ListRolesQuery): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
whereSql: where.length > 0 ? where.join(" AND ") : "1 = 1",
|
whereSql: where.join(" AND "),
|
||||||
params,
|
params,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -342,6 +342,7 @@ export const catalogRepository = {
|
|||||||
`
|
`
|
||||||
SELECT id, code, name, description, is_system, created_at, updated_at
|
SELECT id, code, name, description, is_system, created_at, updated_at
|
||||||
FROM roles
|
FROM roles
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
ORDER BY id ASC
|
ORDER BY id ASC
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
@@ -386,6 +387,7 @@ export const catalogRepository = {
|
|||||||
`
|
`
|
||||||
SELECT id, code, name, description, is_system, created_at, updated_at
|
SELECT id, code, name, description, is_system, created_at, updated_at
|
||||||
FROM roles
|
FROM roles
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
ORDER BY id ASC
|
ORDER BY id ASC
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
@@ -399,6 +401,7 @@ export const catalogRepository = {
|
|||||||
SELECT id, code, name, description, is_system, created_at, updated_at
|
SELECT id, code, name, description, is_system, created_at, updated_at
|
||||||
FROM roles
|
FROM roles
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
|
AND deleted_at IS NULL
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`,
|
`,
|
||||||
[id],
|
[id],
|
||||||
@@ -425,6 +428,7 @@ export const catalogRepository = {
|
|||||||
SELECT id, code, name, description, is_system, created_at, updated_at
|
SELECT id, code, name, description, is_system, created_at, updated_at
|
||||||
FROM roles
|
FROM roles
|
||||||
WHERE code = ?
|
WHERE code = ?
|
||||||
|
AND deleted_at IS NULL
|
||||||
${excludeSql}
|
${excludeSql}
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`,
|
`,
|
||||||
@@ -474,22 +478,32 @@ export const catalogRepository = {
|
|||||||
`
|
`
|
||||||
UPDATE roles
|
UPDATE roles
|
||||||
SET ${sets.join(", ")}
|
SET ${sets.join(", ")}
|
||||||
WHERE id = ?
|
WHERE id = ? AND deleted_at IS NULL
|
||||||
`,
|
`,
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteRole(id: number): Promise<void> {
|
async deleteRole(id: number): Promise<void> {
|
||||||
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<number> {
|
async countEmployeesByRole(roleId: number): Promise<number> {
|
||||||
const [rows] = await pool.execute<CountRow[]>(
|
const [rows] = await pool.execute<CountRow[]>(
|
||||||
`
|
`
|
||||||
SELECT COUNT(*) AS total
|
SELECT COUNT(*) AS total
|
||||||
FROM employee_roles
|
FROM employee_roles er
|
||||||
WHERE role_id = ?
|
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],
|
[roleId],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ export const idParamSchema = z.object({
|
|||||||
id: z.coerce.number().int().positive(),
|
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({
|
export const listStoresQuerySchema = z.object({
|
||||||
includeInactive: z.preprocess(
|
includeInactive: z.preprocess(
|
||||||
(value) => stringToBoolean(emptyStringToUndefined(value)),
|
(value) => stringToBoolean(emptyStringToUndefined(value)),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { conflict, notFound } from "../../shared/http-error";
|
import { conflict, notFound } from "../../shared/http-error";
|
||||||
|
import { employeeService } from "../employees/employee.service";
|
||||||
import { catalogRepository } from "./catalog.repository";
|
import { catalogRepository } from "./catalog.repository";
|
||||||
import type {
|
import type {
|
||||||
CreateRoleInput,
|
CreateRoleInput,
|
||||||
@@ -8,6 +9,7 @@ import type {
|
|||||||
Role,
|
Role,
|
||||||
RoleOption,
|
RoleOption,
|
||||||
Store,
|
Store,
|
||||||
|
StoreDetail,
|
||||||
StoreOption,
|
StoreOption,
|
||||||
UpdateRoleInput,
|
UpdateRoleInput,
|
||||||
UpdateStoreInput,
|
UpdateStoreInput,
|
||||||
@@ -40,6 +42,16 @@ export const catalogService = {
|
|||||||
return store;
|
return store;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getStoreDetailById(id: number): Promise<StoreDetail> {
|
||||||
|
const store = await this.getStoreById(id);
|
||||||
|
const employees = await employeeService.listByStoreId(id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...store,
|
||||||
|
employees,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
async createStore(input: CreateStoreInput): Promise<Store> {
|
async createStore(input: CreateStoreInput): Promise<Store> {
|
||||||
// 门店名称在“未删除门店”范围内保持唯一,避免管理后台出现两个同名门店。
|
// 门店名称在“未删除门店”范围内保持唯一,避免管理后台出现两个同名门店。
|
||||||
const duplicatedStore = await catalogRepository.findActiveStoreByName(
|
const duplicatedStore = await catalogRepository.findActiveStoreByName(
|
||||||
@@ -56,7 +68,7 @@ export const catalogService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async updateStore(id: number, input: UpdateStoreInput): Promise<Store> {
|
async updateStore(id: number, input: UpdateStoreInput): Promise<Store> {
|
||||||
const currentStore = await this.getStoreById(id);
|
await this.getStoreById(id);
|
||||||
|
|
||||||
if (input.name !== undefined) {
|
if (input.name !== undefined) {
|
||||||
const duplicatedStore = await catalogRepository.findActiveStoreByName(
|
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);
|
await catalogRepository.updateStore(id, input);
|
||||||
return this.getStoreById(id);
|
return this.getStoreById(id);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Employee } from "../employees/employee.types";
|
||||||
|
|
||||||
export const STORE_STATUS = ["ACTIVE", "INACTIVE"] as const;
|
export const STORE_STATUS = ["ACTIVE", "INACTIVE"] as const;
|
||||||
|
|
||||||
export const FIXED_ROLE_DEFINITIONS = [
|
export const FIXED_ROLE_DEFINITIONS = [
|
||||||
@@ -57,6 +59,10 @@ export interface Store extends StoreOption {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StoreDetail extends Store {
|
||||||
|
employees: Employee[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Role extends RoleOption {
|
export interface Role extends RoleOption {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
idParamSchema,
|
idParamSchema,
|
||||||
listEmployeesQuerySchema,
|
listEmployeesQuerySchema,
|
||||||
updateEmployeeBodySchema,
|
updateEmployeeBodySchema,
|
||||||
|
updateEmployeePasswordBodySchema,
|
||||||
updateEmployeeStatusBodySchema
|
updateEmployeeStatusBodySchema
|
||||||
} from "./employee.schema";
|
} from "./employee.schema";
|
||||||
import type { AuthUser } from "../auth/auth.types";
|
import type { AuthUser } from "../auth/auth.types";
|
||||||
@@ -123,6 +124,27 @@ export async function employeeRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return ok(employee);
|
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) => {
|
app.delete("/employees/:id", async (request, reply) => {
|
||||||
const user = await authService.getCurrentUser(request.user);
|
const user = await authService.getCurrentUser(request.user);
|
||||||
assertCanManageEmployees(user);
|
assertCanManageEmployees(user);
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import type { PoolConnection, ResultSetHeader, RowDataPacket } from "mysql2/promise";
|
import type { PoolConnection, ResultSetHeader, RowDataPacket } from "mysql2/promise";
|
||||||
import { pool } from "../../db/pool";
|
import { pool } from "../../db/pool";
|
||||||
|
import { INITIAL_EMPLOYEE_PASSWORD_HASH } from "../auth/password";
|
||||||
import type {
|
import type {
|
||||||
CreateEmployeeInput,
|
CreateEmployeeInput,
|
||||||
Employee,
|
Employee,
|
||||||
|
EmployeeStatusTag,
|
||||||
EmployeeStatus,
|
EmployeeStatus,
|
||||||
|
EmployeeStoreStatus,
|
||||||
ListEmployeesQuery,
|
ListEmployeesQuery,
|
||||||
RoleSummary,
|
RoleSummary,
|
||||||
UpdateEmployeeInput
|
UpdateEmployeeInput
|
||||||
@@ -11,13 +14,12 @@ import type {
|
|||||||
|
|
||||||
type DbExecutor = typeof pool | PoolConnection;
|
type DbExecutor = typeof pool | PoolConnection;
|
||||||
type SqlParam = string | number | boolean | Date | null;
|
type SqlParam = string | number | boolean | Date | null;
|
||||||
const DEFAULT_EMPLOYEE_PASSWORD_HASH =
|
|
||||||
"pbkdf2$sha256$310000$QnXyjrpm0QzcGYLEPdunWg$CfR-CywGl1c_Omh_3PyOWPmo93EcbMY1FEjjd5MDjFo";
|
|
||||||
|
|
||||||
interface EmployeeRow extends RowDataPacket {
|
interface EmployeeRow extends RowDataPacket {
|
||||||
id: number;
|
id: number;
|
||||||
store_id: number;
|
store_id: number;
|
||||||
store_name: string;
|
store_name: string;
|
||||||
|
store_status: EmployeeStoreStatus;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
status: EmployeeStatus;
|
status: EmployeeStatus;
|
||||||
@@ -37,20 +39,52 @@ interface CountRow extends RowDataPacket {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EmployeePasswordRow extends RowDataPacket {
|
||||||
|
password_hash: string;
|
||||||
|
}
|
||||||
|
|
||||||
// 数据库层统一把 Date 转成字符串,避免响应里混入运行时对象。
|
// 数据库层统一把 Date 转成字符串,避免响应里混入运行时对象。
|
||||||
function toIso(value: Date): string {
|
function toIso(value: Date): string {
|
||||||
return value.toISOString();
|
return value.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildStatusTags(row: Pick<EmployeeRow, "status" | "store_status">): 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 查询出来的数据库行转换成接口返回结构。
|
// 把 JOIN 查询出来的数据库行转换成接口返回结构。
|
||||||
function toEmployee(row: EmployeeRow, roles: RoleSummary[] = []): Employee {
|
function toEmployee(row: EmployeeRow, roles: RoleSummary[] = []): Employee {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
storeId: row.store_id,
|
storeId: row.store_id,
|
||||||
storeName: row.store_name,
|
storeName: row.store_name,
|
||||||
|
storeStatus: row.store_status,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
phone: row.phone,
|
phone: row.phone,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
|
statusTags: buildStatusTags(row),
|
||||||
remark: row.remark,
|
remark: row.remark,
|
||||||
roles,
|
roles,
|
||||||
createdAt: toIso(row.created_at),
|
createdAt: toIso(row.created_at),
|
||||||
@@ -103,7 +137,7 @@ export const employeeRepository = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async storeExists(storeId: number, db: DbExecutor = pool): Promise<boolean> {
|
async storeExists(storeId: number, db: DbExecutor = pool): Promise<boolean> {
|
||||||
// 员工只能绑定到启用且未删除的门店。
|
// 新建或调整员工归属时,只允许选择启用且未删除的门店。
|
||||||
const [rows] = await db.execute<CountRow[]>(
|
const [rows] = await db.execute<CountRow[]>(
|
||||||
"SELECT COUNT(*) AS total FROM stores WHERE id = ? AND status = 'ACTIVE' AND deleted_at IS NULL",
|
"SELECT COUNT(*) AS total FROM stores WHERE id = ? AND status = 'ACTIVE' AND deleted_at IS NULL",
|
||||||
[storeId]
|
[storeId]
|
||||||
@@ -124,6 +158,7 @@ export const employeeRepository = {
|
|||||||
SELECT id
|
SELECT id
|
||||||
FROM roles
|
FROM roles
|
||||||
WHERE id IN (${placeholders})
|
WHERE id IN (${placeholders})
|
||||||
|
AND deleted_at IS NULL
|
||||||
`,
|
`,
|
||||||
roleIds
|
roleIds
|
||||||
);
|
);
|
||||||
@@ -147,7 +182,7 @@ export const employeeRepository = {
|
|||||||
|
|
||||||
const [rows] = await db.execute<EmployeeRow[]>(
|
const [rows] = await db.execute<EmployeeRow[]>(
|
||||||
`
|
`
|
||||||
SELECT e.*, s.name AS store_name
|
SELECT e.*, s.name AS store_name, s.status AS store_status
|
||||||
FROM employees e
|
FROM employees e
|
||||||
INNER JOIN stores s ON s.id = e.store_id
|
INNER JOIN stores s ON s.id = e.store_id
|
||||||
WHERE e.phone = ?
|
WHERE e.phone = ?
|
||||||
@@ -179,7 +214,7 @@ export const employeeRepository = {
|
|||||||
// LIMIT/OFFSET 已经过 zod 校验成受控正整数;这里直接拼接数字,避免部分 MySQL 驱动对分页占位符的兼容问题。
|
// LIMIT/OFFSET 已经过 zod 校验成受控正整数;这里直接拼接数字,避免部分 MySQL 驱动对分页占位符的兼容问题。
|
||||||
const [rows] = await pool.execute<EmployeeRow[]>(
|
const [rows] = await pool.execute<EmployeeRow[]>(
|
||||||
`
|
`
|
||||||
SELECT e.*, s.name AS store_name
|
SELECT e.*, s.name AS store_name, s.status AS store_status
|
||||||
FROM employees e
|
FROM employees e
|
||||||
INNER JOIN stores s ON s.id = e.store_id
|
INNER JOIN stores s ON s.id = e.store_id
|
||||||
WHERE ${whereSql}
|
WHERE ${whereSql}
|
||||||
@@ -198,10 +233,26 @@ export const employeeRepository = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async listByStoreId(storeId: number, db: DbExecutor = pool): Promise<Employee[]> {
|
||||||
|
const [rows] = await db.execute<EmployeeRow[]>(
|
||||||
|
`
|
||||||
|
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<Employee | null> {
|
async findById(id: number, db: DbExecutor = pool): Promise<Employee | null> {
|
||||||
const [rows] = await db.execute<EmployeeRow[]>(
|
const [rows] = await db.execute<EmployeeRow[]>(
|
||||||
`
|
`
|
||||||
SELECT e.*, s.name AS store_name
|
SELECT e.*, s.name AS store_name, s.status AS store_status
|
||||||
FROM employees e
|
FROM employees e
|
||||||
INNER JOIN stores s ON s.id = e.store_id
|
INNER JOIN stores s ON s.id = e.store_id
|
||||||
WHERE e.id = ? AND e.deleted_at IS NULL
|
WHERE e.id = ? AND e.deleted_at IS NULL
|
||||||
@@ -228,7 +279,7 @@ export const employeeRepository = {
|
|||||||
input.storeId,
|
input.storeId,
|
||||||
input.name,
|
input.name,
|
||||||
input.phone,
|
input.phone,
|
||||||
DEFAULT_EMPLOYEE_PASSWORD_HASH,
|
INITIAL_EMPLOYEE_PASSWORD_HASH,
|
||||||
input.status,
|
input.status,
|
||||||
input.remark ?? null
|
input.remark ?? null
|
||||||
]
|
]
|
||||||
@@ -273,6 +324,31 @@ export const employeeRepository = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updatePasswordHash(id: number, passwordHash: string, db: DbExecutor = pool): Promise<void> {
|
||||||
|
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<string | null> {
|
||||||
|
const [rows] = await db.execute<EmployeePasswordRow[]>(
|
||||||
|
`
|
||||||
|
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<void> {
|
async softDelete(id: number): Promise<void> {
|
||||||
// 员工删除采用软删除:保留历史记录,同时将状态置为 INACTIVE。
|
// 员工删除采用软删除:保留历史记录,同时将状态置为 INACTIVE。
|
||||||
await pool.execute(
|
await pool.execute(
|
||||||
@@ -286,8 +362,15 @@ export const employeeRepository = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async replaceRoles(employeeId: number, roleIds: number[], db: DbExecutor = pool): Promise<void> {
|
async replaceRoles(employeeId: number, roleIds: number[], db: DbExecutor = pool): Promise<void> {
|
||||||
// 简化多对多更新:先删旧关系,再批量插入新关系。调用方用事务包住它。
|
// 关系解绑也使用逻辑删除;重新绑定同一角色时通过 upsert 恢复 deleted_at。
|
||||||
await db.execute("DELETE FROM employee_roles WHERE employee_id = ?", [employeeId]);
|
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) {
|
if (roleIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -297,6 +380,9 @@ export const employeeRepository = {
|
|||||||
`
|
`
|
||||||
INSERT INTO employee_roles (employee_id, role_id)
|
INSERT INTO employee_roles (employee_id, role_id)
|
||||||
VALUES ${roleIds.map(() => "(?, ?)").join(", ")}
|
VALUES ${roleIds.map(() => "(?, ?)").join(", ")}
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
deleted_at = NULL,
|
||||||
|
created_at = CURRENT_TIMESTAMP(3)
|
||||||
`,
|
`,
|
||||||
roleIds.flatMap((roleId) => [employeeId, roleId])
|
roleIds.flatMap((roleId) => [employeeId, roleId])
|
||||||
);
|
);
|
||||||
@@ -320,6 +406,8 @@ export const employeeRepository = {
|
|||||||
FROM employee_roles er
|
FROM employee_roles er
|
||||||
INNER JOIN roles r ON r.id = er.role_id
|
INNER JOIN roles r ON r.id = er.role_id
|
||||||
WHERE er.employee_id IN (${placeholders})
|
WHERE er.employee_id IN (${placeholders})
|
||||||
|
AND er.deleted_at IS NULL
|
||||||
|
AND r.deleted_at IS NULL
|
||||||
ORDER BY r.id ASC
|
ORDER BY r.id ASC
|
||||||
`,
|
`,
|
||||||
employeeIds
|
employeeIds
|
||||||
|
|||||||
@@ -61,3 +61,8 @@ export const updateEmployeeBodySchema = z
|
|||||||
export const updateEmployeeStatusBodySchema = z.object({
|
export const updateEmployeeStatusBodySchema = z.object({
|
||||||
status: z.enum(EMPLOYEE_STATUS)
|
status: z.enum(EMPLOYEE_STATUS)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const updateEmployeePasswordBodySchema = z.object({
|
||||||
|
oldPassword: z.string().min(8).max(128),
|
||||||
|
newPassword: z.string().min(8).max(128)
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import { badRequest, conflict, notFound } from "../../shared/http-error";
|
import { badRequest, conflict, notFound } from "../../shared/http-error";
|
||||||
|
import { hashPassword, INITIAL_EMPLOYEE_PASSWORD_HASH, verifyPassword } from "../auth/password";
|
||||||
import { employeeRepository } from "./employee.repository";
|
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 和主键冲突。
|
// 角色 id 可能从前端重复提交;进入数据库前先去重,减少无意义 SQL 和主键冲突。
|
||||||
function uniqueIds(ids: number[]): number[] {
|
function uniqueIds(ids: number[]): number[] {
|
||||||
return [...new Set(ids)];
|
return [...new Set(ids)];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建或修改员工时,必须保证门店存在且处于启用状态。
|
// 创建员工或更换员工门店时,必须保证目标门店存在且处于启用状态。
|
||||||
async function assertStoreExists(storeId: number): Promise<void> {
|
async function assertStoreExists(storeId: number): Promise<void> {
|
||||||
const exists = await employeeRepository.storeExists(storeId);
|
const exists = await employeeRepository.storeExists(storeId);
|
||||||
|
|
||||||
@@ -22,7 +29,7 @@ async function assertRolesExist(roleIds: number[]): Promise<number[]> {
|
|||||||
const existingRoleIds = await employeeRepository.existingRoleIds(dedupedRoleIds);
|
const existingRoleIds = await employeeRepository.existingRoleIds(dedupedRoleIds);
|
||||||
|
|
||||||
if (existingRoleIds.length !== dedupedRoleIds.length) {
|
if (existingRoleIds.length !== dedupedRoleIds.length) {
|
||||||
throw badRequest("提交的角色包含不存在的角色");
|
throw badRequest("提交的角色包含不存在或已删除的角色");
|
||||||
}
|
}
|
||||||
|
|
||||||
return dedupedRoleIds;
|
return dedupedRoleIds;
|
||||||
@@ -34,6 +41,10 @@ export const employeeService = {
|
|||||||
return employeeRepository.list(query);
|
return employeeRepository.list(query);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async listByStoreId(storeId: number): Promise<Employee[]> {
|
||||||
|
return employeeRepository.listByStoreId(storeId);
|
||||||
|
},
|
||||||
|
|
||||||
async getById(id: number): Promise<Employee> {
|
async getById(id: number): Promise<Employee> {
|
||||||
const employee = await employeeRepository.findById(id);
|
const employee = await employeeRepository.findById(id);
|
||||||
|
|
||||||
@@ -68,7 +79,7 @@ export const employeeService = {
|
|||||||
async update(id: number, input: UpdateEmployeeInput): Promise<Employee> {
|
async update(id: number, input: UpdateEmployeeInput): Promise<Employee> {
|
||||||
const currentEmployee = await this.getById(id);
|
const currentEmployee = await this.getById(id);
|
||||||
|
|
||||||
if (input.storeId !== undefined) {
|
if (input.storeId !== undefined && input.storeId !== currentEmployee.storeId) {
|
||||||
await assertStoreExists(input.storeId);
|
await assertStoreExists(input.storeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +117,40 @@ export const employeeService = {
|
|||||||
return this.getById(id);
|
return this.getById(id);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updatePassword(id: number, input: UpdateEmployeePasswordInput): Promise<Employee> {
|
||||||
|
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<Employee> {
|
||||||
|
await this.getById(id);
|
||||||
|
await employeeRepository.updatePasswordHash(id, INITIAL_EMPLOYEE_PASSWORD_HASH);
|
||||||
|
return this.getById(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeFromStore(storeId: number, employeeId: number): Promise<void> {
|
||||||
|
const employee = await this.getById(employeeId);
|
||||||
|
|
||||||
|
if (employee.storeId !== storeId) {
|
||||||
|
throw notFound("门店员工不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
await employeeRepository.softDelete(employeeId);
|
||||||
|
},
|
||||||
|
|
||||||
async delete(id: number): Promise<void> {
|
async delete(id: number): Promise<void> {
|
||||||
await this.getById(id);
|
await this.getById(id);
|
||||||
// 软删除保留历史数据,也能配合 active_phone 释放手机号唯一约束。
|
// 软删除保留历史数据,也能配合 active_phone 释放手机号唯一约束。
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ export const EMPLOYEE_STATUS = ["ACTIVE", "INACTIVE"] as const;
|
|||||||
|
|
||||||
// 从状态常量推导类型,确保 schema 校验和 TypeScript 类型保持一致。
|
// 从状态常量推导类型,确保 schema 校验和 TypeScript 类型保持一致。
|
||||||
export type EmployeeStatus = (typeof EMPLOYEE_STATUS)[number];
|
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 模块负责。
|
// 员工详情里只需要角色摘要,完整角色管理由 catalog 模块负责。
|
||||||
export interface RoleSummary {
|
export interface RoleSummary {
|
||||||
@@ -10,14 +16,22 @@ export interface RoleSummary {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EmployeeStatusTag {
|
||||||
|
code: EmployeeStatusTagCode;
|
||||||
|
label: string;
|
||||||
|
variant: EmployeeStatusTagVariant;
|
||||||
|
}
|
||||||
|
|
||||||
// Employee 是接口返回给前端的员工结构,字段名使用 camelCase。
|
// Employee 是接口返回给前端的员工结构,字段名使用 camelCase。
|
||||||
export interface Employee {
|
export interface Employee {
|
||||||
id: number;
|
id: number;
|
||||||
storeId: number;
|
storeId: number;
|
||||||
storeName: string;
|
storeName: string;
|
||||||
|
storeStatus: EmployeeStoreStatus;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
status: EmployeeStatus;
|
status: EmployeeStatus;
|
||||||
|
statusTags: EmployeeStatusTag[];
|
||||||
remark: string | null;
|
remark: string | null;
|
||||||
roles: RoleSummary[];
|
roles: RoleSummary[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -49,3 +63,8 @@ export interface UpdateEmployeeInput {
|
|||||||
remark?: string | null;
|
remark?: string | null;
|
||||||
roleIds?: number[];
|
roleIds?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateEmployeePasswordInput {
|
||||||
|
oldPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,14 +75,14 @@ const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
|
|||||||
{
|
{
|
||||||
code: PERMISSIONS.STORE_VIEW,
|
code: PERMISSIONS.STORE_VIEW,
|
||||||
title: "查看门店",
|
title: "查看门店",
|
||||||
description: "查看门店列表、门店详情和门店下拉选项。",
|
description: "查看门店列表、门店详情、门店下员工和门店下拉选项。",
|
||||||
groupKey: "stores",
|
groupKey: "stores",
|
||||||
groupTitle: "门店管理",
|
groupTitle: "门店管理",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: PERMISSIONS.STORE_MANAGE,
|
code: PERMISSIONS.STORE_MANAGE,
|
||||||
title: "管理门店",
|
title: "管理门店",
|
||||||
description: "新增、编辑、停用和删除门店。",
|
description: "新增、编辑、停用和软删除门店。",
|
||||||
groupKey: "stores",
|
groupKey: "stores",
|
||||||
groupTitle: "门店管理",
|
groupTitle: "门店管理",
|
||||||
},
|
},
|
||||||
@@ -96,7 +96,7 @@ const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
|
|||||||
{
|
{
|
||||||
code: PERMISSIONS.ROLE_MANAGE,
|
code: PERMISSIONS.ROLE_MANAGE,
|
||||||
title: "管理角色",
|
title: "管理角色",
|
||||||
description: "新增、编辑和删除非系统角色。",
|
description: "新增、编辑和软删除非系统角色。",
|
||||||
groupKey: "roles",
|
groupKey: "roles",
|
||||||
groupTitle: "角色管理",
|
groupTitle: "角色管理",
|
||||||
},
|
},
|
||||||
@@ -117,7 +117,7 @@ const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
|
|||||||
{
|
{
|
||||||
code: PERMISSIONS.EMPLOYEE_MANAGE,
|
code: PERMISSIONS.EMPLOYEE_MANAGE,
|
||||||
title: "管理员工",
|
title: "管理员工",
|
||||||
description: "新增、编辑、启停和删除员工,并维护员工角色。",
|
description: "新增、编辑、启停、移除、软删除员工,并维护员工角色和密码。",
|
||||||
groupKey: "employees",
|
groupKey: "employees",
|
||||||
groupTitle: "员工管理",
|
groupTitle: "员工管理",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -82,7 +82,9 @@ export const permissionRepository = {
|
|||||||
r.is_system,
|
r.is_system,
|
||||||
rp.permission_code
|
rp.permission_code
|
||||||
FROM roles r
|
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
|
ORDER BY r.id ASC, rp.permission_code ASC
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
@@ -104,8 +106,10 @@ export const permissionRepository = {
|
|||||||
r.is_system,
|
r.is_system,
|
||||||
rp.permission_code
|
rp.permission_code
|
||||||
FROM roles r
|
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 = ?
|
WHERE r.id = ?
|
||||||
|
AND r.deleted_at IS NULL
|
||||||
ORDER BY rp.permission_code ASC
|
ORDER BY rp.permission_code ASC
|
||||||
`,
|
`,
|
||||||
[roleId],
|
[roleId],
|
||||||
@@ -126,6 +130,8 @@ export const permissionRepository = {
|
|||||||
FROM role_permissions rp
|
FROM role_permissions rp
|
||||||
INNER JOIN roles r ON r.id = rp.role_id
|
INNER JOIN roles r ON r.id = rp.role_id
|
||||||
WHERE r.code IN (${placeholders})
|
WHERE r.code IN (${placeholders})
|
||||||
|
AND r.deleted_at IS NULL
|
||||||
|
AND rp.deleted_at IS NULL
|
||||||
ORDER BY rp.permission_code ASC
|
ORDER BY rp.permission_code ASC
|
||||||
`,
|
`,
|
||||||
roleCodes,
|
roleCodes,
|
||||||
@@ -139,7 +145,14 @@ export const permissionRepository = {
|
|||||||
permissionCodes: string[],
|
permissionCodes: string[],
|
||||||
db: DbExecutor = pool,
|
db: DbExecutor = pool,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
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) {
|
if (permissionCodes.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -149,6 +162,9 @@ export const permissionRepository = {
|
|||||||
`
|
`
|
||||||
INSERT INTO role_permissions (role_id, permission_code)
|
INSERT INTO role_permissions (role_id, permission_code)
|
||||||
VALUES ${permissionCodes.map(() => "(?, ?)").join(", ")}
|
VALUES ${permissionCodes.map(() => "(?, ?)").join(", ")}
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
deleted_at = NULL,
|
||||||
|
created_at = CURRENT_TIMESTAMP(3)
|
||||||
`,
|
`,
|
||||||
permissionCodes.flatMap((permissionCode) => [roleId, permissionCode]),
|
permissionCodes.flatMap((permissionCode) => [roleId, permissionCode]),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user