feat: 增加员工端工作台后端能力

This commit is contained in:
湛兮
2026-06-02 12:23:00 +08:00
parent 667dc411fc
commit 98cea63203
33 changed files with 3021 additions and 18 deletions
+1
View File
@@ -4,3 +4,4 @@ dist
.env.*
.DS_Store
*.log
dist.zip
+27 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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>;
+11 -1
View File
@@ -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);
});
}
+22 -3
View File
@@ -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,
};
}
+5
View File
@@ -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),
});
+42 -2
View File
@@ -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);
},
};
+2
View File
@@ -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;
}
+9 -2
View File
@@ -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);
});
+61
View File
@@ -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 [];
}
+48
View File
@@ -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);
});
}
+254
View File
@@ -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);
},
};
+50
View File
@@ -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: "结束时间必须晚于开始时间" },
);
+95
View File
@@ -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);
},
};
+37
View File
@@ -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>;
+69
View File
@@ -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));
});
}
+423
View File
@@ -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;
},
};
+38
View File
@@ -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),
});
+134
View File
@@ -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);
},
};
+52
View File
@@ -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>;