feat: 增加员工端工作台后端能力
This commit is contained in:
@@ -4,3 +4,4 @@ dist
|
||||
.env.*
|
||||
.DS_Store
|
||||
*.log
|
||||
dist.zip
|
||||
|
||||
@@ -18,11 +18,14 @@
|
||||
|
||||
- 门店管理:查询、新增、修改、停用、软删除门店,门店详情可查看员工。
|
||||
- 角色管理:拥有 `role:manage` 的账号可新增、修改、软删除自定义角色,服务端内置角色不可变更。
|
||||
- 员工管理:分页查询、新增、修改、启用/停用、修改密码、重置初始密码、移除和软删除员工。
|
||||
- 员工管理:分页查询、新增、修改、启用/停用、修改密码、重置临时密码、移除和软删除员工。
|
||||
- 员工角色:一个员工可以绑定多个角色。
|
||||
- 登录账号:超级管理员和员工都可以登录。
|
||||
- 后台权限:超级管理员拥有所有权限;角色权限由 `role_permissions` 动态分配。
|
||||
- 动态权限:菜单和按钮动作由 `/api/permissions/me` 返回,前端可通过权限管理页分配角色权限。
|
||||
- 员工工作台:提供员工端首屏聚合、公告、任务、今日排班和排班列表接口。
|
||||
- 后台工作台:提供公告、任务、排班管理接口。
|
||||
- 凭据安全:禁止查看明文密码,只支持本人改密、下级员工临时密码重置和审计追踪。
|
||||
- JWT 鉴权:登录后签发 token,除健康检查和登录外,接口都需要 Bearer token。
|
||||
- 数据校验:使用 zod 校验路径参数、查询参数和请求体。
|
||||
- 数据库迁移:使用 `migrations/*.sql` 管理建表和初始化数据。
|
||||
@@ -53,7 +56,8 @@
|
||||
│ ├── 004_add_employee_login_fields.sql # 给员工补充登录字段
|
||||
│ ├── 005_refine_employee_login_and_role_policy.sql # 调整员工默认密码、手机号唯一和系统角色
|
||||
│ ├── 006_create_role_permissions.sql # 创建角色权限关系表并初始化默认权限
|
||||
│ └── 007_add_soft_delete_to_roles_and_relations.sql # 给角色和关系表补充逻辑删除字段并移除级联删除
|
||||
│ ├── 007_add_soft_delete_to_roles_and_relations.sql # 给角色和关系表补充逻辑删除字段并移除级联删除
|
||||
│ └── 008_add_role_user_workbench_tables.sql # 新增员工工作台、排班和凭据审计表
|
||||
├── src/
|
||||
│ ├── app.ts # 创建 Fastify 应用、注册路由、统一错误处理
|
||||
│ ├── server.ts # 启动 HTTP 服务和优雅停机
|
||||
@@ -63,10 +67,15 @@
|
||||
│ │ ├── migrate.ts # 执行 migrations 目录下的 SQL
|
||||
│ │ └── pool.ts # MySQL 连接池
|
||||
│ ├── modules/
|
||||
│ │ ├── auth/ # 登录、当前用户和 JWT 鉴权模块
|
||||
│ │ ├── announcements/ # 公告后台管理和员工端公告模块
|
||||
│ │ ├── auth/ # 登录、当前用户、本人改密和 JWT 鉴权模块
|
||||
│ │ ├── catalog/ # 门店和角色模块
|
||||
│ │ ├── credentials/ # 凭据重置和凭据审计模块
|
||||
│ │ ├── employees/ # 员工 CRUD 模块
|
||||
│ │ └── permissions/ # 权限点定义、角色权限分配和菜单动作策略
|
||||
│ │ ├── mobile/ # 员工端首屏聚合模块
|
||||
│ │ ├── permissions/ # 权限点定义、角色权限分配和菜单动作策略
|
||||
│ │ ├── shifts/ # 排班后台管理和员工端排班模块
|
||||
│ │ └── tasks/ # 任务后台管理和员工端任务模块
|
||||
│ └── shared/ # 通用响应结构和业务错误
|
||||
├── docker-compose.yml # 本地 MySQL
|
||||
├── package.json
|
||||
@@ -93,10 +102,15 @@
|
||||
| `src/config/env.ts` | 使用 zod 校验当前运行环境变量,避免配置错误拖到请求阶段才暴露。 |
|
||||
| `src/db/migrate.ts` | 执行 `migrations/*.sql`,并用 `schema_migrations` 记录已执行迁移。 |
|
||||
| `src/db/pool.ts` | 创建 MySQL 连接池,提供数据库健康检查和关闭连接的方法。 |
|
||||
| `src/modules/auth/` | 登录鉴权模块,负责后台登录、员工端登录、密码校验、JWT 签发、当前用户查询和权限 guard。 |
|
||||
| `src/modules/announcements/` | 公告模块,负责后台公告管理、员工端可见公告、已读记录和未读统计。 |
|
||||
| `src/modules/auth/` | 登录鉴权模块,负责后台登录、员工端登录、本人改密、密码校验、JWT 签发、当前用户查询和权限 guard。 |
|
||||
| `src/modules/catalog/` | 门店和角色模块,负责基础资料接口、门店详情员工列表和门店移除员工入口。 |
|
||||
| `src/modules/credentials/` | 凭据安全模块,负责下级员工临时密码重置、密码状态和凭据审计。 |
|
||||
| `src/modules/employees/` | 员工模块,负责员工分页、详情、新增、修改、状态变更、密码维护和软删除。 |
|
||||
| `src/modules/mobile/` | 员工端聚合模块,负责 `/api/mobile/bootstrap` 首屏数据。 |
|
||||
| `src/modules/permissions/` | 权限模块,维护权限点定义、角色权限分配、当前用户菜单动作权限和权限策略说明。 |
|
||||
| `src/modules/shifts/` | 排班模块,负责后台排班管理和员工端排班查询。 |
|
||||
| `src/modules/tasks/` | 任务模块,负责后台任务管理、员工端任务处理和任务事件日志。 |
|
||||
| `src/shared/` | 跨模块复用的响应结构和业务错误类型。 |
|
||||
| `docker-compose.yml` | 本地开发用 MySQL 容器配置。 |
|
||||
| `package.json` | 项目信息、依赖和常用脚本;脚本会读取现有 `.env.development`。 |
|
||||
@@ -172,6 +186,10 @@ pnpm db:migrate
|
||||
- `employee_roles`:员工角色关系表
|
||||
- `role_permissions`:角色权限关系表
|
||||
- `super_admins`:超级管理员表
|
||||
- `announcements` / `announcement_targets` / `announcement_reads`:公告、目标范围和已读记录。
|
||||
- `tasks` / `task_assignees` / `task_events`:任务、分配员工和处理日志。
|
||||
- `shifts`:员工排班表。
|
||||
- `credential_audits` / `employee_password_states`:凭据操作审计和员工改密状态。
|
||||
- `schema_migrations`:迁移记录表
|
||||
|
||||
3. 启动后端:
|
||||
@@ -301,7 +319,7 @@ pnpm dev
|
||||
|
||||
## 接口文档
|
||||
|
||||
完整前端对接文档见 [docs/API.md](./docs/API.md),包含认证、权限、字段约束、全部接口、示例请求响应和常见错误码。
|
||||
完整前端对接文档见 [docs/API.md](./docs/API.md),包含认证、权限、字段约束、全部接口、示例请求响应、常见错误码和 C 端正式版新增模块说明。
|
||||
|
||||
## 接口响应格式
|
||||
|
||||
@@ -646,6 +664,7 @@ curl -X DELETE http://localhost:3500/api/employees/1 \
|
||||
- [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` 级联删除语义。
|
||||
- [008_add_role_user_workbench_tables.sql](./migrations/008_add_role_user_workbench_tables.sql):新增公告、公告目标、公告已读、任务、任务分配、任务事件、排班、凭据审计和员工密码状态表,并初始化工作台相关权限。
|
||||
|
||||
执行 `pnpm db:migrate` 或 `pnpm db:migrate:prod` 时,脚本会:
|
||||
|
||||
@@ -670,6 +689,8 @@ migrations/003_add_employee_email.sql
|
||||
- `employees.password_hash` 让员工也能登录,默认本地密码是 `pw111111`。
|
||||
- `employee_roles` 是员工和角色的多对多关系表,解绑时写入 `deleted_at`。
|
||||
- `role_permissions` 保存角色和权限点的多对多关系,权限解绑时写入 `deleted_at`,权限分配保存后会在接口鉴权时实时生效。
|
||||
- `announcements`、`tasks`、`shifts` 支撑员工端工作台和后台管理工作流。
|
||||
- `credential_audits` 只记录凭据操作,不记录明文密码;临时密码只在重置接口响应中返回一次。
|
||||
- `super_admins` 保存超级管理员账号,密码使用 PBKDF2 哈希,禁止存明文。
|
||||
- 权限点定义由 `src/modules/permissions/` 固定,角色拥有的权限点由 `role_permissions` 动态决定。
|
||||
- 前端根据 `/api/permissions/me` 渲染菜单和按钮,根据 `/api/permissions/definitions` 渲染可分配权限点。
|
||||
|
||||
+301
-4
@@ -113,9 +113,17 @@ pw111111
|
||||
| `role:manage` | 新增、修改、软删除自定义角色 |
|
||||
| `employee:view:all` | 查看全部门店员工 |
|
||||
| `employee:view:store` | 查看当前门店员工 |
|
||||
| `employee:manage` | 新增、修改、启停、移除、软删除员工和维护密码 |
|
||||
| `employee:manage` | 新增、修改、启停、移除和软删除员工 |
|
||||
| `permission:view` | 查看权限策略 |
|
||||
| `permission:manage` | 分配角色权限 |
|
||||
| `announcement:view` | 查看公告 |
|
||||
| `announcement:manage` | 新增、编辑、发布、归档公告 |
|
||||
| `task:view` | 查看任务 |
|
||||
| `task:manage` | 新建、编辑、取消任务 |
|
||||
| `shift:view` | 查看排班 |
|
||||
| `shift:manage` | 新增、编辑、取消排班 |
|
||||
| `credential:reset` | 重置下级员工密码 |
|
||||
| `credential:audit:view` | 查看凭据操作审计 |
|
||||
|
||||
### 角色权限
|
||||
|
||||
@@ -149,6 +157,7 @@ interface AuthUser {
|
||||
}>;
|
||||
permissions: string[];
|
||||
canManage: boolean;
|
||||
mustChangePassword?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -241,6 +250,7 @@ interface PermissionMenu {
|
||||
| `POST` | `/api/auth/admin/login` | 否 | 无 | 后台登录 |
|
||||
| `POST` | `/api/auth/employee/login` | 否 | 无 | 员工端登录 |
|
||||
| `GET` | `/api/auth/me` | 是 | 登录即可 | 当前用户 |
|
||||
| `PATCH` | `/api/auth/me/password` | 是 | 登录即可 | 修改本人密码 |
|
||||
| `GET` | `/api/permissions/me` | 是 | 登录即可 | 当前用户权限和菜单 |
|
||||
| `GET` | `/api/permissions/policies` | 是 | `permission:view` | 角色权限策略 |
|
||||
| `GET` | `/api/permissions/definitions` | 是 | `permission:view` | 可分配权限点定义 |
|
||||
@@ -263,8 +273,34 @@ interface PermissionMenu {
|
||||
| `PATCH` | `/api/employees/:id` | 是 | `employee:manage` | 修改员工 |
|
||||
| `PATCH` | `/api/employees/:id/status` | 是 | `employee:manage` | 修改员工状态 |
|
||||
| `PATCH` | `/api/employees/:id/password` | 是 | `employee:manage` | 修改员工密码 |
|
||||
| `PATCH` | `/api/employees/:id/password/reset` | 是 | `employee:manage` | 重置员工为初始密码 |
|
||||
| `PATCH` | `/api/employees/:id/password/reset` | 是 | `credential:reset` | 兼容旧路径,重置临时密码 |
|
||||
| `DELETE` | `/api/employees/:id` | 是 | `employee:manage` | 软删除员工 |
|
||||
| `GET` | `/api/mobile/bootstrap` | 是 | 登录即可 | 员工端首屏聚合 |
|
||||
| `GET` | `/api/mobile/announcements` | 是 | 登录即可 | 当前员工可见公告 |
|
||||
| `GET` | `/api/mobile/announcements/:id` | 是 | 登录即可 | 员工端公告详情 |
|
||||
| `POST` | `/api/mobile/announcements/:id/read` | 是 | 登录即可 | 标记公告已读 |
|
||||
| `GET` | `/api/mobile/tasks` | 是 | 登录即可 | 当前员工任务 |
|
||||
| `GET` | `/api/mobile/tasks/:id` | 是 | 登录即可 | 员工端任务详情 |
|
||||
| `POST` | `/api/mobile/tasks/:id/start` | 是 | 登录即可 | 开始处理任务 |
|
||||
| `POST` | `/api/mobile/tasks/:id/complete` | 是 | 登录即可 | 完成任务 |
|
||||
| `POST` | `/api/mobile/tasks/:id/comment` | 是 | 登录即可 | 添加任务备注 |
|
||||
| `GET` | `/api/mobile/shifts` | 是 | 登录即可 | 当前员工排班 |
|
||||
| `GET` | `/api/mobile/shifts/today` | 是 | 登录即可 | 当前员工今日排班 |
|
||||
| `GET` | `/api/admin/announcements` | 是 | `announcement:view` | 后台公告分页 |
|
||||
| `POST` | `/api/admin/announcements` | 是 | `announcement:manage` | 新建公告 |
|
||||
| `PATCH` | `/api/admin/announcements/:id` | 是 | `announcement:manage` | 编辑公告 |
|
||||
| `POST` | `/api/admin/announcements/:id/publish` | 是 | `announcement:manage` | 发布公告 |
|
||||
| `POST` | `/api/admin/announcements/:id/archive` | 是 | `announcement:manage` | 归档公告 |
|
||||
| `GET` | `/api/admin/tasks` | 是 | `task:view` | 后台任务分页 |
|
||||
| `POST` | `/api/admin/tasks` | 是 | `task:manage` | 新建任务 |
|
||||
| `PATCH` | `/api/admin/tasks/:id` | 是 | `task:manage` | 编辑任务 |
|
||||
| `POST` | `/api/admin/tasks/:id/cancel` | 是 | `task:manage` | 取消任务 |
|
||||
| `GET` | `/api/admin/shifts` | 是 | `shift:view` | 后台排班分页 |
|
||||
| `POST` | `/api/admin/shifts` | 是 | `shift:manage` | 新增排班 |
|
||||
| `PATCH` | `/api/admin/shifts/:id` | 是 | `shift:manage` | 编辑排班 |
|
||||
| `DELETE` | `/api/admin/shifts/:id` | 是 | `shift:manage` | 取消排班 |
|
||||
| `POST` | `/api/admin/employees/:id/password/reset` | 是 | `credential:reset` | 重置员工临时密码 |
|
||||
| `GET` | `/api/admin/credential-audits` | 是 | `credential:audit:view` | 凭据审计分页 |
|
||||
|
||||
## 健康检查
|
||||
|
||||
@@ -1194,7 +1230,7 @@ Authorization: Bearer <token>
|
||||
|
||||
### PATCH /api/employees/:id/password/reset
|
||||
|
||||
重置员工密码为初始密码 `pw111111`。需要 `employee:manage`。
|
||||
兼容旧路径。重置员工临时密码。需要 `credential:reset`。
|
||||
|
||||
路径参数:
|
||||
|
||||
@@ -1204,7 +1240,268 @@ Authorization: Bearer <token>
|
||||
|
||||
请求体:不需要请求体。
|
||||
|
||||
响应 `data` 为 `Employee`,不会返回密码或密码哈希。
|
||||
响应 `data.temporaryPassword` 只返回一次,后端只保存哈希,并把员工标记为必须改密。
|
||||
|
||||
## C 端员工接口
|
||||
|
||||
所有 `/api/mobile/*` 接口都只从 Bearer token 推导当前员工,不接受客户端传入 `employeeId` 做越权查询。
|
||||
|
||||
### GET /api/mobile/bootstrap
|
||||
|
||||
员工端首屏聚合,返回当前员工、门店、权限、未读公告数、待办任务数、逾期任务数、最近公告、待办任务和今日排班。
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"user": {
|
||||
"id": 2,
|
||||
"username": "13800000000",
|
||||
"displayName": "张三",
|
||||
"accountType": "EMPLOYEE",
|
||||
"storeId": 1,
|
||||
"storeName": "示例门店",
|
||||
"roles": [{ "id": 1, "code": "cashier", "name": "收银员" }],
|
||||
"permissions": ["task:view"],
|
||||
"mustChangePassword": false
|
||||
},
|
||||
"store": {
|
||||
"id": 1,
|
||||
"name": "示例门店"
|
||||
},
|
||||
"permissions": {
|
||||
"codes": ["task:view"],
|
||||
"menus": []
|
||||
},
|
||||
"counters": {
|
||||
"unreadAnnouncementCount": 2,
|
||||
"pendingTaskCount": 3,
|
||||
"overdueTaskCount": 1
|
||||
},
|
||||
"latestAnnouncements": [
|
||||
{
|
||||
"id": 10,
|
||||
"title": "端午排班通知",
|
||||
"content": "请按新排班表执行。",
|
||||
"level": "IMPORTANT",
|
||||
"status": "PUBLISHED",
|
||||
"targetType": "STORE",
|
||||
"publishedAt": "2026-06-01T02:00:00.000Z",
|
||||
"readAt": null,
|
||||
"createdAt": "2026-06-01T01:00:00.000Z",
|
||||
"updatedAt": "2026-06-01T02:00:00.000Z",
|
||||
"targets": []
|
||||
}
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"id": 20,
|
||||
"storeId": 1,
|
||||
"storeName": "示例门店",
|
||||
"title": "检查库存",
|
||||
"description": "盘点饮料区库存",
|
||||
"status": "PENDING",
|
||||
"priority": "NORMAL",
|
||||
"dueAt": "2026-06-01T10:00:00.000Z",
|
||||
"assignees": [{ "id": 2, "name": "张三", "phone": "13800000000" }],
|
||||
"createdAt": "2026-06-01T01:00:00.000Z",
|
||||
"updatedAt": "2026-06-01T01:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"todayShifts": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
- `latestAnnouncements` 最多 3 条,按发布时间倒序返回当前员工可见的已发布公告。
|
||||
- `tasks` 最多 5 条,按截止时间优先返回当前员工被分配的 `PENDING`、`IN_PROGRESS` 任务。
|
||||
- `pendingTaskCount` 和 `overdueTaskCount` 当前都只统计已分配给当前员工的未完成任务;暂不把“门店级未指派任务”自动展开给全店员工。
|
||||
- `todayShifts` 保持数组结构,返回当前员工今日 `SCHEDULED` 班次。
|
||||
|
||||
### GET /api/mobile/announcements
|
||||
|
||||
当前员工可见公告分页。支持 `status`、`keyword`、`page`、`pageSize` 查询参数;员工端只返回已发布且命中全员、门店、角色或员工目标的公告。
|
||||
|
||||
### GET /api/mobile/announcements/:id
|
||||
|
||||
当前员工可见公告详情。不可见公告按不存在处理。
|
||||
|
||||
### POST /api/mobile/announcements/:id/read
|
||||
|
||||
标记当前员工已读公告。
|
||||
|
||||
### GET /api/mobile/tasks
|
||||
|
||||
当前员工任务分页。支持 `status`、`keyword`、`page`、`pageSize`。当前版本只返回已分配给当前员工的任务,门店级未指派任务不自动出现在员工端。
|
||||
|
||||
### GET /api/mobile/tasks/:id
|
||||
|
||||
当前员工被分配的任务详情,包含任务事件日志。
|
||||
|
||||
### POST /api/mobile/tasks/:id/start
|
||||
|
||||
开始处理当前员工被分配的待处理任务。
|
||||
|
||||
### POST /api/mobile/tasks/:id/complete
|
||||
|
||||
完成当前员工被分配的待处理或处理中任务。
|
||||
|
||||
### POST /api/mobile/tasks/:id/comment
|
||||
|
||||
为当前员工被分配的任务追加备注。
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"comment": "已完成交接"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/mobile/shifts
|
||||
|
||||
当前员工排班分页。支持 `status`、`startDate`、`endDate`、`page`、`pageSize`,日期格式为 `YYYY-MM-DD`。
|
||||
|
||||
### GET /api/mobile/shifts/today
|
||||
|
||||
当前员工今日有效排班。
|
||||
|
||||
## 后台工作台接口
|
||||
|
||||
### 公告管理
|
||||
|
||||
- `GET /api/admin/announcements`:公告分页,需要 `announcement:view`。
|
||||
- `POST /api/admin/announcements`:新建公告,需要 `announcement:manage`。
|
||||
- `PATCH /api/admin/announcements/:id`:编辑公告,需要 `announcement:manage`。
|
||||
- `POST /api/admin/announcements/:id/publish`:发布公告,需要 `announcement:manage`。
|
||||
- `POST /api/admin/announcements/:id/archive`:归档公告,需要 `announcement:manage`。
|
||||
|
||||
公告请求体核心字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "端午排班通知",
|
||||
"content": "请按新排班表执行。",
|
||||
"level": "IMPORTANT",
|
||||
"targetType": "STORE",
|
||||
"targets": [{ "type": "STORE", "id": 1 }]
|
||||
}
|
||||
```
|
||||
|
||||
`targetType` 可选 `ALL`、`STORE`、`ROLE`、`EMPLOYEE`。`ALL` 时 `targets` 必须为空,其他范围必须提交同类型目标。
|
||||
|
||||
### 任务管理
|
||||
|
||||
- `GET /api/admin/tasks`:任务分页,需要 `task:view`。
|
||||
- `POST /api/admin/tasks`:新建任务,需要 `task:manage`。
|
||||
- `PATCH /api/admin/tasks/:id`:编辑任务,需要 `task:manage`。
|
||||
- `POST /api/admin/tasks/:id/cancel`:取消任务,需要 `task:manage`。
|
||||
|
||||
任务请求体核心字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"storeId": 1,
|
||||
"title": "检查库存",
|
||||
"description": "盘点饮料区库存",
|
||||
"priority": "NORMAL",
|
||||
"dueAt": "2026-06-01T10:00:00.000Z",
|
||||
"assigneeIds": [2, 3]
|
||||
}
|
||||
```
|
||||
|
||||
### 排班管理
|
||||
|
||||
- `GET /api/admin/shifts`:排班分页,需要 `shift:view`。
|
||||
- `POST /api/admin/shifts`:新增排班,需要 `shift:manage`。
|
||||
- `PATCH /api/admin/shifts/:id`:编辑排班,需要 `shift:manage`。
|
||||
- `DELETE /api/admin/shifts/:id`:取消排班,需要 `shift:manage`。
|
||||
|
||||
排班请求体核心字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"storeId": 1,
|
||||
"employeeId": 2,
|
||||
"roleName": "收银",
|
||||
"startAt": "2026-06-01T01:00:00.000Z",
|
||||
"endAt": "2026-06-01T09:00:00.000Z",
|
||||
"status": "SCHEDULED"
|
||||
}
|
||||
```
|
||||
|
||||
排班冲突规则:
|
||||
|
||||
- `POST /api/admin/shifts` 和 `PATCH /api/admin/shifts/:id` 都会校验同一员工在同一时间段不能存在重叠的未取消班次。
|
||||
- 时间段重叠按 `existing.startAt < new.endAt && existing.endAt > new.startAt` 判断,首尾相接不算冲突。
|
||||
- 编辑排班时会排除当前排班自身;状态为 `CANCELLED` 的班次不参与冲突校验。
|
||||
- 发生冲突时返回 `409 CONFLICT`。
|
||||
|
||||
## 凭据安全接口
|
||||
|
||||
后端不提供任何明文密码查看接口。员工密码只保存哈希。
|
||||
|
||||
### PATCH /api/auth/me/password
|
||||
|
||||
当前员工修改本人密码,需要提交旧密码和新密码。成功后清除 `mustChangePassword` 状态,并写入 `credential_audits` 审计。
|
||||
|
||||
### POST /api/admin/employees/:id/password/reset
|
||||
|
||||
重置权限范围内员工的临时密码,需要 `credential:reset`。响应中的 `temporaryPassword` 只返回一次。
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"employee": {},
|
||||
"temporaryPassword": "Tmp-xxxxxx9",
|
||||
"mustChangePassword": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/admin/credential-audits
|
||||
|
||||
凭据审计分页,需要 `credential:audit:view`。
|
||||
|
||||
查询参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `operatorId` | `number` | 否 | 无 | 操作者 ID,同时匹配 `actorAdminId` 或 `actorEmployeeId` |
|
||||
| `targetEmployeeId` | `number` | 否 | 无 | 被操作员工 ID |
|
||||
| `storeId` | `number` | 否 | 无 | 被操作员工所属门店 ID |
|
||||
| `startDate` | `string` | 否 | 无 | 操作开始日期,格式 `YYYY-MM-DD`,包含当天 |
|
||||
| `endDate` | `string` | 否 | 无 | 操作结束日期,格式 `YYYY-MM-DD`,包含当天 |
|
||||
| `page` | `number` | 否 | `1` | 页码 |
|
||||
| `pageSize` | `number` | 否 | `20` | 每页数量,最大 100 |
|
||||
|
||||
店长等只有当前门店员工数据范围的账号会被强制限定在自己的 `storeId`。`operatorId` 不区分超级管理员和员工操作者,后端会同时匹配 `actorAdminId` 和 `actorEmployeeId`。
|
||||
|
||||
响应 `items` 字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"actorAccountType": "SUPER_ADMIN",
|
||||
"actorAdminId": 1,
|
||||
"actorEmployeeId": null,
|
||||
"actorName": "超级管理员",
|
||||
"targetEmployeeId": 2,
|
||||
"targetEmployeeName": "张三",
|
||||
"targetEmployeePhone": "13800000000",
|
||||
"storeName": "示例门店",
|
||||
"action": "RESET_PASSWORD",
|
||||
"reason": "员工忘记密码",
|
||||
"ip": "127.0.0.1",
|
||||
"userAgent": "Mozilla/5.0",
|
||||
"createdAt": "2026-06-01T08:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### DELETE /api/employees/:id
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
-- 008_add_role_user_workbench_tables.sql
|
||||
-- C 端正式版第一批能力:公告、任务、排班、凭据审计和临时密码状态。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS announcements (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
title VARCHAR(120) NOT NULL COMMENT '公告标题',
|
||||
content TEXT NOT NULL COMMENT '公告正文',
|
||||
level ENUM('NORMAL', 'IMPORTANT', 'URGENT') NOT NULL DEFAULT 'NORMAL' COMMENT '重要级别',
|
||||
status ENUM('DRAFT', 'PUBLISHED', 'ARCHIVED') NOT NULL DEFAULT 'DRAFT' COMMENT '发布状态',
|
||||
target_type ENUM('ALL', 'STORE', 'ROLE', 'EMPLOYEE') NOT NULL DEFAULT 'ALL' COMMENT '目标范围',
|
||||
publisher_admin_id INT UNSIGNED NULL COMMENT '发布超级管理员',
|
||||
publisher_employee_id INT UNSIGNED NULL COMMENT '发布员工',
|
||||
published_at DATETIME(3) NULL COMMENT '发布时间',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
deleted_at DATETIME(3) NULL COMMENT '软删除时间',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_announcements_status_published_at (status, published_at),
|
||||
KEY idx_announcements_target_type (target_type),
|
||||
KEY idx_announcements_deleted_at (deleted_at),
|
||||
CONSTRAINT fk_announcements_publisher_admin_id FOREIGN KEY (publisher_admin_id) REFERENCES super_admins (id),
|
||||
CONSTRAINT fk_announcements_publisher_employee_id FOREIGN KEY (publisher_employee_id) REFERENCES employees (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='公告表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS announcement_targets (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
announcement_id INT UNSIGNED NOT NULL,
|
||||
target_type ENUM('STORE', 'ROLE', 'EMPLOYEE') NOT NULL,
|
||||
target_id INT UNSIGNED NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_announcement_targets_scope (announcement_id, target_type, target_id),
|
||||
KEY idx_announcement_targets_target (target_type, target_id),
|
||||
CONSTRAINT fk_announcement_targets_announcement_id FOREIGN KEY (announcement_id) REFERENCES announcements (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='公告目标表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS announcement_reads (
|
||||
announcement_id INT UNSIGNED NOT NULL,
|
||||
employee_id INT UNSIGNED NOT NULL,
|
||||
read_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (announcement_id, employee_id),
|
||||
KEY idx_announcement_reads_employee_id (employee_id),
|
||||
CONSTRAINT fk_announcement_reads_announcement_id FOREIGN KEY (announcement_id) REFERENCES announcements (id),
|
||||
CONSTRAINT fk_announcement_reads_employee_id FOREIGN KEY (employee_id) REFERENCES employees (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='公告已读记录表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
store_id INT UNSIGNED NULL COMMENT '任务所属门店,NULL 表示跨门店',
|
||||
title VARCHAR(120) NOT NULL COMMENT '任务标题',
|
||||
description TEXT NULL COMMENT '任务说明',
|
||||
status ENUM('PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED') NOT NULL DEFAULT 'PENDING',
|
||||
priority ENUM('LOW', 'NORMAL', 'HIGH', 'URGENT') NOT NULL DEFAULT 'NORMAL',
|
||||
due_at DATETIME(3) NULL COMMENT '截止时间',
|
||||
creator_admin_id INT UNSIGNED NULL COMMENT '创建超级管理员',
|
||||
creator_employee_id INT UNSIGNED NULL COMMENT '创建员工',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
deleted_at DATETIME(3) NULL COMMENT '软删除时间',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_tasks_store_status (store_id, status),
|
||||
KEY idx_tasks_status_due_at (status, due_at),
|
||||
KEY idx_tasks_deleted_at (deleted_at),
|
||||
CONSTRAINT fk_tasks_store_id FOREIGN KEY (store_id) REFERENCES stores (id),
|
||||
CONSTRAINT fk_tasks_creator_admin_id FOREIGN KEY (creator_admin_id) REFERENCES super_admins (id),
|
||||
CONSTRAINT fk_tasks_creator_employee_id FOREIGN KEY (creator_employee_id) REFERENCES employees (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='任务表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_assignees (
|
||||
task_id INT UNSIGNED NOT NULL,
|
||||
employee_id INT UNSIGNED NOT NULL,
|
||||
assigned_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (task_id, employee_id),
|
||||
KEY idx_task_assignees_employee_id (employee_id),
|
||||
CONSTRAINT fk_task_assignees_task_id FOREIGN KEY (task_id) REFERENCES tasks (id),
|
||||
CONSTRAINT fk_task_assignees_employee_id FOREIGN KEY (employee_id) REFERENCES employees (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='任务分配表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_events (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
task_id INT UNSIGNED NOT NULL,
|
||||
employee_id INT UNSIGNED NULL,
|
||||
actor_admin_id INT UNSIGNED NULL,
|
||||
actor_employee_id INT UNSIGNED NULL,
|
||||
event_type ENUM('CREATED', 'UPDATED', 'STARTED', 'COMPLETED', 'CANCELLED', 'COMMENTED') NOT NULL,
|
||||
from_status ENUM('PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED') NULL,
|
||||
to_status ENUM('PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED') NULL,
|
||||
comment VARCHAR(1000) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_task_events_task_id (task_id, created_at),
|
||||
KEY idx_task_events_employee_id (employee_id),
|
||||
CONSTRAINT fk_task_events_task_id FOREIGN KEY (task_id) REFERENCES tasks (id),
|
||||
CONSTRAINT fk_task_events_employee_id FOREIGN KEY (employee_id) REFERENCES employees (id),
|
||||
CONSTRAINT fk_task_events_actor_admin_id FOREIGN KEY (actor_admin_id) REFERENCES super_admins (id),
|
||||
CONSTRAINT fk_task_events_actor_employee_id FOREIGN KEY (actor_employee_id) REFERENCES employees (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='任务事件表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shifts (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
store_id INT UNSIGNED NOT NULL,
|
||||
employee_id INT UNSIGNED NOT NULL,
|
||||
role_name VARCHAR(50) NULL COMMENT '班次岗位',
|
||||
start_at DATETIME(3) NOT NULL,
|
||||
end_at DATETIME(3) NOT NULL,
|
||||
status ENUM('SCHEDULED', 'CANCELLED') NOT NULL DEFAULT 'SCHEDULED',
|
||||
creator_admin_id INT UNSIGNED NULL,
|
||||
creator_employee_id INT UNSIGNED NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
deleted_at DATETIME(3) NULL COMMENT '软删除时间',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_shifts_employee_start (employee_id, start_at),
|
||||
KEY idx_shifts_store_start (store_id, start_at),
|
||||
KEY idx_shifts_deleted_at (deleted_at),
|
||||
CONSTRAINT fk_shifts_store_id FOREIGN KEY (store_id) REFERENCES stores (id),
|
||||
CONSTRAINT fk_shifts_employee_id FOREIGN KEY (employee_id) REFERENCES employees (id),
|
||||
CONSTRAINT fk_shifts_creator_admin_id FOREIGN KEY (creator_admin_id) REFERENCES super_admins (id),
|
||||
CONSTRAINT fk_shifts_creator_employee_id FOREIGN KEY (creator_employee_id) REFERENCES employees (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='排班表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS credential_audits (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
actor_account_type ENUM('SUPER_ADMIN', 'EMPLOYEE') NOT NULL,
|
||||
actor_admin_id INT UNSIGNED NULL,
|
||||
actor_employee_id INT UNSIGNED NULL,
|
||||
target_employee_id INT UNSIGNED NOT NULL,
|
||||
action ENUM('RESET_PASSWORD', 'CHANGE_OWN_PASSWORD') NOT NULL,
|
||||
reason VARCHAR(500) NULL,
|
||||
ip VARCHAR(64) NULL,
|
||||
user_agent VARCHAR(500) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_credential_audits_target_created (target_employee_id, created_at),
|
||||
KEY idx_credential_audits_actor_employee_id (actor_employee_id),
|
||||
CONSTRAINT fk_credential_audits_actor_admin_id FOREIGN KEY (actor_admin_id) REFERENCES super_admins (id),
|
||||
CONSTRAINT fk_credential_audits_actor_employee_id FOREIGN KEY (actor_employee_id) REFERENCES employees (id),
|
||||
CONSTRAINT fk_credential_audits_target_employee_id FOREIGN KEY (target_employee_id) REFERENCES employees (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='凭据操作审计表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS employee_password_states (
|
||||
employee_id INT UNSIGNED NOT NULL,
|
||||
must_change_password TINYINT(1) NOT NULL DEFAULT 0,
|
||||
last_reset_at DATETIME(3) NULL,
|
||||
last_reset_by_admin_id INT UNSIGNED NULL,
|
||||
last_reset_by_employee_id INT UNSIGNED NULL,
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (employee_id),
|
||||
CONSTRAINT fk_employee_password_states_employee_id FOREIGN KEY (employee_id) REFERENCES employees (id),
|
||||
CONSTRAINT fk_employee_password_states_admin_id FOREIGN KEY (last_reset_by_admin_id) REFERENCES super_admins (id),
|
||||
CONSTRAINT fk_employee_password_states_employee_actor_id FOREIGN KEY (last_reset_by_employee_id) REFERENCES employees (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='员工密码状态表';
|
||||
|
||||
INSERT IGNORE INTO role_permissions (role_id, permission_code)
|
||||
SELECT r.id, p.permission_code
|
||||
FROM roles r
|
||||
INNER JOIN (
|
||||
SELECT 'admin' AS role_code, 'announcement:view' AS permission_code
|
||||
UNION ALL SELECT 'admin', 'announcement:manage'
|
||||
UNION ALL SELECT 'admin', 'task:view'
|
||||
UNION ALL SELECT 'admin', 'task:manage'
|
||||
UNION ALL SELECT 'admin', 'shift:view'
|
||||
UNION ALL SELECT 'admin', 'shift:manage'
|
||||
UNION ALL SELECT 'admin', 'credential:reset'
|
||||
UNION ALL SELECT 'admin', 'credential:audit:view'
|
||||
UNION ALL SELECT 'store_manager', 'announcement:view'
|
||||
UNION ALL SELECT 'store_manager', 'task:view'
|
||||
UNION ALL SELECT 'store_manager', 'shift:view'
|
||||
) p ON p.role_code = r.code;
|
||||
+13
@@ -5,9 +5,14 @@ import { env } from "./config/env";
|
||||
import { pingDatabase } from "./db/pool";
|
||||
import { authRoutes } from "./modules/auth/auth.controller";
|
||||
import { managementGuard } from "./modules/auth/auth.guard";
|
||||
import { announcementAdminRoutes, announcementMobileRoutes } from "./modules/announcements/announcement.controller";
|
||||
import { catalogRoutes } from "./modules/catalog/catalog.controller";
|
||||
import { credentialAdminRoutes } from "./modules/credentials/credential.controller";
|
||||
import { employeeRoutes } from "./modules/employees/employee.controller";
|
||||
import { mobileRoutes } from "./modules/mobile/mobile.controller";
|
||||
import { permissionRoutes } from "./modules/permissions/permission.controller";
|
||||
import { shiftAdminRoutes, shiftMobileRoutes } from "./modules/shifts/shift.controller";
|
||||
import { taskAdminRoutes, taskMobileRoutes } from "./modules/tasks/task.controller";
|
||||
import { HttpError } from "./shared/http-error";
|
||||
import { ok } from "./shared/response";
|
||||
|
||||
@@ -58,6 +63,10 @@ export function createApp() {
|
||||
// 登录接口不需要 token;/auth/me 在 authRoutes 内部单独加了 authGuard。
|
||||
app.register(authRoutes, { prefix: "/api" });
|
||||
app.register(permissionRoutes, { prefix: "/api" });
|
||||
app.register(mobileRoutes, { prefix: "/api" });
|
||||
app.register(announcementMobileRoutes, { prefix: "/api" });
|
||||
app.register(taskMobileRoutes, { prefix: "/api" });
|
||||
app.register(shiftMobileRoutes, { prefix: "/api" });
|
||||
|
||||
// 业务管理接口统一要求后台权限:超级管理员或拥有 admin 角色的员工。
|
||||
app.register(
|
||||
@@ -65,6 +74,10 @@ export function createApp() {
|
||||
protectedApp.addHook("preHandler", managementGuard);
|
||||
protectedApp.register(catalogRoutes);
|
||||
protectedApp.register(employeeRoutes);
|
||||
protectedApp.register(announcementAdminRoutes);
|
||||
protectedApp.register(taskAdminRoutes);
|
||||
protectedApp.register(shiftAdminRoutes);
|
||||
protectedApp.register(credentialAdminRoutes);
|
||||
},
|
||||
{ prefix: "/api" },
|
||||
);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { created, ok, paginated } from "../../shared/response";
|
||||
import { authGuard, permissionGuard } from "../auth/auth.guard";
|
||||
import { authService } from "../auth/auth.service";
|
||||
import { PERMISSIONS } from "../permissions/permission.policy";
|
||||
import { announcementService } from "./announcement.service";
|
||||
import {
|
||||
announcementIdParamSchema,
|
||||
createAnnouncementBodySchema,
|
||||
listAnnouncementsQuerySchema,
|
||||
updateAnnouncementBodySchema,
|
||||
} from "./announcement.schema";
|
||||
|
||||
export async function announcementAdminRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/admin/announcements", { preHandler: permissionGuard(PERMISSIONS.ANNOUNCEMENT_VIEW) }, async (request) => {
|
||||
const query = listAnnouncementsQuerySchema.parse(request.query);
|
||||
const result = await announcementService.list(query);
|
||||
return paginated(result.items, query.page, query.pageSize, result.total);
|
||||
});
|
||||
|
||||
app.post("/admin/announcements", { preHandler: permissionGuard(PERMISSIONS.ANNOUNCEMENT_MANAGE) }, async (request, reply) => {
|
||||
const user = await authService.getCurrentUser(request.user);
|
||||
const body = createAnnouncementBodySchema.parse(request.body);
|
||||
const announcement = await announcementService.create(body, user);
|
||||
return reply.code(201).send(created(announcement));
|
||||
});
|
||||
|
||||
app.patch("/admin/announcements/:id", { preHandler: permissionGuard(PERMISSIONS.ANNOUNCEMENT_MANAGE) }, async (request) => {
|
||||
const params = announcementIdParamSchema.parse(request.params);
|
||||
const body = updateAnnouncementBodySchema.parse(request.body);
|
||||
return ok(await announcementService.update(params.id, body));
|
||||
});
|
||||
|
||||
app.post("/admin/announcements/:id/publish", { preHandler: permissionGuard(PERMISSIONS.ANNOUNCEMENT_MANAGE) }, async (request) => {
|
||||
const params = announcementIdParamSchema.parse(request.params);
|
||||
return ok(await announcementService.publish(params.id));
|
||||
});
|
||||
|
||||
app.post("/admin/announcements/:id/archive", { preHandler: permissionGuard(PERMISSIONS.ANNOUNCEMENT_MANAGE) }, async (request) => {
|
||||
const params = announcementIdParamSchema.parse(request.params);
|
||||
return ok(await announcementService.archive(params.id));
|
||||
});
|
||||
}
|
||||
|
||||
export async function announcementMobileRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/mobile/announcements", { preHandler: authGuard }, async (request) => {
|
||||
const employee = await authService.getCurrentEmployeeUser(request.user);
|
||||
const query = listAnnouncementsQuerySchema.parse(request.query);
|
||||
const result = await announcementService.listVisibleForEmployee(employee, query);
|
||||
return paginated(result.items, query.page, query.pageSize, result.total);
|
||||
});
|
||||
|
||||
app.get("/mobile/announcements/:id", { preHandler: authGuard }, async (request) => {
|
||||
const employee = await authService.getCurrentEmployeeUser(request.user);
|
||||
const params = announcementIdParamSchema.parse(request.params);
|
||||
return ok(await announcementService.getVisibleByIdForEmployee(params.id, employee));
|
||||
});
|
||||
|
||||
app.post("/mobile/announcements/:id/read", { preHandler: authGuard }, async (request) => {
|
||||
const employee = await authService.getCurrentEmployeeUser(request.user);
|
||||
const params = announcementIdParamSchema.parse(request.params);
|
||||
return ok(await announcementService.markRead(params.id, employee));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import type { PoolConnection, ResultSetHeader, RowDataPacket } from "mysql2/promise";
|
||||
import { pool } from "../../db/pool";
|
||||
import type { AuthUser } from "../auth/auth.types";
|
||||
import type {
|
||||
Announcement,
|
||||
AnnouncementTargetInput,
|
||||
CreateAnnouncementInput,
|
||||
ListAnnouncementsQuery,
|
||||
UpdateAnnouncementInput,
|
||||
} from "./announcement.types";
|
||||
|
||||
type DbExecutor = typeof pool | PoolConnection;
|
||||
type SqlParam = string | number | Date | null;
|
||||
|
||||
interface AnnouncementRow extends RowDataPacket {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
level: Announcement["level"];
|
||||
status: Announcement["status"];
|
||||
target_type: Announcement["targetType"];
|
||||
published_at: Date | null;
|
||||
read_at?: Date | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
interface TargetRow extends RowDataPacket {
|
||||
announcement_id: number;
|
||||
target_type: AnnouncementTargetInput["type"];
|
||||
target_id: number;
|
||||
}
|
||||
|
||||
interface CountRow extends RowDataPacket {
|
||||
total: number;
|
||||
}
|
||||
|
||||
function toIso(value: Date | null): string | null {
|
||||
return value ? value.toISOString() : null;
|
||||
}
|
||||
|
||||
function toAnnouncement(row: AnnouncementRow, targets: AnnouncementTargetInput[] = []): Announcement {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
content: row.content,
|
||||
level: row.level,
|
||||
status: row.status,
|
||||
targetType: row.target_type,
|
||||
publishedAt: toIso(row.published_at),
|
||||
readAt: row.read_at === undefined ? undefined : toIso(row.read_at ?? null),
|
||||
createdAt: toIso(row.created_at) ?? "",
|
||||
updatedAt: toIso(row.updated_at) ?? "",
|
||||
targets,
|
||||
};
|
||||
}
|
||||
|
||||
function actorColumns(user: AuthUser): {
|
||||
publisherAdminId: number | null;
|
||||
publisherEmployeeId: number | null;
|
||||
} {
|
||||
return {
|
||||
publisherAdminId: user.accountType === "SUPER_ADMIN" ? user.id : null,
|
||||
publisherEmployeeId: user.accountType === "EMPLOYEE" ? user.id : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function findTargetsByAnnouncementIds(ids: number[], db: DbExecutor = pool) {
|
||||
const result = new Map<number, AnnouncementTargetInput[]>();
|
||||
|
||||
if (ids.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const [rows] = await db.execute<TargetRow[]>(
|
||||
`
|
||||
SELECT announcement_id, target_type, target_id
|
||||
FROM announcement_targets
|
||||
WHERE announcement_id IN (${ids.map(() => "?").join(", ")})
|
||||
ORDER BY id ASC
|
||||
`,
|
||||
ids,
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
const targets = result.get(row.announcement_id) ?? [];
|
||||
targets.push({ type: row.target_type, id: row.target_id });
|
||||
result.set(row.announcement_id, targets);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export const announcementRepository = {
|
||||
async withTransaction<T>(handler: (connection: PoolConnection) => Promise<T>): Promise<T> {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const result = await handler(connection);
|
||||
await connection.commit();
|
||||
return result;
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
},
|
||||
|
||||
async list(query: ListAnnouncementsQuery): Promise<{ items: Announcement[]; total: number }> {
|
||||
const where = ["deleted_at IS NULL"];
|
||||
const params: SqlParam[] = [];
|
||||
|
||||
if (query.status) {
|
||||
where.push("status = ?");
|
||||
params.push(query.status);
|
||||
}
|
||||
|
||||
if (query.keyword) {
|
||||
where.push("(title LIKE ? OR content LIKE ?)");
|
||||
params.push(`%${query.keyword}%`, `%${query.keyword}%`);
|
||||
}
|
||||
|
||||
const whereSql = where.join(" AND ");
|
||||
const offset = (query.page - 1) * query.pageSize;
|
||||
const [countRows] = await pool.execute<CountRow[]>(
|
||||
`SELECT COUNT(*) AS total FROM announcements WHERE ${whereSql}`,
|
||||
params,
|
||||
);
|
||||
const [rows] = await pool.execute<AnnouncementRow[]>(
|
||||
`
|
||||
SELECT id, title, content, level, status, target_type, published_at, created_at, updated_at
|
||||
FROM announcements
|
||||
WHERE ${whereSql}
|
||||
ORDER BY id DESC
|
||||
LIMIT ${query.pageSize} OFFSET ${offset}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
const targetsById = await findTargetsByAnnouncementIds(rows.map((row) => row.id));
|
||||
|
||||
return {
|
||||
items: rows.map((row) => toAnnouncement(row, targetsById.get(row.id) ?? [])),
|
||||
total: countRows[0]?.total ?? 0,
|
||||
};
|
||||
},
|
||||
|
||||
async findById(id: number, db: DbExecutor = pool): Promise<Announcement | null> {
|
||||
const [rows] = await db.execute<AnnouncementRow[]>(
|
||||
`
|
||||
SELECT id, title, content, level, status, target_type, published_at, created_at, updated_at
|
||||
FROM announcements
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
`,
|
||||
[id],
|
||||
);
|
||||
|
||||
if (!rows[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetsById = await findTargetsByAnnouncementIds([id], db);
|
||||
return toAnnouncement(rows[0], targetsById.get(id) ?? []);
|
||||
},
|
||||
|
||||
async create(input: CreateAnnouncementInput, user: AuthUser, db: DbExecutor = pool): Promise<number> {
|
||||
const actor = actorColumns(user);
|
||||
const [result] = await db.execute<ResultSetHeader>(
|
||||
`
|
||||
INSERT INTO announcements
|
||||
(title, content, level, target_type, publisher_admin_id, publisher_employee_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
input.title,
|
||||
input.content,
|
||||
input.level,
|
||||
input.targetType,
|
||||
actor.publisherAdminId,
|
||||
actor.publisherEmployeeId,
|
||||
],
|
||||
);
|
||||
|
||||
await this.replaceTargets(result.insertId, input.targets, db);
|
||||
return result.insertId;
|
||||
},
|
||||
|
||||
async update(id: number, input: UpdateAnnouncementInput, db: DbExecutor = pool): Promise<void> {
|
||||
const sets: string[] = [];
|
||||
const params: SqlParam[] = [];
|
||||
const fieldMap: Array<[keyof UpdateAnnouncementInput, string]> = [
|
||||
["title", "title"],
|
||||
["content", "content"],
|
||||
["level", "level"],
|
||||
["targetType", "target_type"],
|
||||
];
|
||||
|
||||
for (const [key, column] of fieldMap) {
|
||||
if (Object.prototype.hasOwnProperty.call(input, key)) {
|
||||
sets.push(`${column} = ?`);
|
||||
params.push(input[key] as SqlParam);
|
||||
}
|
||||
}
|
||||
|
||||
if (sets.length > 0) {
|
||||
params.push(id);
|
||||
await db.execute(
|
||||
`UPDATE announcements SET ${sets.join(", ")} WHERE id = ? AND deleted_at IS NULL`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.targets !== undefined) {
|
||||
await this.replaceTargets(id, input.targets, db);
|
||||
}
|
||||
},
|
||||
|
||||
async replaceTargets(id: number, targets: AnnouncementTargetInput[], db: DbExecutor = pool): Promise<void> {
|
||||
await db.execute("DELETE FROM announcement_targets WHERE announcement_id = ?", [id]);
|
||||
|
||||
if (targets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db.execute(
|
||||
`
|
||||
INSERT INTO announcement_targets (announcement_id, target_type, target_id)
|
||||
VALUES ${targets.map(() => "(?, ?, ?)").join(", ")}
|
||||
`,
|
||||
targets.flatMap((target) => [id, target.type, target.id]),
|
||||
);
|
||||
},
|
||||
|
||||
async setStatus(id: number, status: Announcement["status"]): Promise<void> {
|
||||
const publishedAtSql = status === "PUBLISHED" ? ", published_at = COALESCE(published_at, CURRENT_TIMESTAMP(3))" : "";
|
||||
await pool.execute(
|
||||
`
|
||||
UPDATE announcements
|
||||
SET status = ?${publishedAtSql}
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`,
|
||||
[status, id],
|
||||
);
|
||||
},
|
||||
|
||||
async listVisibleForEmployee(employeeId: number, storeId: number, roleCodes: string[], query: ListAnnouncementsQuery) {
|
||||
const rolePlaceholders = roleCodes.length > 0 ? roleCodes.map(() => "?").join(", ") : "NULL";
|
||||
const where = ["a.deleted_at IS NULL", "a.status = 'PUBLISHED'"];
|
||||
const whereParams: SqlParam[] = [];
|
||||
const visibilityParams: SqlParam[] = [storeId, employeeId, ...roleCodes];
|
||||
|
||||
if (query.keyword) {
|
||||
where.push("(a.title LIKE ? OR a.content LIKE ?)");
|
||||
whereParams.push(`%${query.keyword}%`, `%${query.keyword}%`);
|
||||
}
|
||||
|
||||
const visibilitySql = `
|
||||
(
|
||||
a.target_type = 'ALL'
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM announcement_targets at_store
|
||||
WHERE at_store.announcement_id = a.id
|
||||
AND at_store.target_type = 'STORE'
|
||||
AND at_store.target_id = ?
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM announcement_targets at_employee
|
||||
WHERE at_employee.announcement_id = a.id
|
||||
AND at_employee.target_type = 'EMPLOYEE'
|
||||
AND at_employee.target_id = ?
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM announcement_targets at_role
|
||||
INNER JOIN roles r ON r.id = at_role.target_id
|
||||
WHERE at_role.announcement_id = a.id
|
||||
AND at_role.target_type = 'ROLE'
|
||||
AND r.code IN (${rolePlaceholders})
|
||||
)
|
||||
)
|
||||
`;
|
||||
|
||||
const offset = (query.page - 1) * query.pageSize;
|
||||
const [countRows] = await pool.execute<CountRow[]>(
|
||||
`
|
||||
SELECT COUNT(*) AS total
|
||||
FROM announcements a
|
||||
WHERE ${where.join(" AND ")} AND ${visibilitySql}
|
||||
`,
|
||||
[...whereParams, ...visibilityParams],
|
||||
);
|
||||
const [rows] = await pool.execute<AnnouncementRow[]>(
|
||||
`
|
||||
SELECT a.id, a.title, a.content, a.level, a.status, a.target_type, a.published_at,
|
||||
ar.read_at, a.created_at, a.updated_at
|
||||
FROM announcements a
|
||||
LEFT JOIN announcement_reads ar ON ar.announcement_id = a.id AND ar.employee_id = ?
|
||||
WHERE ${where.join(" AND ")} AND ${visibilitySql}
|
||||
ORDER BY a.published_at DESC, a.id DESC
|
||||
LIMIT ${query.pageSize} OFFSET ${offset}
|
||||
`,
|
||||
[employeeId, ...whereParams, ...visibilityParams],
|
||||
);
|
||||
|
||||
return {
|
||||
items: rows.map((row) => toAnnouncement(row)),
|
||||
total: countRows[0]?.total ?? 0,
|
||||
};
|
||||
},
|
||||
|
||||
async findVisibleByIdForEmployee(id: number, employeeId: number, storeId: number, roleCodes: string[]) {
|
||||
const rolePlaceholders = roleCodes.length > 0 ? roleCodes.map(() => "?").join(", ") : "NULL";
|
||||
const [rows] = await pool.execute<AnnouncementRow[]>(
|
||||
`
|
||||
SELECT a.id, a.title, a.content, a.level, a.status, a.target_type, a.published_at,
|
||||
ar.read_at, a.created_at, a.updated_at
|
||||
FROM announcements a
|
||||
LEFT JOIN announcement_reads ar ON ar.announcement_id = a.id AND ar.employee_id = ?
|
||||
WHERE a.id = ?
|
||||
AND a.deleted_at IS NULL
|
||||
AND a.status = 'PUBLISHED'
|
||||
AND (
|
||||
a.target_type = 'ALL'
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM announcement_targets at_store
|
||||
WHERE at_store.announcement_id = a.id
|
||||
AND at_store.target_type = 'STORE'
|
||||
AND at_store.target_id = ?
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM announcement_targets at_employee
|
||||
WHERE at_employee.announcement_id = a.id
|
||||
AND at_employee.target_type = 'EMPLOYEE'
|
||||
AND at_employee.target_id = ?
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM announcement_targets at_role
|
||||
INNER JOIN roles r ON r.id = at_role.target_id
|
||||
WHERE at_role.announcement_id = a.id
|
||||
AND at_role.target_type = 'ROLE'
|
||||
AND r.code IN (${rolePlaceholders})
|
||||
)
|
||||
)
|
||||
LIMIT 1
|
||||
`,
|
||||
[employeeId, id, storeId, employeeId, ...roleCodes],
|
||||
);
|
||||
|
||||
return rows[0] ? toAnnouncement(rows[0]) : null;
|
||||
},
|
||||
|
||||
async markRead(id: number, employeeId: number): Promise<void> {
|
||||
await pool.execute(
|
||||
`
|
||||
INSERT INTO announcement_reads (announcement_id, employee_id)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE read_at = CURRENT_TIMESTAMP(3)
|
||||
`,
|
||||
[id, employeeId],
|
||||
);
|
||||
},
|
||||
|
||||
async countUnreadForEmployee(employeeId: number, storeId: number, roleCodes: string[]): Promise<number> {
|
||||
const rolePlaceholders = roleCodes.length > 0 ? roleCodes.map(() => "?").join(", ") : "NULL";
|
||||
const [rows] = await pool.execute<CountRow[]>(
|
||||
`
|
||||
SELECT COUNT(*) AS total
|
||||
FROM announcements a
|
||||
LEFT JOIN announcement_reads ar ON ar.announcement_id = a.id AND ar.employee_id = ?
|
||||
WHERE a.deleted_at IS NULL
|
||||
AND a.status = 'PUBLISHED'
|
||||
AND ar.employee_id IS NULL
|
||||
AND (
|
||||
a.target_type = 'ALL'
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM announcement_targets at_store
|
||||
WHERE at_store.announcement_id = a.id
|
||||
AND at_store.target_type = 'STORE'
|
||||
AND at_store.target_id = ?
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM announcement_targets at_employee
|
||||
WHERE at_employee.announcement_id = a.id
|
||||
AND at_employee.target_type = 'EMPLOYEE'
|
||||
AND at_employee.target_id = ?
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM announcement_targets at_role
|
||||
INNER JOIN roles r ON r.id = at_role.target_id
|
||||
WHERE at_role.announcement_id = a.id
|
||||
AND at_role.target_type = 'ROLE'
|
||||
AND r.code IN (${rolePlaceholders})
|
||||
)
|
||||
)
|
||||
`,
|
||||
[employeeId, storeId, employeeId, ...roleCodes],
|
||||
);
|
||||
return rows[0]?.total ?? 0;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ANNOUNCEMENT_LEVELS,
|
||||
ANNOUNCEMENT_STATUSES,
|
||||
ANNOUNCEMENT_TARGET_TYPES,
|
||||
} from "./announcement.types";
|
||||
|
||||
const emptyStringToUndefined = (value: unknown) =>
|
||||
typeof value === "string" && value.trim() === "" ? undefined : value;
|
||||
|
||||
const targetSchema = z.object({
|
||||
type: z.enum(["STORE", "ROLE", "EMPLOYEE"]),
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
export const announcementIdParamSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
export const listAnnouncementsQuerySchema = z.object({
|
||||
status: z.preprocess(emptyStringToUndefined, z.enum(ANNOUNCEMENT_STATUSES).optional()),
|
||||
keyword: z.preprocess(emptyStringToUndefined, z.string().trim().min(1).max(100).optional()),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
const announcementBodyBaseSchema = z.object({
|
||||
title: z.string().trim().min(1).max(120),
|
||||
content: z.string().trim().min(1).max(20000),
|
||||
level: z.enum(ANNOUNCEMENT_LEVELS).default("NORMAL"),
|
||||
targetType: z.enum(ANNOUNCEMENT_TARGET_TYPES).default("ALL"),
|
||||
targets: z.array(targetSchema).max(200).default([]),
|
||||
});
|
||||
|
||||
export const createAnnouncementBodySchema = announcementBodyBaseSchema
|
||||
.refine(
|
||||
(value) =>
|
||||
value.targetType === "ALL"
|
||||
? value.targets.length === 0
|
||||
: value.targets.every((target) => target.type === value.targetType),
|
||||
{ message: "公告目标范围和目标列表不一致" },
|
||||
);
|
||||
|
||||
export const updateAnnouncementBodySchema = announcementBodyBaseSchema
|
||||
.partial()
|
||||
.refine((value) => Object.keys(value).length > 0, {
|
||||
message: "至少需要提交一个要修改的字段",
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { badRequest, notFound } from "../../shared/http-error";
|
||||
import type { AuthUser } from "../auth/auth.types";
|
||||
import { announcementRepository } from "./announcement.repository";
|
||||
import type { CreateAnnouncementInput, ListAnnouncementsQuery, UpdateAnnouncementInput } from "./announcement.types";
|
||||
|
||||
function assertTargetShape(input: Pick<CreateAnnouncementInput, "targetType" | "targets">): void {
|
||||
if (input.targetType === "ALL" && input.targets.length > 0) {
|
||||
throw badRequest("全员公告不能提交目标列表");
|
||||
}
|
||||
|
||||
if (input.targetType !== "ALL" && input.targets.length === 0) {
|
||||
throw badRequest("非全员公告必须提交目标列表");
|
||||
}
|
||||
}
|
||||
|
||||
export const announcementService = {
|
||||
list(query: ListAnnouncementsQuery) {
|
||||
return announcementRepository.list(query);
|
||||
},
|
||||
|
||||
async getById(id: number) {
|
||||
const announcement = await announcementRepository.findById(id);
|
||||
|
||||
if (!announcement) {
|
||||
throw notFound("公告不存在");
|
||||
}
|
||||
|
||||
return announcement;
|
||||
},
|
||||
|
||||
async create(input: CreateAnnouncementInput, user: AuthUser) {
|
||||
assertTargetShape(input);
|
||||
const id = await announcementRepository.withTransaction((connection) =>
|
||||
announcementRepository.create(input, user, connection),
|
||||
);
|
||||
return this.getById(id);
|
||||
},
|
||||
|
||||
async update(id: number, input: UpdateAnnouncementInput) {
|
||||
const current = await this.getById(id);
|
||||
assertTargetShape({
|
||||
targetType: input.targetType ?? current.targetType,
|
||||
targets: input.targets ?? current.targets,
|
||||
});
|
||||
await announcementRepository.withTransaction((connection) =>
|
||||
announcementRepository.update(id, input, connection),
|
||||
);
|
||||
return this.getById(id);
|
||||
},
|
||||
|
||||
async publish(id: number) {
|
||||
await this.getById(id);
|
||||
await announcementRepository.setStatus(id, "PUBLISHED");
|
||||
return this.getById(id);
|
||||
},
|
||||
|
||||
async archive(id: number) {
|
||||
await this.getById(id);
|
||||
await announcementRepository.setStatus(id, "ARCHIVED");
|
||||
return this.getById(id);
|
||||
},
|
||||
|
||||
listVisibleForEmployee(employee: AuthUser, query: ListAnnouncementsQuery) {
|
||||
return announcementRepository.listVisibleForEmployee(
|
||||
employee.id,
|
||||
employee.storeId ?? 0,
|
||||
employee.roles.map((role) => role.code),
|
||||
query,
|
||||
);
|
||||
},
|
||||
|
||||
async getVisibleByIdForEmployee(id: number, employee: AuthUser) {
|
||||
const announcement = await announcementRepository.findVisibleByIdForEmployee(
|
||||
id,
|
||||
employee.id,
|
||||
employee.storeId ?? 0,
|
||||
employee.roles.map((role) => role.code),
|
||||
);
|
||||
|
||||
if (!announcement) {
|
||||
throw notFound("公告不存在");
|
||||
}
|
||||
|
||||
return announcement;
|
||||
},
|
||||
|
||||
async markRead(id: number, employee: AuthUser) {
|
||||
await this.getVisibleByIdForEmployee(id, employee);
|
||||
await announcementRepository.markRead(id, employee.id);
|
||||
return this.getVisibleByIdForEmployee(id, employee);
|
||||
},
|
||||
|
||||
countUnreadForEmployee(employee: AuthUser) {
|
||||
return announcementRepository.countUnreadForEmployee(
|
||||
employee.id,
|
||||
employee.storeId ?? 0,
|
||||
employee.roles.map((role) => role.code),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
export const ANNOUNCEMENT_LEVELS = ["NORMAL", "IMPORTANT", "URGENT"] as const;
|
||||
export const ANNOUNCEMENT_STATUSES = ["DRAFT", "PUBLISHED", "ARCHIVED"] as const;
|
||||
export const ANNOUNCEMENT_TARGET_TYPES = ["ALL", "STORE", "ROLE", "EMPLOYEE"] as const;
|
||||
|
||||
export type AnnouncementLevel = (typeof ANNOUNCEMENT_LEVELS)[number];
|
||||
export type AnnouncementStatus = (typeof ANNOUNCEMENT_STATUSES)[number];
|
||||
export type AnnouncementTargetType = (typeof ANNOUNCEMENT_TARGET_TYPES)[number];
|
||||
|
||||
export interface AnnouncementTargetInput {
|
||||
type: Exclude<AnnouncementTargetType, "ALL">;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface Announcement {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
level: AnnouncementLevel;
|
||||
status: AnnouncementStatus;
|
||||
targetType: AnnouncementTargetType;
|
||||
publishedAt: string | null;
|
||||
readAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
targets: AnnouncementTargetInput[];
|
||||
}
|
||||
|
||||
export interface ListAnnouncementsQuery {
|
||||
status?: AnnouncementStatus;
|
||||
keyword?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface CreateAnnouncementInput {
|
||||
title: string;
|
||||
content: string;
|
||||
level: AnnouncementLevel;
|
||||
targetType: AnnouncementTargetType;
|
||||
targets: AnnouncementTargetInput[];
|
||||
}
|
||||
|
||||
export type UpdateAnnouncementInput = Partial<CreateAnnouncementInput>;
|
||||
@@ -2,7 +2,7 @@ import type { FastifyInstance } from "fastify";
|
||||
import { env } from "../../config/env";
|
||||
import { ok } from "../../shared/response";
|
||||
import { authGuard } from "./auth.guard";
|
||||
import { loginBodySchema } from "./auth.schema";
|
||||
import { loginBodySchema, updateOwnPasswordBodySchema } from "./auth.schema";
|
||||
import { authService } from "./auth.service";
|
||||
|
||||
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
@@ -50,4 +50,14 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
return ok(user);
|
||||
});
|
||||
|
||||
app.patch("/auth/me/password", { preHandler: authGuard }, async (request) => {
|
||||
const body = updateOwnPasswordBodySchema.parse(request.body);
|
||||
const user = await authService.updateOwnPassword(request.user, body, {
|
||||
ip: request.ip,
|
||||
userAgent: request.headers["user-agent"] ?? null,
|
||||
});
|
||||
|
||||
return ok(user);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ interface EmployeeLoginRow extends RowDataPacket {
|
||||
store_id: number;
|
||||
store_name: string;
|
||||
store_status: "ACTIVE" | "INACTIVE";
|
||||
must_change_password: 0 | 1 | null;
|
||||
}
|
||||
|
||||
interface EmployeeRoleRow extends RowDataPacket {
|
||||
@@ -123,9 +124,11 @@ export const authRepository = {
|
||||
e.status,
|
||||
e.store_id,
|
||||
s.name AS store_name,
|
||||
s.status AS store_status
|
||||
s.status AS store_status,
|
||||
eps.must_change_password
|
||||
FROM employees e
|
||||
INNER JOIN stores s ON s.id = e.store_id
|
||||
LEFT JOIN employee_password_states eps ON eps.employee_id = e.id
|
||||
WHERE e.phone = ?
|
||||
AND e.status = 'ACTIVE'
|
||||
AND e.deleted_at IS NULL
|
||||
@@ -160,9 +163,11 @@ export const authRepository = {
|
||||
e.status,
|
||||
e.store_id,
|
||||
s.name AS store_name,
|
||||
s.status AS store_status
|
||||
s.status AS store_status,
|
||||
eps.must_change_password
|
||||
FROM employees e
|
||||
INNER JOIN stores s ON s.id = e.store_id
|
||||
LEFT JOIN employee_password_states eps ON eps.employee_id = e.id
|
||||
WHERE e.phone = ?
|
||||
AND e.deleted_at IS NULL
|
||||
AND s.deleted_at IS NULL
|
||||
@@ -195,9 +200,11 @@ export const authRepository = {
|
||||
e.status,
|
||||
e.store_id,
|
||||
s.name AS store_name,
|
||||
s.status AS store_status
|
||||
s.status AS store_status,
|
||||
eps.must_change_password
|
||||
FROM employees e
|
||||
INNER JOIN stores s ON s.id = e.store_id
|
||||
LEFT JOIN employee_password_states eps ON eps.employee_id = e.id
|
||||
WHERE e.id = ?
|
||||
AND e.status = 'ACTIVE'
|
||||
AND e.deleted_at IS NULL
|
||||
@@ -230,6 +237,17 @@ export const authRepository = {
|
||||
);
|
||||
},
|
||||
|
||||
async updateEmployeePasswordHash(id: number, passwordHash: string): Promise<void> {
|
||||
await pool.execute(
|
||||
`
|
||||
UPDATE employees
|
||||
SET password_hash = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`,
|
||||
[passwordHash, id],
|
||||
);
|
||||
},
|
||||
|
||||
async findRolesByEmployeeIds(
|
||||
employeeIds: number[],
|
||||
): Promise<Map<number, EmployeeLoginAccount["roles"]>> {
|
||||
@@ -280,6 +298,7 @@ function toEmployeeLoginAccount(
|
||||
storeId: row.store_id,
|
||||
storeName: row.store_name,
|
||||
storeStatus: row.store_status,
|
||||
mustChangePassword: row.must_change_password === 1,
|
||||
roles,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,3 +4,8 @@ export const loginBodySchema = z.object({
|
||||
username: z.string().trim().min(1).max(50),
|
||||
password: z.string().min(8).max(128),
|
||||
});
|
||||
|
||||
export const updateOwnPasswordBodySchema = z.object({
|
||||
oldPassword: z.string().min(8).max(128),
|
||||
newPassword: z.string().min(8).max(128),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { unauthorized } from "../../shared/http-error";
|
||||
import { badRequest, forbidden, unauthorized } from "../../shared/http-error";
|
||||
import { authRepository } from "./auth.repository";
|
||||
import type {
|
||||
AuthJwtPayload,
|
||||
@@ -8,7 +8,8 @@ import type {
|
||||
LoginScene,
|
||||
SuperAdmin,
|
||||
} from "./auth.types";
|
||||
import { verifyPassword } from "./password";
|
||||
import { hashPassword, verifyPassword } from "./password";
|
||||
import { credentialService } from "../credentials/credential.service";
|
||||
import { permissionService } from "../permissions/permission.service";
|
||||
|
||||
async function toAuthUser(admin: SuperAdmin): Promise<AuthUser> {
|
||||
@@ -54,6 +55,7 @@ async function toEmployeeAuthUser(
|
||||
roles: employee.roles,
|
||||
permissions,
|
||||
canManage: permissions.some((permission) => permission.endsWith(":manage")),
|
||||
mustChangePassword: employee.mustChangePassword,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -212,4 +214,42 @@ export const authService = {
|
||||
|
||||
throw unauthorized("登录已失效,请重新登录");
|
||||
},
|
||||
|
||||
async getCurrentEmployeeUser(payload: AuthJwtPayload): Promise<AuthUser> {
|
||||
const user = await this.getCurrentUser(payload);
|
||||
|
||||
if (user.accountType !== "EMPLOYEE") {
|
||||
throw forbidden("只有员工账号可以访问员工端接口");
|
||||
}
|
||||
|
||||
return user;
|
||||
},
|
||||
|
||||
async updateOwnPassword(
|
||||
payload: AuthJwtPayload,
|
||||
input: { oldPassword: string; newPassword: string },
|
||||
meta: { ip?: string | null; userAgent?: string | null },
|
||||
): Promise<AuthUser> {
|
||||
if (payload.accountType !== "EMPLOYEE" || !payload.employeeId) {
|
||||
throw forbidden("超级管理员暂不通过员工端接口修改密码");
|
||||
}
|
||||
|
||||
const employee = await authRepository.findActiveEmployeeById(payload.employeeId);
|
||||
|
||||
if (!employee) {
|
||||
throw unauthorized("登录已失效,请重新登录");
|
||||
}
|
||||
|
||||
const passwordMatched = await verifyPassword(input.oldPassword, employee.passwordHash);
|
||||
|
||||
if (!passwordMatched) {
|
||||
throw badRequest("旧密码不正确");
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(input.newPassword);
|
||||
await authRepository.updateEmployeePasswordHash(employee.id, passwordHash);
|
||||
const actor = await this.getCurrentEmployeeUser(payload);
|
||||
await credentialService.recordOwnPasswordChanged(employee.id, actor, meta);
|
||||
return this.getCurrentUser(payload);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface EmployeeLoginAccount {
|
||||
storeId: number;
|
||||
storeName: string;
|
||||
storeStatus: "ACTIVE" | "INACTIVE";
|
||||
mustChangePassword: boolean;
|
||||
roles: Array<{
|
||||
id: number;
|
||||
code: string;
|
||||
@@ -53,6 +54,7 @@ export interface AuthUser {
|
||||
}>;
|
||||
permissions: string[];
|
||||
canManage: boolean;
|
||||
mustChangePassword?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthJwtPayload {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { ok, paginated } from "../../shared/response";
|
||||
import { permissionGuard } from "../auth/auth.guard";
|
||||
import { authService } from "../auth/auth.service";
|
||||
import { PERMISSIONS } from "../permissions/permission.policy";
|
||||
import { credentialEmployeeIdParamSchema, listCredentialAuditsQuerySchema, resetPasswordBodySchema } from "./credential.schema";
|
||||
import { credentialService } from "./credential.service";
|
||||
|
||||
export async function credentialAdminRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.post("/admin/employees/:id/password/reset", { preHandler: permissionGuard(PERMISSIONS.CREDENTIAL_RESET) }, async (request) => {
|
||||
const actor = await authService.getCurrentUser(request.user);
|
||||
const params = credentialEmployeeIdParamSchema.parse(request.params);
|
||||
const body = resetPasswordBodySchema.parse(request.body ?? {});
|
||||
return ok(
|
||||
await credentialService.resetEmployeePassword(params.id, actor, {
|
||||
reason: body.reason,
|
||||
ip: request.ip,
|
||||
userAgent: request.headers["user-agent"] ?? null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
app.get("/admin/credential-audits", { preHandler: permissionGuard(PERMISSIONS.CREDENTIAL_AUDIT_VIEW) }, async (request) => {
|
||||
const actor = await authService.getCurrentUser(request.user);
|
||||
const query = listCredentialAuditsQuerySchema.parse(request.query);
|
||||
const result = await credentialService.listAudits(query, actor);
|
||||
return paginated(result.items, query.page, query.pageSize, result.total);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { RowDataPacket } from "mysql2/promise";
|
||||
import { pool } from "../../db/pool";
|
||||
import type { AuthUser } from "../auth/auth.types";
|
||||
import type { Employee } from "../employees/employee.types";
|
||||
import type { CredentialAudit, ListCredentialAuditsQuery, ResetEmployeePasswordInput } from "./credential.types";
|
||||
|
||||
type SqlParam = string | number | null;
|
||||
|
||||
interface AuditRow extends RowDataPacket {
|
||||
id: number;
|
||||
actor_account_type: CredentialAudit["actorAccountType"];
|
||||
actor_admin_id: number | null;
|
||||
actor_employee_id: number | null;
|
||||
actor_name: string | null;
|
||||
target_employee_id: number;
|
||||
target_employee_name: string;
|
||||
target_employee_phone: string;
|
||||
store_name: string | null;
|
||||
action: CredentialAudit["action"];
|
||||
reason: string | null;
|
||||
ip: string | null;
|
||||
user_agent: string | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
interface CountRow extends RowDataPacket {
|
||||
total: number;
|
||||
}
|
||||
|
||||
function toIso(value: Date): string {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
function toAudit(row: AuditRow): CredentialAudit {
|
||||
return {
|
||||
id: row.id,
|
||||
actorAccountType: row.actor_account_type,
|
||||
actorAdminId: row.actor_admin_id,
|
||||
actorEmployeeId: row.actor_employee_id,
|
||||
actorName: row.actor_name,
|
||||
targetEmployeeId: row.target_employee_id,
|
||||
targetEmployeeName: row.target_employee_name,
|
||||
targetEmployeePhone: row.target_employee_phone,
|
||||
storeName: row.store_name,
|
||||
action: row.action,
|
||||
reason: row.reason,
|
||||
ip: row.ip,
|
||||
userAgent: row.user_agent,
|
||||
createdAt: toIso(row.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
function actorColumns(user: AuthUser): [CredentialAudit["actorAccountType"], number | null, number | null] {
|
||||
return user.accountType === "SUPER_ADMIN" ? ["SUPER_ADMIN", user.id, null] : ["EMPLOYEE", null, user.id];
|
||||
}
|
||||
|
||||
export const credentialRepository = {
|
||||
async updatePasswordForReset(employeeId: number, passwordHash: string, actor: AuthUser): Promise<void> {
|
||||
const [, adminId, actorEmployeeId] = actorColumns(actor);
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.execute(
|
||||
"UPDATE employees SET password_hash = ? WHERE id = ? AND deleted_at IS NULL",
|
||||
[passwordHash, employeeId],
|
||||
);
|
||||
await connection.execute(
|
||||
`
|
||||
INSERT INTO employee_password_states
|
||||
(employee_id, must_change_password, last_reset_at, last_reset_by_admin_id, last_reset_by_employee_id)
|
||||
VALUES (?, 1, CURRENT_TIMESTAMP(3), ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
must_change_password = 1,
|
||||
last_reset_at = CURRENT_TIMESTAMP(3),
|
||||
last_reset_by_admin_id = VALUES(last_reset_by_admin_id),
|
||||
last_reset_by_employee_id = VALUES(last_reset_by_employee_id)
|
||||
`,
|
||||
[employeeId, adminId, actorEmployeeId],
|
||||
);
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
},
|
||||
|
||||
async markOwnPasswordChanged(employeeId: number): Promise<void> {
|
||||
await pool.execute(
|
||||
`
|
||||
INSERT INTO employee_password_states (employee_id, must_change_password)
|
||||
VALUES (?, 0)
|
||||
ON DUPLICATE KEY UPDATE must_change_password = 0
|
||||
`,
|
||||
[employeeId],
|
||||
);
|
||||
},
|
||||
|
||||
async recordAudit(
|
||||
actor: AuthUser,
|
||||
target: Employee,
|
||||
action: CredentialAudit["action"],
|
||||
input: ResetEmployeePasswordInput,
|
||||
): Promise<void> {
|
||||
const [actorAccountType, actorAdminId, actorEmployeeId] = actorColumns(actor);
|
||||
await pool.execute(
|
||||
`
|
||||
INSERT INTO credential_audits
|
||||
(actor_account_type, actor_admin_id, actor_employee_id, target_employee_id, action, reason, ip, user_agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
actorAccountType,
|
||||
actorAdminId,
|
||||
actorEmployeeId,
|
||||
target.id,
|
||||
action,
|
||||
input.reason ?? null,
|
||||
input.ip ?? null,
|
||||
input.userAgent ?? null,
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
async listAudits(query: ListCredentialAuditsQuery): Promise<{ items: CredentialAudit[]; total: number }> {
|
||||
const where: string[] = [];
|
||||
const params: SqlParam[] = [];
|
||||
|
||||
if (query.operatorId !== undefined) {
|
||||
where.push("(ca.actor_admin_id = ? OR ca.actor_employee_id = ?)");
|
||||
params.push(query.operatorId, query.operatorId);
|
||||
}
|
||||
|
||||
if (query.targetEmployeeId !== undefined) {
|
||||
where.push("ca.target_employee_id = ?");
|
||||
params.push(query.targetEmployeeId);
|
||||
}
|
||||
|
||||
if (query.storeId !== undefined) {
|
||||
where.push("te.store_id = ?");
|
||||
params.push(query.storeId);
|
||||
}
|
||||
|
||||
if (query.startDate) {
|
||||
where.push("ca.created_at >= ?");
|
||||
params.push(`${query.startDate} 00:00:00.000`);
|
||||
}
|
||||
|
||||
if (query.endDate) {
|
||||
where.push("ca.created_at < DATE_ADD(?, INTERVAL 1 DAY)");
|
||||
params.push(`${query.endDate} 00:00:00.000`);
|
||||
}
|
||||
|
||||
const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
|
||||
const offset = (query.page - 1) * query.pageSize;
|
||||
const [countRows] = await pool.execute<CountRow[]>(
|
||||
`
|
||||
SELECT COUNT(*) AS total
|
||||
FROM credential_audits ca
|
||||
INNER JOIN employees te ON te.id = ca.target_employee_id
|
||||
${whereSql}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
const [rows] = await pool.execute<AuditRow[]>(
|
||||
`
|
||||
SELECT
|
||||
ca.*,
|
||||
COALESCE(sa.display_name, ae.name) AS actor_name,
|
||||
te.name AS target_employee_name,
|
||||
te.phone AS target_employee_phone,
|
||||
s.name AS store_name
|
||||
FROM credential_audits ca
|
||||
LEFT JOIN super_admins sa ON sa.id = ca.actor_admin_id
|
||||
LEFT JOIN employees ae ON ae.id = ca.actor_employee_id
|
||||
INNER JOIN employees te ON te.id = ca.target_employee_id
|
||||
LEFT JOIN stores s ON s.id = te.store_id
|
||||
${whereSql}
|
||||
ORDER BY ca.id DESC
|
||||
LIMIT ${query.pageSize} OFFSET ${offset}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return {
|
||||
items: rows.map(toAudit),
|
||||
total: countRows[0]?.total ?? 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const emptyStringToUndefined = (value: unknown) =>
|
||||
typeof value === "string" && value.trim() === "" ? undefined : value;
|
||||
const dateSchema = z.string().trim().regex(/^\d{4}-\d{2}-\d{2}$/, "日期格式应为 YYYY-MM-DD");
|
||||
|
||||
export const credentialEmployeeIdParamSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
export const resetPasswordBodySchema = z.object({
|
||||
reason: z.string().trim().max(500).nullable().optional(),
|
||||
});
|
||||
|
||||
export const listCredentialAuditsQuerySchema = z.object({
|
||||
operatorId: z.preprocess(emptyStringToUndefined, z.coerce.number().int().positive().optional()),
|
||||
targetEmployeeId: z.preprocess(emptyStringToUndefined, z.coerce.number().int().positive().optional()),
|
||||
storeId: z.preprocess(emptyStringToUndefined, z.coerce.number().int().positive().optional()),
|
||||
startDate: z.preprocess(emptyStringToUndefined, dateSchema.optional()),
|
||||
endDate: z.preprocess(emptyStringToUndefined, dateSchema.optional()),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { badRequest, forbidden } from "../../shared/http-error";
|
||||
import type { AuthUser } from "../auth/auth.types";
|
||||
import { hashPassword } from "../auth/password";
|
||||
import { employeeService } from "../employees/employee.service";
|
||||
import type { Employee } from "../employees/employee.types";
|
||||
import { hasAnyPermission, hasPermission, PERMISSIONS } from "../permissions/permission.policy";
|
||||
import { credentialRepository } from "./credential.repository";
|
||||
import type { ListCredentialAuditsQuery, ResetEmployeePasswordInput } from "./credential.types";
|
||||
|
||||
function generateTemporaryPassword(): string {
|
||||
return `Tmp-${randomBytes(9).toString("base64url")}9`;
|
||||
}
|
||||
|
||||
function assertCanReset(actor: AuthUser, target: Employee): void {
|
||||
if (!hasPermission(actor.permissions, PERMISSIONS.CREDENTIAL_RESET)) {
|
||||
throw forbidden("当前账号没有重置密码权限");
|
||||
}
|
||||
|
||||
if (actor.accountType === "EMPLOYEE" && actor.id === target.id) {
|
||||
throw badRequest("本人改密请使用修改本人密码接口");
|
||||
}
|
||||
|
||||
if (hasPermission(actor.permissions, PERMISSIONS.EMPLOYEE_VIEW_ALL)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
hasPermission(actor.permissions, PERMISSIONS.EMPLOYEE_VIEW_STORE) &&
|
||||
actor.storeId === target.storeId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw forbidden("当前账号无权重置该员工密码");
|
||||
}
|
||||
|
||||
function assertCanViewAudit(actor: AuthUser): void {
|
||||
if (!hasPermission(actor.permissions, PERMISSIONS.CREDENTIAL_AUDIT_VIEW)) {
|
||||
throw forbidden("当前账号没有查看凭据审计权限");
|
||||
}
|
||||
|
||||
if (
|
||||
!hasAnyPermission(actor.permissions, [
|
||||
PERMISSIONS.EMPLOYEE_VIEW_ALL,
|
||||
PERMISSIONS.EMPLOYEE_VIEW_STORE,
|
||||
])
|
||||
) {
|
||||
throw forbidden("当前账号缺少员工数据范围权限");
|
||||
}
|
||||
}
|
||||
|
||||
export const credentialService = {
|
||||
async resetEmployeePassword(employeeId: number, actor: AuthUser, input: ResetEmployeePasswordInput) {
|
||||
const target = await employeeService.getById(employeeId);
|
||||
assertCanReset(actor, target);
|
||||
|
||||
const temporaryPassword = generateTemporaryPassword();
|
||||
const passwordHash = await hashPassword(temporaryPassword);
|
||||
await credentialRepository.updatePasswordForReset(employeeId, passwordHash, actor);
|
||||
await credentialRepository.recordAudit(actor, target, "RESET_PASSWORD", input);
|
||||
|
||||
return {
|
||||
employee: await employeeService.getById(employeeId),
|
||||
temporaryPassword,
|
||||
mustChangePassword: true,
|
||||
};
|
||||
},
|
||||
|
||||
async recordOwnPasswordChanged(employeeId: number, actor: AuthUser, input: ResetEmployeePasswordInput) {
|
||||
const target = await employeeService.getById(employeeId);
|
||||
await credentialRepository.markOwnPasswordChanged(employeeId);
|
||||
await credentialRepository.recordAudit(actor, target, "CHANGE_OWN_PASSWORD", input);
|
||||
},
|
||||
|
||||
listAudits(query: ListCredentialAuditsQuery, actor: AuthUser) {
|
||||
assertCanViewAudit(actor);
|
||||
const scopedQuery =
|
||||
hasPermission(actor.permissions, PERMISSIONS.EMPLOYEE_VIEW_STORE) &&
|
||||
!hasPermission(actor.permissions, PERMISSIONS.EMPLOYEE_VIEW_ALL)
|
||||
? { ...query, storeId: actor.storeId }
|
||||
: query;
|
||||
|
||||
return credentialRepository.listAudits(scopedQuery);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
export interface CredentialAudit {
|
||||
id: number;
|
||||
actorAccountType: "SUPER_ADMIN" | "EMPLOYEE";
|
||||
actorAdminId: number | null;
|
||||
actorEmployeeId: number | null;
|
||||
actorName: string | null;
|
||||
targetEmployeeId: number;
|
||||
targetEmployeeName: string;
|
||||
targetEmployeePhone: string;
|
||||
storeName: string | null;
|
||||
action: "RESET_PASSWORD" | "CHANGE_OWN_PASSWORD";
|
||||
reason: string | null;
|
||||
ip: string | null;
|
||||
userAgent: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ListCredentialAuditsQuery {
|
||||
operatorId?: number;
|
||||
targetEmployeeId?: number;
|
||||
storeId?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface ResetEmployeePasswordInput {
|
||||
reason?: string | null;
|
||||
ip?: string | null;
|
||||
userAgent?: string | null;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify";
|
||||
import { forbidden } from "../../shared/http-error";
|
||||
import { created, ok, paginated } from "../../shared/response";
|
||||
import { authService } from "../auth/auth.service";
|
||||
import { credentialService } from "../credentials/credential.service";
|
||||
import {
|
||||
hasAnyPermission,
|
||||
hasPermission,
|
||||
@@ -137,10 +138,16 @@ export async function employeeRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
app.patch("/employees/:id/password/reset", async (request) => {
|
||||
const user = await authService.getCurrentUser(request.user);
|
||||
assertCanManageEmployees(user);
|
||||
if (!hasPermission(user.permissions, PERMISSIONS.CREDENTIAL_RESET)) {
|
||||
throw forbidden("当前账号没有重置密码权限");
|
||||
}
|
||||
|
||||
const params = idParamSchema.parse(request.params);
|
||||
const employee = await employeeService.resetPassword(params.id);
|
||||
const employee = await credentialService.resetEmployeePassword(params.id, user, {
|
||||
reason: "LEGACY_EMPLOYEE_RESET_ENDPOINT",
|
||||
ip: request.ip,
|
||||
userAgent: request.headers["user-agent"] ?? null,
|
||||
});
|
||||
|
||||
return ok(employee);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { ok } from "../../shared/response";
|
||||
import { authGuard } from "../auth/auth.guard";
|
||||
import { authService } from "../auth/auth.service";
|
||||
import { getVisibleMenus } from "../permissions/permission.policy";
|
||||
import { announcementService } from "../announcements/announcement.service";
|
||||
import { taskService } from "../tasks/task.service";
|
||||
import { shiftService } from "../shifts/shift.service";
|
||||
|
||||
export async function mobileRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/mobile/bootstrap", { preHandler: authGuard }, async (request) => {
|
||||
const user = await authService.getCurrentEmployeeUser(request.user);
|
||||
const [
|
||||
unreadAnnouncementCount,
|
||||
pendingTaskCount,
|
||||
overdueTaskCount,
|
||||
latestAnnouncementsResult,
|
||||
tasks,
|
||||
todayShifts,
|
||||
] = await Promise.all([
|
||||
announcementService.countUnreadForEmployee(user),
|
||||
taskService.countPendingForEmployee(user),
|
||||
taskService.countOverdueForEmployee(user),
|
||||
announcementService.listVisibleForEmployee(user, { page: 1, pageSize: 3 }),
|
||||
taskService.listOpenForEmployee(user, 5),
|
||||
shiftService.todayForEmployee(user),
|
||||
]);
|
||||
|
||||
return ok({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
accountType: user.accountType,
|
||||
storeId: user.storeId,
|
||||
storeName: user.storeName,
|
||||
roles: user.roles,
|
||||
permissions: user.permissions,
|
||||
mustChangePassword: user.mustChangePassword ?? false,
|
||||
},
|
||||
store: user.storeId
|
||||
? {
|
||||
id: user.storeId,
|
||||
name: user.storeName ?? "",
|
||||
}
|
||||
: null,
|
||||
permissions: {
|
||||
codes: user.permissions,
|
||||
menus: getVisibleMenus(user),
|
||||
},
|
||||
counters: {
|
||||
unreadAnnouncementCount,
|
||||
pendingTaskCount,
|
||||
overdueTaskCount,
|
||||
},
|
||||
latestAnnouncements: latestAnnouncementsResult.items,
|
||||
tasks,
|
||||
todayShifts,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,14 @@ export const PERMISSIONS = {
|
||||
EMPLOYEE_MANAGE: "employee:manage",
|
||||
PERMISSION_VIEW: "permission:view",
|
||||
PERMISSION_MANAGE: "permission:manage",
|
||||
ANNOUNCEMENT_VIEW: "announcement:view",
|
||||
ANNOUNCEMENT_MANAGE: "announcement:manage",
|
||||
TASK_VIEW: "task:view",
|
||||
TASK_MANAGE: "task:manage",
|
||||
SHIFT_VIEW: "shift:view",
|
||||
SHIFT_MANAGE: "shift:manage",
|
||||
CREDENTIAL_RESET: "credential:reset",
|
||||
CREDENTIAL_AUDIT_VIEW: "credential:audit:view",
|
||||
} as const;
|
||||
|
||||
export type PermissionCode = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
|
||||
@@ -41,6 +49,10 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
create: "新增",
|
||||
update: "编辑",
|
||||
delete: "删除",
|
||||
publish: "发布",
|
||||
archive: "归档",
|
||||
cancel: "取消",
|
||||
reset: "重置",
|
||||
};
|
||||
|
||||
const MENUS: PermissionMenu[] = [
|
||||
@@ -69,6 +81,34 @@ const MENUS: PermissionMenu[] = [
|
||||
permission: PERMISSIONS.PERMISSION_VIEW,
|
||||
actions: ["view", "update"],
|
||||
},
|
||||
{
|
||||
key: "announcements",
|
||||
title: "公告管理",
|
||||
icon: "megaphone",
|
||||
permission: PERMISSIONS.ANNOUNCEMENT_VIEW,
|
||||
actions: ["view", "create", "update", "publish", "archive"],
|
||||
},
|
||||
{
|
||||
key: "tasks",
|
||||
title: "任务管理",
|
||||
icon: "clipboard-list",
|
||||
permission: PERMISSIONS.TASK_VIEW,
|
||||
actions: ["view", "create", "update", "cancel"],
|
||||
},
|
||||
{
|
||||
key: "shifts",
|
||||
title: "排班管理",
|
||||
icon: "calendar-clock",
|
||||
permission: PERMISSIONS.SHIFT_VIEW,
|
||||
actions: ["view", "create", "update", "delete"],
|
||||
},
|
||||
{
|
||||
key: "credentials",
|
||||
title: "凭据安全",
|
||||
icon: "shield-keyhole",
|
||||
permission: PERMISSIONS.CREDENTIAL_AUDIT_VIEW,
|
||||
actions: ["view", "reset"],
|
||||
},
|
||||
];
|
||||
|
||||
const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
|
||||
@@ -135,6 +175,62 @@ const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
|
||||
groupKey: "permissions",
|
||||
groupTitle: "权限管理",
|
||||
},
|
||||
{
|
||||
code: PERMISSIONS.ANNOUNCEMENT_VIEW,
|
||||
title: "查看公告",
|
||||
description: "查看后台公告列表和公告详情。",
|
||||
groupKey: "announcements",
|
||||
groupTitle: "公告管理",
|
||||
},
|
||||
{
|
||||
code: PERMISSIONS.ANNOUNCEMENT_MANAGE,
|
||||
title: "管理公告",
|
||||
description: "新增、编辑、发布和归档公告。",
|
||||
groupKey: "announcements",
|
||||
groupTitle: "公告管理",
|
||||
},
|
||||
{
|
||||
code: PERMISSIONS.TASK_VIEW,
|
||||
title: "查看任务",
|
||||
description: "查看后台任务列表和任务详情。",
|
||||
groupKey: "tasks",
|
||||
groupTitle: "任务管理",
|
||||
},
|
||||
{
|
||||
code: PERMISSIONS.TASK_MANAGE,
|
||||
title: "管理任务",
|
||||
description: "新建、编辑和取消任务。",
|
||||
groupKey: "tasks",
|
||||
groupTitle: "任务管理",
|
||||
},
|
||||
{
|
||||
code: PERMISSIONS.SHIFT_VIEW,
|
||||
title: "查看排班",
|
||||
description: "查看后台排班列表和排班详情。",
|
||||
groupKey: "shifts",
|
||||
groupTitle: "排班管理",
|
||||
},
|
||||
{
|
||||
code: PERMISSIONS.SHIFT_MANAGE,
|
||||
title: "管理排班",
|
||||
description: "新增、编辑和取消排班。",
|
||||
groupKey: "shifts",
|
||||
groupTitle: "排班管理",
|
||||
},
|
||||
{
|
||||
code: PERMISSIONS.CREDENTIAL_RESET,
|
||||
title: "重置凭据",
|
||||
description: "在权限范围内重置下级员工临时密码。",
|
||||
groupKey: "credentials",
|
||||
groupTitle: "凭据安全",
|
||||
},
|
||||
{
|
||||
code: PERMISSIONS.CREDENTIAL_AUDIT_VIEW,
|
||||
title: "查看凭据审计",
|
||||
description: "查看密码重置等凭据操作审计记录。",
|
||||
groupKey: "credentials",
|
||||
groupTitle: "凭据安全",
|
||||
},
|
||||
];
|
||||
|
||||
const PERMISSION_ORDER = new Map(
|
||||
@@ -148,6 +244,10 @@ const PERMISSION_DEPENDENCIES: Partial<
|
||||
[PERMISSIONS.ROLE_MANAGE]: [PERMISSIONS.ROLE_VIEW],
|
||||
[PERMISSIONS.EMPLOYEE_MANAGE]: [PERMISSIONS.EMPLOYEE_VIEW_ALL],
|
||||
[PERMISSIONS.PERMISSION_MANAGE]: [PERMISSIONS.PERMISSION_VIEW],
|
||||
[PERMISSIONS.ANNOUNCEMENT_MANAGE]: [PERMISSIONS.ANNOUNCEMENT_VIEW],
|
||||
[PERMISSIONS.TASK_MANAGE]: [PERMISSIONS.TASK_VIEW],
|
||||
[PERMISSIONS.SHIFT_MANAGE]: [PERMISSIONS.SHIFT_VIEW],
|
||||
[PERMISSIONS.CREDENTIAL_RESET]: [PERMISSIONS.EMPLOYEE_VIEW_STORE],
|
||||
};
|
||||
|
||||
export function isPermissionCode(value: string): value is PermissionCode {
|
||||
@@ -260,6 +360,46 @@ export function getAllowedActions(user: AuthUser, menuKey: string): string[] {
|
||||
: [];
|
||||
}
|
||||
|
||||
if (menuKey === "announcements") {
|
||||
if (hasPermission(user.permissions, PERMISSIONS.ANNOUNCEMENT_MANAGE)) {
|
||||
return ["view", "create", "update", "publish", "archive"];
|
||||
}
|
||||
|
||||
return hasPermission(user.permissions, PERMISSIONS.ANNOUNCEMENT_VIEW)
|
||||
? ["view"]
|
||||
: [];
|
||||
}
|
||||
|
||||
if (menuKey === "tasks") {
|
||||
if (hasPermission(user.permissions, PERMISSIONS.TASK_MANAGE)) {
|
||||
return ["view", "create", "update", "cancel"];
|
||||
}
|
||||
|
||||
return hasPermission(user.permissions, PERMISSIONS.TASK_VIEW) ? ["view"] : [];
|
||||
}
|
||||
|
||||
if (menuKey === "shifts") {
|
||||
if (hasPermission(user.permissions, PERMISSIONS.SHIFT_MANAGE)) {
|
||||
return ["view", "create", "update", "delete"];
|
||||
}
|
||||
|
||||
return hasPermission(user.permissions, PERMISSIONS.SHIFT_VIEW) ? ["view"] : [];
|
||||
}
|
||||
|
||||
if (menuKey === "credentials") {
|
||||
const actions: string[] = [];
|
||||
|
||||
if (hasPermission(user.permissions, PERMISSIONS.CREDENTIAL_AUDIT_VIEW)) {
|
||||
actions.push("view");
|
||||
}
|
||||
|
||||
if (hasPermission(user.permissions, PERMISSIONS.CREDENTIAL_RESET)) {
|
||||
actions.push("reset");
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { created, ok, paginated } from "../../shared/response";
|
||||
import { authGuard, permissionGuard } from "../auth/auth.guard";
|
||||
import { authService } from "../auth/auth.service";
|
||||
import { PERMISSIONS } from "../permissions/permission.policy";
|
||||
import { createShiftBodySchema, listShiftsQuerySchema, shiftIdParamSchema, updateShiftBodySchema } from "./shift.schema";
|
||||
import { shiftService } from "./shift.service";
|
||||
|
||||
export async function shiftAdminRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/admin/shifts", { preHandler: permissionGuard(PERMISSIONS.SHIFT_VIEW) }, async (request) => {
|
||||
const query = listShiftsQuerySchema.parse(request.query);
|
||||
const result = await shiftService.list(query);
|
||||
return paginated(result.items, query.page, query.pageSize, result.total);
|
||||
});
|
||||
|
||||
app.post("/admin/shifts", { preHandler: permissionGuard(PERMISSIONS.SHIFT_MANAGE) }, async (request, reply) => {
|
||||
const user = await authService.getCurrentUser(request.user);
|
||||
const body = createShiftBodySchema.parse(request.body);
|
||||
const shift = await shiftService.create(body, user);
|
||||
return reply.code(201).send(created(shift));
|
||||
});
|
||||
|
||||
app.patch("/admin/shifts/:id", { preHandler: permissionGuard(PERMISSIONS.SHIFT_MANAGE) }, async (request) => {
|
||||
const params = shiftIdParamSchema.parse(request.params);
|
||||
const body = updateShiftBodySchema.parse(request.body);
|
||||
return ok(await shiftService.update(params.id, body));
|
||||
});
|
||||
|
||||
app.delete("/admin/shifts/:id", { preHandler: permissionGuard(PERMISSIONS.SHIFT_MANAGE) }, async (request, reply) => {
|
||||
const params = shiftIdParamSchema.parse(request.params);
|
||||
await shiftService.cancel(params.id);
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
|
||||
export async function shiftMobileRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/mobile/shifts/today", { preHandler: authGuard }, async (request) => {
|
||||
const employee = await authService.getCurrentEmployeeUser(request.user);
|
||||
return ok(await shiftService.todayForEmployee(employee));
|
||||
});
|
||||
|
||||
app.get("/mobile/shifts", { preHandler: authGuard }, async (request) => {
|
||||
const employee = await authService.getCurrentEmployeeUser(request.user);
|
||||
const query = listShiftsQuerySchema.parse(request.query);
|
||||
const result = await shiftService.listForEmployee(employee, query);
|
||||
return paginated(result.items, query.page, query.pageSize, result.total);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import type { ResultSetHeader, RowDataPacket } from "mysql2/promise";
|
||||
import { pool } from "../../db/pool";
|
||||
import type { AuthUser } from "../auth/auth.types";
|
||||
import type { CreateShiftInput, ListShiftsQuery, Shift, UpdateShiftInput } from "./shift.types";
|
||||
|
||||
type SqlParam = string | number | Date | null;
|
||||
|
||||
interface ShiftRow extends RowDataPacket {
|
||||
id: number;
|
||||
store_id: number;
|
||||
store_name: string;
|
||||
employee_id: number;
|
||||
employee_name: string;
|
||||
role_name: string | null;
|
||||
start_at: Date;
|
||||
end_at: Date;
|
||||
status: Shift["status"];
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
interface CountRow extends RowDataPacket {
|
||||
total: number;
|
||||
}
|
||||
|
||||
function toIso(value: Date): string {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
function toShift(row: ShiftRow): Shift {
|
||||
return {
|
||||
id: row.id,
|
||||
storeId: row.store_id,
|
||||
storeName: row.store_name,
|
||||
employeeId: row.employee_id,
|
||||
employeeName: row.employee_name,
|
||||
roleName: row.role_name,
|
||||
startAt: toIso(row.start_at),
|
||||
endAt: toIso(row.end_at),
|
||||
status: row.status,
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function actorValues(user: AuthUser): [number | null, number | null] {
|
||||
return user.accountType === "SUPER_ADMIN" ? [user.id, null] : [null, user.id];
|
||||
}
|
||||
|
||||
function buildWhere(query: ListShiftsQuery): { whereSql: string; params: SqlParam[] } {
|
||||
const where = ["sh.deleted_at IS NULL"];
|
||||
const params: SqlParam[] = [];
|
||||
|
||||
if (query.storeId !== undefined) {
|
||||
where.push("sh.store_id = ?");
|
||||
params.push(query.storeId);
|
||||
}
|
||||
|
||||
if (query.employeeId !== undefined) {
|
||||
where.push("sh.employee_id = ?");
|
||||
params.push(query.employeeId);
|
||||
}
|
||||
|
||||
if (query.status) {
|
||||
where.push("sh.status = ?");
|
||||
params.push(query.status);
|
||||
}
|
||||
|
||||
if (query.startDate) {
|
||||
where.push("sh.start_at >= ?");
|
||||
params.push(`${query.startDate} 00:00:00.000`);
|
||||
}
|
||||
|
||||
if (query.endDate) {
|
||||
where.push("sh.start_at < DATE_ADD(?, INTERVAL 1 DAY)");
|
||||
params.push(`${query.endDate} 00:00:00.000`);
|
||||
}
|
||||
|
||||
return { whereSql: where.join(" AND "), params };
|
||||
}
|
||||
|
||||
export const shiftRepository = {
|
||||
async employeeBelongsToStore(employeeId: number, storeId: number): Promise<boolean> {
|
||||
const [rows] = await pool.execute<CountRow[]>(
|
||||
`
|
||||
SELECT COUNT(*) AS total
|
||||
FROM employees
|
||||
WHERE id = ?
|
||||
AND store_id = ?
|
||||
AND status = 'ACTIVE'
|
||||
AND deleted_at IS NULL
|
||||
`,
|
||||
[employeeId, storeId],
|
||||
);
|
||||
return (rows[0]?.total ?? 0) > 0;
|
||||
},
|
||||
|
||||
async hasOverlappingShift(
|
||||
employeeId: number,
|
||||
startAt: string,
|
||||
endAt: string,
|
||||
excludeShiftId?: number,
|
||||
): Promise<boolean> {
|
||||
const params: SqlParam[] = [employeeId, new Date(endAt), new Date(startAt)];
|
||||
const excludeSql = excludeShiftId === undefined ? "" : "AND id <> ?";
|
||||
|
||||
if (excludeShiftId !== undefined) {
|
||||
params.push(excludeShiftId);
|
||||
}
|
||||
|
||||
const [rows] = await pool.execute<CountRow[]>(
|
||||
`
|
||||
SELECT COUNT(*) AS total
|
||||
FROM shifts
|
||||
WHERE employee_id = ?
|
||||
AND deleted_at IS NULL
|
||||
AND status <> 'CANCELLED'
|
||||
AND start_at < ?
|
||||
AND end_at > ?
|
||||
${excludeSql}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
return (rows[0]?.total ?? 0) > 0;
|
||||
},
|
||||
|
||||
async list(query: ListShiftsQuery): Promise<{ items: Shift[]; total: number }> {
|
||||
const { whereSql, params } = buildWhere(query);
|
||||
const offset = (query.page - 1) * query.pageSize;
|
||||
const [countRows] = await pool.execute<CountRow[]>(
|
||||
`
|
||||
SELECT COUNT(*) AS total
|
||||
FROM shifts sh
|
||||
INNER JOIN stores s ON s.id = sh.store_id
|
||||
INNER JOIN employees e ON e.id = sh.employee_id
|
||||
WHERE ${whereSql}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
const [rows] = await pool.execute<ShiftRow[]>(
|
||||
`
|
||||
SELECT sh.*, s.name AS store_name, e.name AS employee_name
|
||||
FROM shifts sh
|
||||
INNER JOIN stores s ON s.id = sh.store_id
|
||||
INNER JOIN employees e ON e.id = sh.employee_id
|
||||
WHERE ${whereSql}
|
||||
ORDER BY sh.start_at ASC, sh.id ASC
|
||||
LIMIT ${query.pageSize} OFFSET ${offset}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return {
|
||||
items: rows.map(toShift),
|
||||
total: countRows[0]?.total ?? 0,
|
||||
};
|
||||
},
|
||||
|
||||
async findById(id: number): Promise<Shift | null> {
|
||||
const [rows] = await pool.execute<ShiftRow[]>(
|
||||
`
|
||||
SELECT sh.*, s.name AS store_name, e.name AS employee_name
|
||||
FROM shifts sh
|
||||
INNER JOIN stores s ON s.id = sh.store_id
|
||||
INNER JOIN employees e ON e.id = sh.employee_id
|
||||
WHERE sh.id = ? AND sh.deleted_at IS NULL
|
||||
LIMIT 1
|
||||
`,
|
||||
[id],
|
||||
);
|
||||
return rows[0] ? toShift(rows[0]) : null;
|
||||
},
|
||||
|
||||
async create(input: CreateShiftInput, user: AuthUser): Promise<number> {
|
||||
const [creatorAdminId, creatorEmployeeId] = actorValues(user);
|
||||
const [result] = await pool.execute<ResultSetHeader>(
|
||||
`
|
||||
INSERT INTO shifts
|
||||
(store_id, employee_id, role_name, start_at, end_at, status, creator_admin_id, creator_employee_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
input.storeId,
|
||||
input.employeeId,
|
||||
input.roleName ?? null,
|
||||
new Date(input.startAt),
|
||||
new Date(input.endAt),
|
||||
input.status,
|
||||
creatorAdminId,
|
||||
creatorEmployeeId,
|
||||
],
|
||||
);
|
||||
return result.insertId;
|
||||
},
|
||||
|
||||
async update(id: number, input: UpdateShiftInput): Promise<void> {
|
||||
const fieldMap: Array<[keyof UpdateShiftInput, string]> = [
|
||||
["storeId", "store_id"],
|
||||
["employeeId", "employee_id"],
|
||||
["roleName", "role_name"],
|
||||
["startAt", "start_at"],
|
||||
["endAt", "end_at"],
|
||||
["status", "status"],
|
||||
];
|
||||
const sets: string[] = [];
|
||||
const params: SqlParam[] = [];
|
||||
|
||||
for (const [key, column] of fieldMap) {
|
||||
if (Object.prototype.hasOwnProperty.call(input, key)) {
|
||||
sets.push(`${column} = ?`);
|
||||
params.push((key === "startAt" || key === "endAt") && input[key] ? new Date(input[key]) : (input[key] as SqlParam));
|
||||
}
|
||||
}
|
||||
|
||||
if (sets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
params.push(id);
|
||||
await pool.execute(
|
||||
`UPDATE shifts SET ${sets.join(", ")} WHERE id = ? AND deleted_at IS NULL`,
|
||||
params,
|
||||
);
|
||||
},
|
||||
|
||||
async cancel(id: number): Promise<void> {
|
||||
await pool.execute(
|
||||
`
|
||||
UPDATE shifts
|
||||
SET status = 'CANCELLED', deleted_at = CURRENT_TIMESTAMP(3)
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`,
|
||||
[id],
|
||||
);
|
||||
},
|
||||
|
||||
async listTodayForEmployee(employeeId: number): Promise<Shift[]> {
|
||||
const [rows] = await pool.execute<ShiftRow[]>(
|
||||
`
|
||||
SELECT sh.*, s.name AS store_name, e.name AS employee_name
|
||||
FROM shifts sh
|
||||
INNER JOIN stores s ON s.id = sh.store_id
|
||||
INNER JOIN employees e ON e.id = sh.employee_id
|
||||
WHERE sh.employee_id = ?
|
||||
AND sh.deleted_at IS NULL
|
||||
AND sh.status = 'SCHEDULED'
|
||||
AND DATE(sh.start_at) = CURRENT_DATE()
|
||||
ORDER BY sh.start_at ASC
|
||||
`,
|
||||
[employeeId],
|
||||
);
|
||||
return rows.map(toShift);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { z } from "zod";
|
||||
import { SHIFT_STATUSES } from "./shift.types";
|
||||
|
||||
const emptyStringToUndefined = (value: unknown) =>
|
||||
typeof value === "string" && value.trim() === "" ? undefined : value;
|
||||
const dateTimeSchema = z.string().trim().datetime({ offset: true });
|
||||
const dateSchema = z.string().trim().regex(/^\d{4}-\d{2}-\d{2}$/, "日期格式应为 YYYY-MM-DD");
|
||||
|
||||
export const shiftIdParamSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
export const listShiftsQuerySchema = z.object({
|
||||
storeId: z.preprocess(emptyStringToUndefined, z.coerce.number().int().positive().optional()),
|
||||
employeeId: z.preprocess(emptyStringToUndefined, z.coerce.number().int().positive().optional()),
|
||||
status: z.preprocess(emptyStringToUndefined, z.enum(SHIFT_STATUSES).optional()),
|
||||
startDate: z.preprocess(emptyStringToUndefined, dateSchema.optional()),
|
||||
endDate: z.preprocess(emptyStringToUndefined, dateSchema.optional()),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
const shiftBodyBaseSchema = z.object({
|
||||
storeId: z.coerce.number().int().positive(),
|
||||
employeeId: z.coerce.number().int().positive(),
|
||||
roleName: z.string().trim().max(50).nullable().optional(),
|
||||
startAt: dateTimeSchema,
|
||||
endAt: dateTimeSchema,
|
||||
status: z.enum(SHIFT_STATUSES).default("SCHEDULED"),
|
||||
});
|
||||
|
||||
export const createShiftBodySchema = shiftBodyBaseSchema.refine(
|
||||
(value) => new Date(value.endAt).getTime() > new Date(value.startAt).getTime(),
|
||||
{
|
||||
message: "结束时间必须晚于开始时间",
|
||||
},
|
||||
);
|
||||
|
||||
export const updateShiftBodySchema = shiftBodyBaseSchema
|
||||
.partial()
|
||||
.refine((value) => Object.keys(value).length > 0, {
|
||||
message: "至少需要提交一个要修改的字段",
|
||||
})
|
||||
.refine(
|
||||
(value) =>
|
||||
!value.startAt ||
|
||||
!value.endAt ||
|
||||
new Date(value.endAt).getTime() > new Date(value.startAt).getTime(),
|
||||
{ message: "结束时间必须晚于开始时间" },
|
||||
);
|
||||
@@ -0,0 +1,95 @@
|
||||
import { badRequest, conflict, notFound } from "../../shared/http-error";
|
||||
import type { AuthUser } from "../auth/auth.types";
|
||||
import { shiftRepository } from "./shift.repository";
|
||||
import type { CreateShiftInput, ListShiftsQuery, UpdateShiftInput } from "./shift.types";
|
||||
|
||||
async function assertEmployeeStore(employeeId: number, storeId: number): Promise<void> {
|
||||
const matched = await shiftRepository.employeeBelongsToStore(employeeId, storeId);
|
||||
|
||||
if (!matched) {
|
||||
throw badRequest("排班员工不存在、已停用或不属于该门店");
|
||||
}
|
||||
}
|
||||
|
||||
function assertTimeRange(startAt: string, endAt: string): void {
|
||||
if (new Date(endAt).getTime() <= new Date(startAt).getTime()) {
|
||||
throw badRequest("结束时间必须晚于开始时间");
|
||||
}
|
||||
}
|
||||
|
||||
async function assertNoOverlap(
|
||||
employeeId: number,
|
||||
startAt: string,
|
||||
endAt: string,
|
||||
excludeShiftId?: number,
|
||||
): Promise<void> {
|
||||
const overlapped = await shiftRepository.hasOverlappingShift(employeeId, startAt, endAt, excludeShiftId);
|
||||
|
||||
if (overlapped) {
|
||||
throw conflict("该员工在该时间段已有排班");
|
||||
}
|
||||
}
|
||||
|
||||
export const shiftService = {
|
||||
list(query: ListShiftsQuery) {
|
||||
return shiftRepository.list(query);
|
||||
},
|
||||
|
||||
async getById(id: number) {
|
||||
const shift = await shiftRepository.findById(id);
|
||||
|
||||
if (!shift) {
|
||||
throw notFound("排班不存在");
|
||||
}
|
||||
|
||||
return shift;
|
||||
},
|
||||
|
||||
async create(input: CreateShiftInput, user: AuthUser) {
|
||||
await assertEmployeeStore(input.employeeId, input.storeId);
|
||||
assertTimeRange(input.startAt, input.endAt);
|
||||
|
||||
if (input.status !== "CANCELLED") {
|
||||
await assertNoOverlap(input.employeeId, input.startAt, input.endAt);
|
||||
}
|
||||
|
||||
const id = await shiftRepository.create(input, user);
|
||||
return this.getById(id);
|
||||
},
|
||||
|
||||
async update(id: number, input: UpdateShiftInput) {
|
||||
const current = await this.getById(id);
|
||||
const employeeId = input.employeeId ?? current.employeeId;
|
||||
const storeId = input.storeId ?? current.storeId;
|
||||
await assertEmployeeStore(employeeId, storeId);
|
||||
const startAt = input.startAt ?? current.startAt;
|
||||
const endAt = input.endAt ?? current.endAt;
|
||||
const status = input.status ?? current.status;
|
||||
|
||||
assertTimeRange(startAt, endAt);
|
||||
|
||||
if (status !== "CANCELLED") {
|
||||
await assertNoOverlap(employeeId, startAt, endAt, id);
|
||||
}
|
||||
|
||||
await shiftRepository.update(id, input);
|
||||
return this.getById(id);
|
||||
},
|
||||
|
||||
async cancel(id: number) {
|
||||
await this.getById(id);
|
||||
await shiftRepository.cancel(id);
|
||||
},
|
||||
|
||||
listForEmployee(employee: AuthUser, query: ListShiftsQuery) {
|
||||
return shiftRepository.list({
|
||||
...query,
|
||||
employeeId: employee.id,
|
||||
storeId: undefined,
|
||||
});
|
||||
},
|
||||
|
||||
todayForEmployee(employee: AuthUser) {
|
||||
return shiftRepository.listTodayForEmployee(employee.id);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
export const SHIFT_STATUSES = ["SCHEDULED", "CANCELLED"] as const;
|
||||
export type ShiftStatus = (typeof SHIFT_STATUSES)[number];
|
||||
|
||||
export interface Shift {
|
||||
id: number;
|
||||
storeId: number;
|
||||
storeName: string;
|
||||
employeeId: number;
|
||||
employeeName: string;
|
||||
roleName: string | null;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
status: ShiftStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ListShiftsQuery {
|
||||
storeId?: number;
|
||||
employeeId?: number;
|
||||
status?: ShiftStatus;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface CreateShiftInput {
|
||||
storeId: number;
|
||||
employeeId: number;
|
||||
roleName?: string | null;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
status: ShiftStatus;
|
||||
}
|
||||
|
||||
export type UpdateShiftInput = Partial<CreateShiftInput>;
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { created, ok, paginated } from "../../shared/response";
|
||||
import { authGuard, permissionGuard } from "../auth/auth.guard";
|
||||
import { authService } from "../auth/auth.service";
|
||||
import { PERMISSIONS } from "../permissions/permission.policy";
|
||||
import { createTaskBodySchema, listTasksQuerySchema, taskCommentBodySchema, taskIdParamSchema, updateTaskBodySchema } from "./task.schema";
|
||||
import { taskService } from "./task.service";
|
||||
|
||||
export async function taskAdminRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/admin/tasks", { preHandler: permissionGuard(PERMISSIONS.TASK_VIEW) }, async (request) => {
|
||||
const query = listTasksQuerySchema.parse(request.query);
|
||||
const result = await taskService.list(query);
|
||||
return paginated(result.items, query.page, query.pageSize, result.total);
|
||||
});
|
||||
|
||||
app.post("/admin/tasks", { preHandler: permissionGuard(PERMISSIONS.TASK_MANAGE) }, async (request, reply) => {
|
||||
const user = await authService.getCurrentUser(request.user);
|
||||
const body = createTaskBodySchema.parse(request.body);
|
||||
const task = await taskService.create(body, user);
|
||||
return reply.code(201).send(created(task));
|
||||
});
|
||||
|
||||
app.patch("/admin/tasks/:id", { preHandler: permissionGuard(PERMISSIONS.TASK_MANAGE) }, async (request) => {
|
||||
const user = await authService.getCurrentUser(request.user);
|
||||
const params = taskIdParamSchema.parse(request.params);
|
||||
const body = updateTaskBodySchema.parse(request.body);
|
||||
return ok(await taskService.update(params.id, body, user));
|
||||
});
|
||||
|
||||
app.post("/admin/tasks/:id/cancel", { preHandler: permissionGuard(PERMISSIONS.TASK_MANAGE) }, async (request) => {
|
||||
const user = await authService.getCurrentUser(request.user);
|
||||
const params = taskIdParamSchema.parse(request.params);
|
||||
return ok(await taskService.cancel(params.id, user));
|
||||
});
|
||||
}
|
||||
|
||||
export async function taskMobileRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/mobile/tasks", { preHandler: authGuard }, async (request) => {
|
||||
const employee = await authService.getCurrentEmployeeUser(request.user);
|
||||
const query = listTasksQuerySchema.parse(request.query);
|
||||
const result = await taskService.listForEmployee(employee, query);
|
||||
return paginated(result.items, query.page, query.pageSize, result.total);
|
||||
});
|
||||
|
||||
app.get("/mobile/tasks/:id", { preHandler: authGuard }, async (request) => {
|
||||
const employee = await authService.getCurrentEmployeeUser(request.user);
|
||||
const params = taskIdParamSchema.parse(request.params);
|
||||
return ok(await taskService.getForEmployee(params.id, employee));
|
||||
});
|
||||
|
||||
app.post("/mobile/tasks/:id/start", { preHandler: authGuard }, async (request) => {
|
||||
const employee = await authService.getCurrentEmployeeUser(request.user);
|
||||
const params = taskIdParamSchema.parse(request.params);
|
||||
return ok(await taskService.start(params.id, employee));
|
||||
});
|
||||
|
||||
app.post("/mobile/tasks/:id/complete", { preHandler: authGuard }, async (request) => {
|
||||
const employee = await authService.getCurrentEmployeeUser(request.user);
|
||||
const params = taskIdParamSchema.parse(request.params);
|
||||
return ok(await taskService.complete(params.id, employee));
|
||||
});
|
||||
|
||||
app.post("/mobile/tasks/:id/comment", { preHandler: authGuard }, async (request) => {
|
||||
const employee = await authService.getCurrentEmployeeUser(request.user);
|
||||
const params = taskIdParamSchema.parse(request.params);
|
||||
const body = taskCommentBodySchema.parse(request.body);
|
||||
return ok(await taskService.comment(params.id, employee, body.comment));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import type { PoolConnection, ResultSetHeader, RowDataPacket } from "mysql2/promise";
|
||||
import { pool } from "../../db/pool";
|
||||
import type { AuthUser } from "../auth/auth.types";
|
||||
import type { CreateTaskInput, ListTasksQuery, Task, TaskEvent, TaskStatus, UpdateTaskInput } from "./task.types";
|
||||
|
||||
type DbExecutor = typeof pool | PoolConnection;
|
||||
type SqlParam = string | number | Date | null;
|
||||
|
||||
interface TaskRow extends RowDataPacket {
|
||||
id: number;
|
||||
store_id: number | null;
|
||||
store_name: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: TaskStatus;
|
||||
priority: Task["priority"];
|
||||
due_at: Date | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
interface AssigneeRow extends RowDataPacket {
|
||||
task_id: number;
|
||||
id: number;
|
||||
name: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
interface EventRow extends RowDataPacket {
|
||||
id: number;
|
||||
task_id: number;
|
||||
event_type: TaskEvent["eventType"];
|
||||
from_status: TaskStatus | null;
|
||||
to_status: TaskStatus | null;
|
||||
comment: string | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
interface CountRow extends RowDataPacket {
|
||||
total: number;
|
||||
}
|
||||
|
||||
function toIso(value: Date | null): string | null {
|
||||
return value ? value.toISOString() : null;
|
||||
}
|
||||
|
||||
function toTask(row: TaskRow, assignees: Task["assignees"] = []): Task {
|
||||
return {
|
||||
id: row.id,
|
||||
storeId: row.store_id,
|
||||
storeName: row.store_name,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
status: row.status,
|
||||
priority: row.priority,
|
||||
dueAt: toIso(row.due_at),
|
||||
assignees,
|
||||
createdAt: toIso(row.created_at) ?? "",
|
||||
updatedAt: toIso(row.updated_at) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function actorValues(user: AuthUser): [number | null, number | null] {
|
||||
return user.accountType === "SUPER_ADMIN" ? [user.id, null] : [null, user.id];
|
||||
}
|
||||
|
||||
async function findAssigneesByTaskIds(ids: number[], db: DbExecutor = pool) {
|
||||
const result = new Map<number, Task["assignees"]>();
|
||||
|
||||
if (ids.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const [rows] = await db.execute<AssigneeRow[]>(
|
||||
`
|
||||
SELECT ta.task_id, e.id, e.name, e.phone
|
||||
FROM task_assignees ta
|
||||
INNER JOIN employees e ON e.id = ta.employee_id
|
||||
WHERE ta.task_id IN (${ids.map(() => "?").join(", ")})
|
||||
ORDER BY e.id ASC
|
||||
`,
|
||||
ids,
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
const assignees = result.get(row.task_id) ?? [];
|
||||
assignees.push({ id: row.id, name: row.name, phone: row.phone });
|
||||
result.set(row.task_id, assignees);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export const taskRepository = {
|
||||
async withTransaction<T>(handler: (connection: PoolConnection) => Promise<T>): Promise<T> {
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const result = await handler(connection);
|
||||
await connection.commit();
|
||||
return result;
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
},
|
||||
|
||||
async employeeIdsExist(ids: number[], db: DbExecutor = pool): Promise<number[]> {
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [rows] = await db.execute<(RowDataPacket & { id: number })[]>(
|
||||
`
|
||||
SELECT id
|
||||
FROM employees
|
||||
WHERE id IN (${ids.map(() => "?").join(", ")})
|
||||
AND deleted_at IS NULL
|
||||
AND status = 'ACTIVE'
|
||||
`,
|
||||
ids,
|
||||
);
|
||||
return rows.map((row) => row.id);
|
||||
},
|
||||
|
||||
async list(query: ListTasksQuery): Promise<{ items: Task[]; total: number }> {
|
||||
const where = ["t.deleted_at IS NULL"];
|
||||
const params: SqlParam[] = [];
|
||||
|
||||
if (query.storeId !== undefined) {
|
||||
where.push("t.store_id = ?");
|
||||
params.push(query.storeId);
|
||||
}
|
||||
|
||||
if (query.status) {
|
||||
where.push("t.status = ?");
|
||||
params.push(query.status);
|
||||
}
|
||||
|
||||
if (query.keyword) {
|
||||
where.push("(t.title LIKE ? OR t.description LIKE ?)");
|
||||
params.push(`%${query.keyword}%`, `%${query.keyword}%`);
|
||||
}
|
||||
|
||||
const whereSql = where.join(" AND ");
|
||||
const offset = (query.page - 1) * query.pageSize;
|
||||
const [countRows] = await pool.execute<CountRow[]>(
|
||||
`
|
||||
SELECT COUNT(*) AS total
|
||||
FROM tasks t
|
||||
LEFT JOIN stores s ON s.id = t.store_id
|
||||
WHERE ${whereSql}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
const [rows] = await pool.execute<TaskRow[]>(
|
||||
`
|
||||
SELECT t.*, s.name AS store_name
|
||||
FROM tasks t
|
||||
LEFT JOIN stores s ON s.id = t.store_id
|
||||
WHERE ${whereSql}
|
||||
ORDER BY t.id DESC
|
||||
LIMIT ${query.pageSize} OFFSET ${offset}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
const assigneesByTaskId = await findAssigneesByTaskIds(rows.map((row) => row.id));
|
||||
|
||||
return {
|
||||
items: rows.map((row) => toTask(row, assigneesByTaskId.get(row.id) ?? [])),
|
||||
total: countRows[0]?.total ?? 0,
|
||||
};
|
||||
},
|
||||
|
||||
async findById(id: number, db: DbExecutor = pool): Promise<Task | null> {
|
||||
const [rows] = await db.execute<TaskRow[]>(
|
||||
`
|
||||
SELECT t.*, s.name AS store_name
|
||||
FROM tasks t
|
||||
LEFT JOIN stores s ON s.id = t.store_id
|
||||
WHERE t.id = ? AND t.deleted_at IS NULL
|
||||
LIMIT 1
|
||||
`,
|
||||
[id],
|
||||
);
|
||||
|
||||
if (!rows[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assigneesByTaskId = await findAssigneesByTaskIds([id], db);
|
||||
return toTask(rows[0], assigneesByTaskId.get(id) ?? []);
|
||||
},
|
||||
|
||||
async create(input: CreateTaskInput, user: AuthUser, db: DbExecutor = pool): Promise<number> {
|
||||
const [creatorAdminId, creatorEmployeeId] = actorValues(user);
|
||||
const [result] = await db.execute<ResultSetHeader>(
|
||||
`
|
||||
INSERT INTO tasks
|
||||
(store_id, title, description, priority, due_at, creator_admin_id, creator_employee_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
input.storeId ?? null,
|
||||
input.title,
|
||||
input.description ?? null,
|
||||
input.priority,
|
||||
input.dueAt ? new Date(input.dueAt) : null,
|
||||
creatorAdminId,
|
||||
creatorEmployeeId,
|
||||
],
|
||||
);
|
||||
await this.replaceAssignees(result.insertId, input.assigneeIds, db);
|
||||
await this.addEvent(result.insertId, "CREATED", null, "PENDING", null, user, null, db);
|
||||
return result.insertId;
|
||||
},
|
||||
|
||||
async update(id: number, input: UpdateTaskInput, user: AuthUser, db: DbExecutor = pool): Promise<void> {
|
||||
const sets: string[] = [];
|
||||
const params: SqlParam[] = [];
|
||||
const fieldMap: Array<[keyof UpdateTaskInput, string]> = [
|
||||
["storeId", "store_id"],
|
||||
["title", "title"],
|
||||
["description", "description"],
|
||||
["priority", "priority"],
|
||||
["dueAt", "due_at"],
|
||||
];
|
||||
|
||||
for (const [key, column] of fieldMap) {
|
||||
if (Object.prototype.hasOwnProperty.call(input, key)) {
|
||||
sets.push(`${column} = ?`);
|
||||
params.push(key === "dueAt" && input.dueAt ? new Date(input.dueAt) : (input[key] as SqlParam));
|
||||
}
|
||||
}
|
||||
|
||||
if (sets.length > 0) {
|
||||
params.push(id);
|
||||
await db.execute(`UPDATE tasks SET ${sets.join(", ")} WHERE id = ? AND deleted_at IS NULL`, params);
|
||||
}
|
||||
|
||||
if (input.assigneeIds !== undefined) {
|
||||
await this.replaceAssignees(id, input.assigneeIds, db);
|
||||
}
|
||||
|
||||
await this.addEvent(id, "UPDATED", null, null, null, user, null, db);
|
||||
},
|
||||
|
||||
async replaceAssignees(taskId: number, employeeIds: number[], db: DbExecutor = pool): Promise<void> {
|
||||
await db.execute("DELETE FROM task_assignees WHERE task_id = ?", [taskId]);
|
||||
await db.execute(
|
||||
`
|
||||
INSERT INTO task_assignees (task_id, employee_id)
|
||||
VALUES ${employeeIds.map(() => "(?, ?)").join(", ")}
|
||||
`,
|
||||
employeeIds.flatMap((employeeId) => [taskId, employeeId]),
|
||||
);
|
||||
},
|
||||
|
||||
async setStatus(id: number, fromStatus: TaskStatus, toStatus: TaskStatus, user: AuthUser, comment?: string | null): Promise<void> {
|
||||
await pool.execute(
|
||||
"UPDATE tasks SET status = ? WHERE id = ? AND deleted_at IS NULL",
|
||||
[toStatus, id],
|
||||
);
|
||||
await this.addEvent(id, toStatus === "CANCELLED" ? "CANCELLED" : toStatus === "COMPLETED" ? "COMPLETED" : "STARTED", fromStatus, toStatus, null, user, comment ?? null);
|
||||
},
|
||||
|
||||
async addComment(id: number, employeeId: number, user: AuthUser, comment: string): Promise<void> {
|
||||
await this.addEvent(id, "COMMENTED", null, null, employeeId, user, comment);
|
||||
},
|
||||
|
||||
async addEvent(
|
||||
taskId: number,
|
||||
eventType: TaskEvent["eventType"],
|
||||
fromStatus: TaskStatus | null,
|
||||
toStatus: TaskStatus | null,
|
||||
employeeId: number | null,
|
||||
user: AuthUser,
|
||||
comment: string | null,
|
||||
db: DbExecutor = pool,
|
||||
): Promise<void> {
|
||||
const [actorAdminId, actorEmployeeId] = actorValues(user);
|
||||
await db.execute(
|
||||
`
|
||||
INSERT INTO task_events
|
||||
(task_id, employee_id, actor_admin_id, actor_employee_id, event_type, from_status, to_status, comment)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[taskId, employeeId, actorAdminId, actorEmployeeId, eventType, fromStatus, toStatus, comment],
|
||||
);
|
||||
},
|
||||
|
||||
async listEvents(taskId: number): Promise<TaskEvent[]> {
|
||||
const [rows] = await pool.execute<EventRow[]>(
|
||||
`
|
||||
SELECT id, task_id, event_type, from_status, to_status, comment, created_at
|
||||
FROM task_events
|
||||
WHERE task_id = ?
|
||||
ORDER BY id ASC
|
||||
`,
|
||||
[taskId],
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
taskId: row.task_id,
|
||||
eventType: row.event_type,
|
||||
fromStatus: row.from_status,
|
||||
toStatus: row.to_status,
|
||||
comment: row.comment,
|
||||
createdAt: toIso(row.created_at) ?? "",
|
||||
}));
|
||||
},
|
||||
|
||||
async listForEmployee(employeeId: number, query: ListTasksQuery): Promise<{ items: Task[]; total: number }> {
|
||||
const where = ["t.deleted_at IS NULL", "ta.employee_id = ?"];
|
||||
const params: SqlParam[] = [employeeId];
|
||||
|
||||
if (query.status) {
|
||||
where.push("t.status = ?");
|
||||
params.push(query.status);
|
||||
}
|
||||
|
||||
if (query.keyword) {
|
||||
where.push("(t.title LIKE ? OR t.description LIKE ?)");
|
||||
params.push(`%${query.keyword}%`, `%${query.keyword}%`);
|
||||
}
|
||||
|
||||
const whereSql = where.join(" AND ");
|
||||
const offset = (query.page - 1) * query.pageSize;
|
||||
const [countRows] = await pool.execute<CountRow[]>(
|
||||
`
|
||||
SELECT COUNT(*) AS total
|
||||
FROM tasks t
|
||||
INNER JOIN task_assignees ta ON ta.task_id = t.id
|
||||
WHERE ${whereSql}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
const [rows] = await pool.execute<TaskRow[]>(
|
||||
`
|
||||
SELECT t.*, s.name AS store_name
|
||||
FROM tasks t
|
||||
INNER JOIN task_assignees ta ON ta.task_id = t.id
|
||||
LEFT JOIN stores s ON s.id = t.store_id
|
||||
WHERE ${whereSql}
|
||||
ORDER BY t.id DESC
|
||||
LIMIT ${query.pageSize} OFFSET ${offset}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
const assigneesByTaskId = await findAssigneesByTaskIds(rows.map((row) => row.id));
|
||||
|
||||
return {
|
||||
items: rows.map((row) => toTask(row, assigneesByTaskId.get(row.id) ?? [])),
|
||||
total: countRows[0]?.total ?? 0,
|
||||
};
|
||||
},
|
||||
|
||||
async isAssigned(taskId: number, employeeId: number): Promise<boolean> {
|
||||
const [rows] = await pool.execute<CountRow[]>(
|
||||
"SELECT COUNT(*) AS total FROM task_assignees WHERE task_id = ? AND employee_id = ?",
|
||||
[taskId, employeeId],
|
||||
);
|
||||
return (rows[0]?.total ?? 0) > 0;
|
||||
},
|
||||
|
||||
async listOpenForEmployee(employeeId: number, limit: number): Promise<Task[]> {
|
||||
const [rows] = await pool.execute<TaskRow[]>(
|
||||
`
|
||||
SELECT t.*, s.name AS store_name
|
||||
FROM tasks t
|
||||
INNER JOIN task_assignees ta ON ta.task_id = t.id
|
||||
LEFT JOIN stores s ON s.id = t.store_id
|
||||
WHERE ta.employee_id = ?
|
||||
AND t.deleted_at IS NULL
|
||||
AND t.status IN ('PENDING', 'IN_PROGRESS')
|
||||
ORDER BY
|
||||
t.due_at IS NULL ASC,
|
||||
t.due_at ASC,
|
||||
t.id DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
[employeeId, limit],
|
||||
);
|
||||
const assigneesByTaskId = await findAssigneesByTaskIds(rows.map((row) => row.id));
|
||||
return rows.map((row) => toTask(row, assigneesByTaskId.get(row.id) ?? []));
|
||||
},
|
||||
|
||||
async countPendingForEmployee(employeeId: number): Promise<number> {
|
||||
const [rows] = await pool.execute<CountRow[]>(
|
||||
`
|
||||
SELECT COUNT(*) AS total
|
||||
FROM tasks t
|
||||
INNER JOIN task_assignees ta ON ta.task_id = t.id
|
||||
WHERE ta.employee_id = ?
|
||||
AND t.deleted_at IS NULL
|
||||
AND t.status IN ('PENDING', 'IN_PROGRESS')
|
||||
`,
|
||||
[employeeId],
|
||||
);
|
||||
return rows[0]?.total ?? 0;
|
||||
},
|
||||
|
||||
async countOverdueForEmployee(employeeId: number): Promise<number> {
|
||||
const [rows] = await pool.execute<CountRow[]>(
|
||||
`
|
||||
SELECT COUNT(*) AS total
|
||||
FROM tasks t
|
||||
INNER JOIN task_assignees ta ON ta.task_id = t.id
|
||||
WHERE ta.employee_id = ?
|
||||
AND t.deleted_at IS NULL
|
||||
AND t.status IN ('PENDING', 'IN_PROGRESS')
|
||||
AND t.due_at IS NOT NULL
|
||||
AND t.due_at < CURRENT_TIMESTAMP(3)
|
||||
`,
|
||||
[employeeId],
|
||||
);
|
||||
return rows[0]?.total ?? 0;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { z } from "zod";
|
||||
import { TASK_PRIORITIES, TASK_STATUSES } from "./task.types";
|
||||
|
||||
const emptyStringToUndefined = (value: unknown) =>
|
||||
typeof value === "string" && value.trim() === "" ? undefined : value;
|
||||
|
||||
const dateStringSchema = z.string().trim().datetime({ offset: true });
|
||||
|
||||
export const taskIdParamSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
export const listTasksQuerySchema = z.object({
|
||||
storeId: z.preprocess(emptyStringToUndefined, z.coerce.number().int().positive().optional()),
|
||||
status: z.preprocess(emptyStringToUndefined, z.enum(TASK_STATUSES).optional()),
|
||||
keyword: z.preprocess(emptyStringToUndefined, z.string().trim().min(1).max(100).optional()),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
export const createTaskBodySchema = z.object({
|
||||
storeId: z.coerce.number().int().positive().nullable().optional(),
|
||||
title: z.string().trim().min(1).max(120),
|
||||
description: z.string().trim().max(20000).nullable().optional(),
|
||||
priority: z.enum(TASK_PRIORITIES).default("NORMAL"),
|
||||
dueAt: dateStringSchema.nullable().optional(),
|
||||
assigneeIds: z.array(z.coerce.number().int().positive()).min(1).max(200),
|
||||
});
|
||||
|
||||
export const updateTaskBodySchema = createTaskBodySchema
|
||||
.partial()
|
||||
.refine((value) => Object.keys(value).length > 0, {
|
||||
message: "至少需要提交一个要修改的字段",
|
||||
});
|
||||
|
||||
export const taskCommentBodySchema = z.object({
|
||||
comment: z.string().trim().min(1).max(1000),
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { badRequest, forbidden, notFound } from "../../shared/http-error";
|
||||
import type { AuthUser } from "../auth/auth.types";
|
||||
import { taskRepository } from "./task.repository";
|
||||
import type { CreateTaskInput, ListTasksQuery, Task, UpdateTaskInput } from "./task.types";
|
||||
|
||||
function uniqueIds(ids: number[]): number[] {
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
async function assertAssigneesExist(ids: number[]): Promise<number[]> {
|
||||
const unique = uniqueIds(ids);
|
||||
const existingIds = await taskRepository.employeeIdsExist(unique);
|
||||
|
||||
if (existingIds.length !== unique.length) {
|
||||
throw badRequest("任务分配员工包含不存在、停用或已删除的员工");
|
||||
}
|
||||
|
||||
return unique;
|
||||
}
|
||||
|
||||
function assertCanTransition(task: Task, action: "start" | "complete" | "cancel"): void {
|
||||
if (action === "start" && task.status !== "PENDING") {
|
||||
throw badRequest("只有待处理任务可以开始");
|
||||
}
|
||||
|
||||
if (action === "complete" && !["PENDING", "IN_PROGRESS"].includes(task.status)) {
|
||||
throw badRequest("只有待处理或处理中任务可以完成");
|
||||
}
|
||||
|
||||
if (action === "cancel" && task.status === "COMPLETED") {
|
||||
throw badRequest("已完成任务不能取消");
|
||||
}
|
||||
}
|
||||
|
||||
export const taskService = {
|
||||
list(query: ListTasksQuery) {
|
||||
return taskRepository.list(query);
|
||||
},
|
||||
|
||||
async getById(id: number) {
|
||||
const task = await taskRepository.findById(id);
|
||||
|
||||
if (!task) {
|
||||
throw notFound("任务不存在");
|
||||
}
|
||||
|
||||
return {
|
||||
...task,
|
||||
events: await taskRepository.listEvents(id),
|
||||
};
|
||||
},
|
||||
|
||||
async create(input: CreateTaskInput, user: AuthUser) {
|
||||
const assigneeIds = await assertAssigneesExist(input.assigneeIds);
|
||||
const id = await taskRepository.withTransaction((connection) =>
|
||||
taskRepository.create({ ...input, assigneeIds }, user, connection),
|
||||
);
|
||||
return this.getById(id);
|
||||
},
|
||||
|
||||
async update(id: number, input: UpdateTaskInput, user: AuthUser) {
|
||||
await this.getById(id);
|
||||
const assigneeIds = input.assigneeIds === undefined ? undefined : await assertAssigneesExist(input.assigneeIds);
|
||||
await taskRepository.withTransaction((connection) =>
|
||||
taskRepository.update(id, { ...input, assigneeIds }, user, connection),
|
||||
);
|
||||
return this.getById(id);
|
||||
},
|
||||
|
||||
async cancel(id: number, user: AuthUser) {
|
||||
const task = await this.getById(id);
|
||||
assertCanTransition(task, "cancel");
|
||||
await taskRepository.setStatus(id, task.status, "CANCELLED", user);
|
||||
return this.getById(id);
|
||||
},
|
||||
|
||||
listForEmployee(employee: AuthUser, query: ListTasksQuery) {
|
||||
return taskRepository.listForEmployee(employee.id, query);
|
||||
},
|
||||
|
||||
async getForEmployee(id: number, employee: AuthUser) {
|
||||
const assigned = await taskRepository.isAssigned(id, employee.id);
|
||||
|
||||
if (!assigned) {
|
||||
throw notFound("任务不存在");
|
||||
}
|
||||
|
||||
return this.getById(id);
|
||||
},
|
||||
|
||||
async start(id: number, employee: AuthUser) {
|
||||
const task = await this.getForEmployee(id, employee);
|
||||
assertCanTransition(task, "start");
|
||||
await taskRepository.setStatus(id, task.status, "IN_PROGRESS", employee);
|
||||
return this.getForEmployee(id, employee);
|
||||
},
|
||||
|
||||
async complete(id: number, employee: AuthUser) {
|
||||
const task = await this.getForEmployee(id, employee);
|
||||
assertCanTransition(task, "complete");
|
||||
await taskRepository.setStatus(id, task.status, "COMPLETED", employee);
|
||||
return this.getForEmployee(id, employee);
|
||||
},
|
||||
|
||||
async comment(id: number, employee: AuthUser, comment: string) {
|
||||
await this.getForEmployee(id, employee);
|
||||
await taskRepository.addComment(id, employee.id, employee, comment);
|
||||
return this.getForEmployee(id, employee);
|
||||
},
|
||||
|
||||
async countPendingForEmployee(employee: AuthUser) {
|
||||
if (employee.accountType !== "EMPLOYEE") {
|
||||
throw forbidden("只有员工账号可以访问员工端任务");
|
||||
}
|
||||
|
||||
return taskRepository.countPendingForEmployee(employee.id);
|
||||
},
|
||||
|
||||
async listOpenForEmployee(employee: AuthUser, limit: number) {
|
||||
if (employee.accountType !== "EMPLOYEE") {
|
||||
throw forbidden("只有员工账号可以访问员工端任务");
|
||||
}
|
||||
|
||||
return taskRepository.listOpenForEmployee(employee.id, limit);
|
||||
},
|
||||
|
||||
async countOverdueForEmployee(employee: AuthUser) {
|
||||
if (employee.accountType !== "EMPLOYEE") {
|
||||
throw forbidden("只有员工账号可以访问员工端任务");
|
||||
}
|
||||
|
||||
return taskRepository.countOverdueForEmployee(employee.id);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
export const TASK_STATUSES = ["PENDING", "IN_PROGRESS", "COMPLETED", "CANCELLED"] as const;
|
||||
export const TASK_PRIORITIES = ["LOW", "NORMAL", "HIGH", "URGENT"] as const;
|
||||
|
||||
export type TaskStatus = (typeof TASK_STATUSES)[number];
|
||||
export type TaskPriority = (typeof TASK_PRIORITIES)[number];
|
||||
|
||||
export interface Task {
|
||||
id: number;
|
||||
storeId: number | null;
|
||||
storeName: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: TaskStatus;
|
||||
priority: TaskPriority;
|
||||
dueAt: string | null;
|
||||
assignees: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
phone: string;
|
||||
}>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TaskEvent {
|
||||
id: number;
|
||||
taskId: number;
|
||||
eventType: "CREATED" | "UPDATED" | "STARTED" | "COMPLETED" | "CANCELLED" | "COMMENTED";
|
||||
fromStatus: TaskStatus | null;
|
||||
toStatus: TaskStatus | null;
|
||||
comment: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ListTasksQuery {
|
||||
storeId?: number;
|
||||
status?: TaskStatus;
|
||||
keyword?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface CreateTaskInput {
|
||||
storeId?: number | null;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
priority: TaskPriority;
|
||||
dueAt?: string | null;
|
||||
assigneeIds: number[];
|
||||
}
|
||||
|
||||
export type UpdateTaskInput = Partial<CreateTaskInput>;
|
||||
Reference in New Issue
Block a user