From 98cea63203c267e3f6ca6eb88b4de0b52054c749 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=9B=E5=85=AE?= Date: Tue, 2 Jun 2026 12:23:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=91=98=E5=B7=A5?= =?UTF-8?q?=E7=AB=AF=E5=B7=A5=E4=BD=9C=E5=8F=B0=E5=90=8E=E7=AB=AF=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + README.md | 33 +- docs/API.md | 305 ++++++++++++- .../008_add_role_user_workbench_tables.sql | 169 +++++++ src/app.ts | 13 + .../announcements/announcement.controller.ts | 64 +++ .../announcements/announcement.repository.ts | 401 +++++++++++++++++ .../announcements/announcement.schema.ts | 48 ++ .../announcements/announcement.service.ts | 100 +++++ .../announcements/announcement.types.ts | 43 ++ src/modules/auth/auth.controller.ts | 12 +- src/modules/auth/auth.repository.ts | 25 +- src/modules/auth/auth.schema.ts | 5 + src/modules/auth/auth.service.ts | 44 +- src/modules/auth/auth.types.ts | 2 + .../credentials/credential.controller.ts | 29 ++ .../credentials/credential.repository.ts | 192 ++++++++ src/modules/credentials/credential.schema.ts | 23 + src/modules/credentials/credential.service.ts | 86 ++++ src/modules/credentials/credential.types.ts | 32 ++ src/modules/employees/employee.controller.ts | 11 +- src/modules/mobile/mobile.controller.ts | 61 +++ src/modules/permissions/permission.policy.ts | 140 ++++++ src/modules/shifts/shift.controller.ts | 48 ++ src/modules/shifts/shift.repository.ts | 254 +++++++++++ src/modules/shifts/shift.schema.ts | 50 +++ src/modules/shifts/shift.service.ts | 95 ++++ src/modules/shifts/shift.types.ts | 37 ++ src/modules/tasks/task.controller.ts | 69 +++ src/modules/tasks/task.repository.ts | 423 ++++++++++++++++++ src/modules/tasks/task.schema.ts | 38 ++ src/modules/tasks/task.service.ts | 134 ++++++ src/modules/tasks/task.types.ts | 52 +++ 33 files changed, 3021 insertions(+), 18 deletions(-) create mode 100644 migrations/008_add_role_user_workbench_tables.sql create mode 100644 src/modules/announcements/announcement.controller.ts create mode 100644 src/modules/announcements/announcement.repository.ts create mode 100644 src/modules/announcements/announcement.schema.ts create mode 100644 src/modules/announcements/announcement.service.ts create mode 100644 src/modules/announcements/announcement.types.ts create mode 100644 src/modules/credentials/credential.controller.ts create mode 100644 src/modules/credentials/credential.repository.ts create mode 100644 src/modules/credentials/credential.schema.ts create mode 100644 src/modules/credentials/credential.service.ts create mode 100644 src/modules/credentials/credential.types.ts create mode 100644 src/modules/mobile/mobile.controller.ts create mode 100644 src/modules/shifts/shift.controller.ts create mode 100644 src/modules/shifts/shift.repository.ts create mode 100644 src/modules/shifts/shift.schema.ts create mode 100644 src/modules/shifts/shift.service.ts create mode 100644 src/modules/shifts/shift.types.ts create mode 100644 src/modules/tasks/task.controller.ts create mode 100644 src/modules/tasks/task.repository.ts create mode 100644 src/modules/tasks/task.schema.ts create mode 100644 src/modules/tasks/task.service.ts create mode 100644 src/modules/tasks/task.types.ts diff --git a/.gitignore b/.gitignore index 80f1ad3..eb349bc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist .env.* .DS_Store *.log +dist.zip diff --git a/README.md b/README.md index 774a990..3efa0b2 100644 --- a/README.md +++ b/README.md @@ -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` 渲染可分配权限点。 diff --git a/docs/API.md b/docs/API.md index c17f6e8..d1bcbb2 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 ### PATCH /api/employees/:id/password/reset -重置员工密码为初始密码 `pw111111`。需要 `employee:manage`。 +兼容旧路径。重置员工临时密码。需要 `credential:reset`。 路径参数: @@ -1204,7 +1240,268 @@ Authorization: Bearer 请求体:不需要请求体。 -响应 `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 diff --git a/migrations/008_add_role_user_workbench_tables.sql b/migrations/008_add_role_user_workbench_tables.sql new file mode 100644 index 0000000..a73c5ef --- /dev/null +++ b/migrations/008_add_role_user_workbench_tables.sql @@ -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; diff --git a/src/app.ts b/src/app.ts index 2ceebc2..cb9d109 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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" }, ); diff --git a/src/modules/announcements/announcement.controller.ts b/src/modules/announcements/announcement.controller.ts new file mode 100644 index 0000000..ba5fa25 --- /dev/null +++ b/src/modules/announcements/announcement.controller.ts @@ -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 { + 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 { + 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)); + }); +} diff --git a/src/modules/announcements/announcement.repository.ts b/src/modules/announcements/announcement.repository.ts new file mode 100644 index 0000000..2a4b26d --- /dev/null +++ b/src/modules/announcements/announcement.repository.ts @@ -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(); + + if (ids.length === 0) { + return result; + } + + const [rows] = await db.execute( + ` + 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(handler: (connection: PoolConnection) => Promise): Promise { + 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( + `SELECT COUNT(*) AS total FROM announcements WHERE ${whereSql}`, + params, + ); + const [rows] = await pool.execute( + ` + 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 { + const [rows] = await db.execute( + ` + 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 { + const actor = actorColumns(user); + const [result] = await db.execute( + ` + 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 { + 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 { + 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 { + 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( + ` + SELECT COUNT(*) AS total + FROM announcements a + WHERE ${where.join(" AND ")} AND ${visibilitySql} + `, + [...whereParams, ...visibilityParams], + ); + const [rows] = await pool.execute( + ` + 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( + ` + 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 { + 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 { + const rolePlaceholders = roleCodes.length > 0 ? roleCodes.map(() => "?").join(", ") : "NULL"; + const [rows] = await pool.execute( + ` + 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; + }, +}; diff --git a/src/modules/announcements/announcement.schema.ts b/src/modules/announcements/announcement.schema.ts new file mode 100644 index 0000000..ef09123 --- /dev/null +++ b/src/modules/announcements/announcement.schema.ts @@ -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: "至少需要提交一个要修改的字段", + }); diff --git a/src/modules/announcements/announcement.service.ts b/src/modules/announcements/announcement.service.ts new file mode 100644 index 0000000..0b4cf47 --- /dev/null +++ b/src/modules/announcements/announcement.service.ts @@ -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): 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), + ); + }, +}; diff --git a/src/modules/announcements/announcement.types.ts b/src/modules/announcements/announcement.types.ts new file mode 100644 index 0000000..afa5cb2 --- /dev/null +++ b/src/modules/announcements/announcement.types.ts @@ -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; + 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; diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 2527afb..838f7b4 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -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 { @@ -50,4 +50,14 @@ export async function authRoutes(app: FastifyInstance): Promise { 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); + }); } diff --git a/src/modules/auth/auth.repository.ts b/src/modules/auth/auth.repository.ts index 1c3be00..90ecb42 100644 --- a/src/modules/auth/auth.repository.ts +++ b/src/modules/auth/auth.repository.ts @@ -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 { + await pool.execute( + ` + UPDATE employees + SET password_hash = ? + WHERE id = ? AND deleted_at IS NULL + `, + [passwordHash, id], + ); + }, + async findRolesByEmployeeIds( employeeIds: number[], ): Promise> { @@ -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, }; } diff --git a/src/modules/auth/auth.schema.ts b/src/modules/auth/auth.schema.ts index 793887a..ead0590 100644 --- a/src/modules/auth/auth.schema.ts +++ b/src/modules/auth/auth.schema.ts @@ -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), +}); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 742607b..8182905 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -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 { @@ -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 { + 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 { + 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); + }, }; diff --git a/src/modules/auth/auth.types.ts b/src/modules/auth/auth.types.ts index a280785..69bf25d 100644 --- a/src/modules/auth/auth.types.ts +++ b/src/modules/auth/auth.types.ts @@ -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 { diff --git a/src/modules/credentials/credential.controller.ts b/src/modules/credentials/credential.controller.ts new file mode 100644 index 0000000..b1113e8 --- /dev/null +++ b/src/modules/credentials/credential.controller.ts @@ -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 { + 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); + }); +} diff --git a/src/modules/credentials/credential.repository.ts b/src/modules/credentials/credential.repository.ts new file mode 100644 index 0000000..aecc6b6 --- /dev/null +++ b/src/modules/credentials/credential.repository.ts @@ -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 { + 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 { + 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 { + 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( + ` + 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( + ` + 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, + }; + }, +}; diff --git a/src/modules/credentials/credential.schema.ts b/src/modules/credentials/credential.schema.ts new file mode 100644 index 0000000..9382a52 --- /dev/null +++ b/src/modules/credentials/credential.schema.ts @@ -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), +}); diff --git a/src/modules/credentials/credential.service.ts b/src/modules/credentials/credential.service.ts new file mode 100644 index 0000000..2180d1d --- /dev/null +++ b/src/modules/credentials/credential.service.ts @@ -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); + }, +}; diff --git a/src/modules/credentials/credential.types.ts b/src/modules/credentials/credential.types.ts new file mode 100644 index 0000000..af8f4dd --- /dev/null +++ b/src/modules/credentials/credential.types.ts @@ -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; +} diff --git a/src/modules/employees/employee.controller.ts b/src/modules/employees/employee.controller.ts index 59ff176..7c52aa8 100644 --- a/src/modules/employees/employee.controller.ts +++ b/src/modules/employees/employee.controller.ts @@ -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 { 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); }); diff --git a/src/modules/mobile/mobile.controller.ts b/src/modules/mobile/mobile.controller.ts new file mode 100644 index 0000000..fa1113a --- /dev/null +++ b/src/modules/mobile/mobile.controller.ts @@ -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 { + 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, + }); + }); +} diff --git a/src/modules/permissions/permission.policy.ts b/src/modules/permissions/permission.policy.ts index c6b41ed..6b5ca29 100644 --- a/src/modules/permissions/permission.policy.ts +++ b/src/modules/permissions/permission.policy.ts @@ -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 = { 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 []; } diff --git a/src/modules/shifts/shift.controller.ts b/src/modules/shifts/shift.controller.ts new file mode 100644 index 0000000..2988608 --- /dev/null +++ b/src/modules/shifts/shift.controller.ts @@ -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 { + 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 { + 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); + }); +} diff --git a/src/modules/shifts/shift.repository.ts b/src/modules/shifts/shift.repository.ts new file mode 100644 index 0000000..e73d3c3 --- /dev/null +++ b/src/modules/shifts/shift.repository.ts @@ -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 { + const [rows] = await pool.execute( + ` + 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 { + 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( + ` + 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( + ` + 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( + ` + 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 { + const [rows] = await pool.execute( + ` + 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 { + const [creatorAdminId, creatorEmployeeId] = actorValues(user); + const [result] = await pool.execute( + ` + 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 { + 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 { + 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 { + const [rows] = await pool.execute( + ` + 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); + }, +}; diff --git a/src/modules/shifts/shift.schema.ts b/src/modules/shifts/shift.schema.ts new file mode 100644 index 0000000..ff57041 --- /dev/null +++ b/src/modules/shifts/shift.schema.ts @@ -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: "结束时间必须晚于开始时间" }, + ); diff --git a/src/modules/shifts/shift.service.ts b/src/modules/shifts/shift.service.ts new file mode 100644 index 0000000..4205ba7 --- /dev/null +++ b/src/modules/shifts/shift.service.ts @@ -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 { + 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 { + 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); + }, +}; diff --git a/src/modules/shifts/shift.types.ts b/src/modules/shifts/shift.types.ts new file mode 100644 index 0000000..f46d42f --- /dev/null +++ b/src/modules/shifts/shift.types.ts @@ -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; diff --git a/src/modules/tasks/task.controller.ts b/src/modules/tasks/task.controller.ts new file mode 100644 index 0000000..8fec03a --- /dev/null +++ b/src/modules/tasks/task.controller.ts @@ -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 { + 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 { + 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)); + }); +} diff --git a/src/modules/tasks/task.repository.ts b/src/modules/tasks/task.repository.ts new file mode 100644 index 0000000..a4208ca --- /dev/null +++ b/src/modules/tasks/task.repository.ts @@ -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(); + + if (ids.length === 0) { + return result; + } + + const [rows] = await db.execute( + ` + 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(handler: (connection: PoolConnection) => Promise): Promise { + 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 { + 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( + ` + 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( + ` + 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 { + const [rows] = await db.execute( + ` + 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 { + const [creatorAdminId, creatorEmployeeId] = actorValues(user); + const [result] = await db.execute( + ` + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const [rows] = await pool.execute( + ` + 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( + ` + 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( + ` + 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 { + const [rows] = await pool.execute( + "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 { + const [rows] = await pool.execute( + ` + 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 { + const [rows] = await pool.execute( + ` + 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 { + const [rows] = await pool.execute( + ` + 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; + }, +}; diff --git a/src/modules/tasks/task.schema.ts b/src/modules/tasks/task.schema.ts new file mode 100644 index 0000000..f4c00c4 --- /dev/null +++ b/src/modules/tasks/task.schema.ts @@ -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), +}); diff --git a/src/modules/tasks/task.service.ts b/src/modules/tasks/task.service.ts new file mode 100644 index 0000000..0d907db --- /dev/null +++ b/src/modules/tasks/task.service.ts @@ -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 { + 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); + }, +}; diff --git a/src/modules/tasks/task.types.ts b/src/modules/tasks/task.types.ts new file mode 100644 index 0000000..cde0fe8 --- /dev/null +++ b/src/modules/tasks/task.types.ts @@ -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;