docs: 完善项目说明和注释
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
name: readme-structure-sync
|
||||||
|
description: Use this skill whenever files or directories are added, removed, renamed, or reorganized in the access-manage project. It ensures README.md stays synchronized with the actual project structure and package scripts.
|
||||||
|
---
|
||||||
|
|
||||||
|
# README Structure Sync
|
||||||
|
|
||||||
|
When the project file structure changes, update `README.md` in the same change.
|
||||||
|
|
||||||
|
## Required checks
|
||||||
|
|
||||||
|
1. Run `rg --files -g '!node_modules' -g '!dist'` to inspect the current repository structure.
|
||||||
|
2. If a directory or important file was added, removed, renamed, or moved, update the README directory section.
|
||||||
|
3. If `package.json` scripts changed, update the README script section.
|
||||||
|
4. Keep explanations practical: describe what each directory or important file is used for.
|
||||||
|
5. Run `pnpm typecheck` after documentation-related changes when TypeScript source was also touched.
|
||||||
|
|
||||||
|
## README sections to keep current
|
||||||
|
|
||||||
|
- `目录结构`
|
||||||
|
- `package.json 脚本说明`
|
||||||
|
- `启动步骤`
|
||||||
|
- `数据库迁移说明`
|
||||||
|
|
||||||
|
## Standard
|
||||||
|
|
||||||
|
The README should help a new developer understand:
|
||||||
|
|
||||||
|
- where API routes live,
|
||||||
|
- where business rules live,
|
||||||
|
- where SQL lives,
|
||||||
|
- how to start MySQL,
|
||||||
|
- how to run migrations,
|
||||||
|
- how to start and verify the backend.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
NODE_ENV=development
|
||||||
|
PORT=3500
|
||||||
|
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3307
|
||||||
|
DB_USER=access_user
|
||||||
|
DB_PASSWORD=access_pass
|
||||||
|
DB_NAME=access_manage
|
||||||
|
DB_CONNECTION_LIMIT=10
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
# access-manage
|
||||||
|
|
||||||
|
`access-manage` 是一个用于学习 MySQL CRUD 的门店员工权限管理后端项目。
|
||||||
|
|
||||||
|
项目刻意使用 `mysql2` 直接写 SQL,不引入 ORM。这样可以更直接地学习表设计、索引、外键、事务、软删除、参数化查询和分层代码组织。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- Node.js + TypeScript
|
||||||
|
- Fastify
|
||||||
|
- MySQL 8.4
|
||||||
|
- mysql2
|
||||||
|
- zod
|
||||||
|
- Docker Compose
|
||||||
|
|
||||||
|
## 项目能力
|
||||||
|
|
||||||
|
- 门店管理:查询、新增、修改、软删除门店。
|
||||||
|
- 角色管理:查询、新增、修改、删除角色。
|
||||||
|
- 员工管理:分页查询、新增、修改、启用/停用、软删除员工。
|
||||||
|
- 员工角色:一个员工可以绑定多个角色。
|
||||||
|
- 数据校验:使用 zod 校验路径参数、查询参数和请求体。
|
||||||
|
- 数据库迁移:使用 `migrations/*.sql` 管理建表和初始化数据。
|
||||||
|
- 事务处理:创建/更新员工和角色绑定时使用事务,避免部分成功。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├── .agents/
|
||||||
|
│ └── skills/
|
||||||
|
│ └── readme-structure-sync/ # README 和目录结构同步维护规则
|
||||||
|
├── .env.example # 本地环境变量示例,复制为 .env.development 后使用
|
||||||
|
├── AGENTS.md # Codex/Agent 入口指令,当前指向 RTK.md
|
||||||
|
├── RTK.md # 项目协作规则和开发约定
|
||||||
|
├── migrations/ # 数据库迁移 SQL
|
||||||
|
│ ├── 001_initial_schema.sql # 创建基础表结构
|
||||||
|
│ └── 002_seed_demo_data.sql # 初始化演示门店和角色
|
||||||
|
├── src/
|
||||||
|
│ ├── app.ts # 创建 Fastify 应用、注册路由、统一错误处理
|
||||||
|
│ ├── server.ts # 启动 HTTP 服务和优雅停机
|
||||||
|
│ ├── config/
|
||||||
|
│ │ └── env.ts # 环境变量校验
|
||||||
|
│ ├── db/
|
||||||
|
│ │ ├── migrate.ts # 执行 migrations 目录下的 SQL
|
||||||
|
│ │ └── pool.ts # MySQL 连接池
|
||||||
|
│ ├── modules/
|
||||||
|
│ │ ├── catalog/ # 门店和角色模块
|
||||||
|
│ │ └── employees/ # 员工 CRUD 模块
|
||||||
|
│ └── shared/ # 通用响应结构和业务错误
|
||||||
|
├── docker-compose.yml # 本地 MySQL
|
||||||
|
├── package.json
|
||||||
|
├── pnpm-lock.yaml
|
||||||
|
├── README.md
|
||||||
|
└── tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 目录和关键文件说明
|
||||||
|
|
||||||
|
| 路径 | 作用 |
|
||||||
|
| --- | --- |
|
||||||
|
| `.agents/skills/readme-structure-sync/` | 项目内 skill。约定当目录、重要文件或 `package.json` 脚本变化时,同步更新 README。 |
|
||||||
|
| `.env.example` | 可提交到仓库的环境变量示例。新开发者复制成 `.env.development` 后再启动项目。 |
|
||||||
|
| `AGENTS.md` | Agent 工具读取的入口文件,当前通过 `@RTK.md` 引入项目规则。 |
|
||||||
|
| `RTK.md` | 本项目的协作规则,例如使用中文说明、保持分层、改目录时同步 README。 |
|
||||||
|
| `migrations/` | 数据库迁移目录。所有建表、改表、初始化基础数据的 SQL 都放在这里。 |
|
||||||
|
| `src/app.ts` | 创建 Fastify 应用,注册路由,处理健康检查和全局错误。 |
|
||||||
|
| `src/server.ts` | 真正启动 HTTP 服务,监听端口,并处理优雅停机。 |
|
||||||
|
| `src/config/env.ts` | 使用 zod 校验环境变量,避免配置错误拖到请求阶段才暴露。 |
|
||||||
|
| `src/db/migrate.ts` | 执行 `migrations/*.sql`,并用 `schema_migrations` 记录已执行迁移。 |
|
||||||
|
| `src/db/pool.ts` | 创建 MySQL 连接池,提供数据库健康检查和关闭连接的方法。 |
|
||||||
|
| `src/modules/catalog/` | 门店和角色模块,负责基础资料接口。 |
|
||||||
|
| `src/modules/employees/` | 员工模块,负责员工分页、详情、新增、修改、状态变更和软删除。 |
|
||||||
|
| `src/shared/` | 跨模块复用的响应结构和业务错误类型。 |
|
||||||
|
| `docker-compose.yml` | 本地开发用 MySQL 容器配置。 |
|
||||||
|
| `package.json` | 项目信息、依赖和常用脚本。 |
|
||||||
|
| `pnpm-lock.yaml` | pnpm 锁文件,保证依赖版本一致。 |
|
||||||
|
| `tsconfig.json` | TypeScript 编译配置。 |
|
||||||
|
|
||||||
|
## 分层约定
|
||||||
|
|
||||||
|
项目按 `controller -> service -> repository` 分层:
|
||||||
|
|
||||||
|
- `controller`:处理 HTTP 请求,校验入参,调用 service,返回响应。
|
||||||
|
- `service`:处理业务规则,例如门店是否存在、手机号是否重复、角色能否删除。
|
||||||
|
- `repository`:只负责 SQL 查询和数据库字段转换。
|
||||||
|
- `schema`:使用 zod 定义接口入参规则。
|
||||||
|
- `types`:定义接口输入输出类型。
|
||||||
|
|
||||||
|
这个分层是学习重点之一。新增接口时,优先保持同样结构。
|
||||||
|
|
||||||
|
## 环境准备
|
||||||
|
|
||||||
|
需要先安装:
|
||||||
|
|
||||||
|
- Node.js
|
||||||
|
- pnpm
|
||||||
|
- Docker Desktop
|
||||||
|
|
||||||
|
安装依赖:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
复制环境变量示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.development
|
||||||
|
```
|
||||||
|
|
||||||
|
本地配置示例见 [.env.example](./.env.example):
|
||||||
|
|
||||||
|
```env
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3500
|
||||||
|
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3307
|
||||||
|
DB_USER=access_user
|
||||||
|
DB_PASSWORD=access_pass
|
||||||
|
DB_NAME=access_manage
|
||||||
|
DB_CONNECTION_LIMIT=10
|
||||||
|
```
|
||||||
|
|
||||||
|
## 启动步骤
|
||||||
|
|
||||||
|
1. 启动 MySQL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm mysql:up
|
||||||
|
```
|
||||||
|
|
||||||
|
本机连接端口是 `3307`,容器内 MySQL 端口仍然是 `3306`。这样可以减少和本机已有 MySQL 的冲突。
|
||||||
|
|
||||||
|
2. 执行数据库迁移:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
迁移会创建这些表:
|
||||||
|
|
||||||
|
- `stores`:门店表
|
||||||
|
- `roles`:角色表
|
||||||
|
- `employees`:员工表
|
||||||
|
- `employee_roles`:员工角色关系表
|
||||||
|
- `schema_migrations`:迁移记录表
|
||||||
|
|
||||||
|
3. 启动后端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
服务默认运行在:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:3500
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 健康检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3500/health
|
||||||
|
```
|
||||||
|
|
||||||
|
正常响应示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"status": "ok",
|
||||||
|
"database": "up",
|
||||||
|
"now": "2026-05-26T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## package.json 脚本说明
|
||||||
|
|
||||||
|
这些脚本定义在 [package.json](./package.json) 的 `scripts` 字段里。
|
||||||
|
|
||||||
|
| 命令 | 作用 | 什么时候用 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `pnpm dev` | 使用 `.env.development` 启动开发服务,并通过 `tsx watch` 监听代码变化。 | 日常开发接口时使用。 |
|
||||||
|
| `pnpm build` | 使用 `tsc` 编译 TypeScript,输出到 `dist/`。 | 准备运行编译产物或发布前验证时使用。 |
|
||||||
|
| `pnpm start` | 使用 `.env.development` 运行 `dist/server.js`。 | 已经执行过 `pnpm build` 后,用编译产物启动服务。 |
|
||||||
|
| `pnpm typecheck` | 执行 `tsc --noEmit`,只检查类型,不生成文件。 | 改 TypeScript 代码后快速确认类型是否正确。 |
|
||||||
|
| `pnpm db:migrate` | 运行 `src/db/migrate.ts`,按顺序执行 `migrations/*.sql`。 | 第一次启动项目、拉到新迁移、改数据库结构后使用。 |
|
||||||
|
| `pnpm db:shell` | 进入 Docker 容器里的 MySQL 命令行。 | 需要手动查看表结构或查询数据时使用。 |
|
||||||
|
| `pnpm mysql:up` | 启动本地 MySQL 容器。 | 开发前先启动数据库。 |
|
||||||
|
| `pnpm mysql:down` | 停止并移除本地 MySQL 容器。 | 不再需要本地数据库容器时使用。 |
|
||||||
|
|
||||||
|
重要顺序通常是:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm mysql:up
|
||||||
|
pnpm db:migrate
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 接口响应格式
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
分页响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"items": [],
|
||||||
|
"pagination": {
|
||||||
|
"page": 1,
|
||||||
|
"pageSize": 20,
|
||||||
|
"total": 0,
|
||||||
|
"totalPages": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
错误响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": {
|
||||||
|
"code": "VALIDATION_ERROR",
|
||||||
|
"message": "请求参数不合法",
|
||||||
|
"details": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 门店接口示例
|
||||||
|
|
||||||
|
查询门店选项:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3500/api/stores
|
||||||
|
```
|
||||||
|
|
||||||
|
查询包含停用门店的列表:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl 'http://localhost:3500/api/stores?includeInactive=true'
|
||||||
|
```
|
||||||
|
|
||||||
|
新增门店:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3500/api/stores \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"name": "人民广场店",
|
||||||
|
"address": "上海市黄浦区人民广场",
|
||||||
|
"phone": "021-12345678",
|
||||||
|
"status": "ACTIVE"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
修改门店:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PATCH http://localhost:3500/api/stores/1 \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"phone": "021-87654321"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
删除门店:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X DELETE http://localhost:3500/api/stores/1
|
||||||
|
```
|
||||||
|
|
||||||
|
门店下还有员工时,不能停用或删除门店。
|
||||||
|
|
||||||
|
## 角色接口示例
|
||||||
|
|
||||||
|
查询角色:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3500/api/roles
|
||||||
|
```
|
||||||
|
|
||||||
|
新增角色:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3500/api/roles \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"code": "shift_leader",
|
||||||
|
"name": "班组长",
|
||||||
|
"description": "负责当班现场协调"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
修改角色:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PATCH http://localhost:3500/api/roles/1 \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"description": "负责门店日常管理和权限审批"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
删除角色:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X DELETE http://localhost:3500/api/roles/1
|
||||||
|
```
|
||||||
|
|
||||||
|
角色已绑定员工时,不能删除。
|
||||||
|
|
||||||
|
## 员工接口示例
|
||||||
|
|
||||||
|
新增员工:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3500/api/employees \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"storeId": 1,
|
||||||
|
"name": "张三",
|
||||||
|
"phone": "13812345678",
|
||||||
|
"roleIds": [1, 2],
|
||||||
|
"remark": "早班员工"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
查询员工列表:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl 'http://localhost:3500/api/employees?page=1&pageSize=20'
|
||||||
|
```
|
||||||
|
|
||||||
|
按门店和状态筛选:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl 'http://localhost:3500/api/employees?storeId=1&status=ACTIVE&page=1&pageSize=20'
|
||||||
|
```
|
||||||
|
|
||||||
|
按姓名或手机号搜索:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl 'http://localhost:3500/api/employees?keyword=张三'
|
||||||
|
```
|
||||||
|
|
||||||
|
查询员工详情:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3500/api/employees/1
|
||||||
|
```
|
||||||
|
|
||||||
|
修改员工:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PATCH http://localhost:3500/api/employees/1 \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"name": "张三丰",
|
||||||
|
"roleIds": [1]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
停用员工:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PATCH http://localhost:3500/api/employees/1/status \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"status": "INACTIVE"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
软删除员工:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X DELETE http://localhost:3500/api/employees/1
|
||||||
|
```
|
||||||
|
|
||||||
|
同一个门店下,未删除员工的手机号不能重复。员工软删除后,可以重新录入相同手机号。
|
||||||
|
|
||||||
|
## 数据库迁移说明
|
||||||
|
|
||||||
|
迁移文件放在 [migrations](./migrations) 目录下。
|
||||||
|
|
||||||
|
当前迁移:
|
||||||
|
|
||||||
|
- [001_initial_schema.sql](./migrations/001_initial_schema.sql):创建门店、角色、员工、员工角色关系表。
|
||||||
|
- [002_seed_demo_data.sql](./migrations/002_seed_demo_data.sql):写入一个示例门店和几个常见角色。
|
||||||
|
|
||||||
|
执行 `pnpm db:migrate` 时,脚本会:
|
||||||
|
|
||||||
|
1. 创建 `schema_migrations` 迁移记录表。
|
||||||
|
2. 读取 `migrations` 目录里的 `.sql` 文件。
|
||||||
|
3. 按文件名排序执行未执行过的迁移。
|
||||||
|
4. 把已执行文件名写入 `schema_migrations`。
|
||||||
|
|
||||||
|
后续改表时,建议新增迁移文件,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
migrations/003_add_employee_email.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
不要随意修改已经在数据库执行过的旧迁移,否则不同环境的数据库结构可能不一致。
|
||||||
|
|
||||||
|
## 学习重点
|
||||||
|
|
||||||
|
- `stores.deleted_at` 和 `employees.deleted_at` 用于软删除。
|
||||||
|
- `employees.active_phone` 是生成列,用来实现“同一门店未删除员工手机号唯一”。
|
||||||
|
- `employee_roles` 是多对多关系表。
|
||||||
|
- `repository` 使用参数化查询,避免 SQL 注入。
|
||||||
|
- `service` 使用事务保证员工信息和角色绑定同时成功或同时失败。
|
||||||
|
- `app.ts` 统一处理 zod 校验错误、业务错误和数据库唯一索引冲突。
|
||||||
|
|
||||||
|
## README 维护规则
|
||||||
|
|
||||||
|
本项目有一个项目内 skill:[readme-structure-sync](./.agents/skills/readme-structure-sync/SKILL.md)。
|
||||||
|
|
||||||
|
只要发生以下任一变化,就必须在同一次修改里更新 README:
|
||||||
|
|
||||||
|
- 新增、删除、重命名或移动目录。
|
||||||
|
- 新增、删除、重命名或移动重要源码文件。
|
||||||
|
- 修改 `package.json` 的 `scripts`。
|
||||||
|
- 修改启动流程、数据库迁移流程或本地环境变量。
|
||||||
|
|
||||||
|
README 至少要同步这些部分:
|
||||||
|
|
||||||
|
- `目录结构`
|
||||||
|
- `目录和关键文件说明`
|
||||||
|
- `package.json 脚本说明`
|
||||||
|
- `启动步骤`
|
||||||
|
- `数据库迁移说明`
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 连接不上数据库
|
||||||
|
|
||||||
|
先确认 MySQL 容器是否启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
再确认 `.env.development` 里的 `DB_PORT` 是 `3307`。
|
||||||
|
|
||||||
|
### 迁移执行过了,为什么再次运行会跳过
|
||||||
|
|
||||||
|
`src/db/migrate.ts` 会把执行过的文件名写入 `schema_migrations`。再次运行时,如果文件名已存在,就会跳过,避免重复建表或重复插入数据。
|
||||||
|
|
||||||
|
### 删除员工后,为什么数据库里还有记录
|
||||||
|
|
||||||
|
这是软删除。删除接口会把 `deleted_at` 设置为当前时间,并把状态改成 `INACTIVE`。这样可以保留历史数据,同时普通查询会过滤掉已删除记录。
|
||||||
|
|
||||||
|
### 为什么不使用 ORM
|
||||||
|
|
||||||
|
这个项目的目标是学习 SQL 和 MySQL 基础能力。直接使用 `mysql2` 可以更清楚地看到 SQL、索引、事务和参数绑定是如何工作的。
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# access-manage Project Notes
|
||||||
|
|
||||||
|
This is a learning project for MySQL CRUD with Node.js, TypeScript, Fastify, and mysql2.
|
||||||
|
|
||||||
|
When working in this repository:
|
||||||
|
|
||||||
|
- Prefer explaining changes in Chinese because the project is being used for learning.
|
||||||
|
- Keep examples close to the current code style: controller handles HTTP, service handles business rules, repository handles SQL.
|
||||||
|
- Do not replace the direct `mysql2` SQL approach with an ORM; the goal is to learn tables, SQL, indexes, transactions, and parameterized queries.
|
||||||
|
- Use `.env.development` for local development configuration.
|
||||||
|
- Use Docker Compose only for local MySQL unless the user asks to containerize the Node.js app too.
|
||||||
|
- Before changing database behavior, check `migrations/`, `src/db/`, and the related repository files.
|
||||||
|
- When files or directories are added, removed, renamed, or reorganized, update `README.md` in the same change.
|
||||||
|
- If `package.json` scripts change, update the README script explanation in the same change.
|
||||||
|
- Use `.agents/skills/readme-structure-sync/SKILL.md` as the project skill for README and structure synchronization.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.4
|
||||||
|
container_name: access-manage-mysql
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: root123456
|
||||||
|
MYSQL_DATABASE: access_manage
|
||||||
|
MYSQL_USER: access_user
|
||||||
|
MYSQL_PASSWORD: access_pass
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
ports:
|
||||||
|
# 本机使用 3307,避免和电脑上已有的 MySQL 3306 冲突。
|
||||||
|
- "3307:3306"
|
||||||
|
volumes:
|
||||||
|
# 把 MySQL 数据持久化到本机目录,方便学习时直接看到数据卷位置。
|
||||||
|
- /Users/mac033/Desktop/docker-volumes/access-manage-mysql:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -proot123456 --silent"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
-- 001_initial_schema.sql
|
||||||
|
-- 这个迁移文件负责创建项目的基础表结构。
|
||||||
|
-- 迁移脚本会按文件名排序执行,所以 001 会先于 002 执行。
|
||||||
|
|
||||||
|
-- 门店表:保存每个门店的基础信息。
|
||||||
|
-- deleted_at 用于软删除,业务查询通常只查询 deleted_at IS NULL 的数据。
|
||||||
|
CREATE TABLE IF NOT EXISTS stores (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
name VARCHAR(100) NOT NULL COMMENT '门店名称',
|
||||||
|
address VARCHAR(255) NULL COMMENT '门店地址',
|
||||||
|
phone VARCHAR(30) NULL COMMENT '门店联系电话',
|
||||||
|
status ENUM('ACTIVE', 'INACTIVE') NOT NULL DEFAULT 'ACTIVE' 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 '软删除时间,NULL 表示未删除',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_stores_status (status),
|
||||||
|
KEY idx_stores_deleted_at (deleted_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='门店表';
|
||||||
|
|
||||||
|
-- 角色表:保存系统内可分配给员工的角色。
|
||||||
|
-- code 是稳定的角色编码,适合在代码里做权限判断;name 是展示给用户看的名称。
|
||||||
|
CREATE TABLE IF NOT EXISTS roles (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
code VARCHAR(50) NOT NULL COMMENT '角色编码,代码里使用这个值做权限判断',
|
||||||
|
name VARCHAR(50) NOT NULL COMMENT '角色名称',
|
||||||
|
description VARCHAR(255) 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),
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_roles_code (code)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='角色表';
|
||||||
|
|
||||||
|
-- 员工表:保存员工基础资料,并通过 store_id 关联所属门店。
|
||||||
|
-- 这里不直接存角色列表,因为一个员工可以有多个角色,角色关系放在 employee_roles 表。
|
||||||
|
CREATE TABLE IF NOT EXISTS employees (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
store_id INT UNSIGNED NOT NULL COMMENT '所属门店',
|
||||||
|
name VARCHAR(50) NOT NULL COMMENT '员工姓名',
|
||||||
|
phone VARCHAR(30) NOT NULL COMMENT '员工手机号',
|
||||||
|
status ENUM('ACTIVE', 'INACTIVE') NOT NULL DEFAULT 'ACTIVE' COMMENT '员工状态',
|
||||||
|
remark VARCHAR(500) 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 '软删除时间,NULL 表示未删除',
|
||||||
|
-- MySQL 的唯一索引允许多个 NULL。
|
||||||
|
-- 软删除后 active_phone 会变成 NULL,这样同一门店可以重新录入相同手机号的新员工。
|
||||||
|
active_phone VARCHAR(30) GENERATED ALWAYS AS (
|
||||||
|
CASE WHEN deleted_at IS NULL THEN phone ELSE NULL END
|
||||||
|
) STORED COMMENT '仅用于保证同一门店未删除员工手机号唯一',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_employees_store_active_phone (store_id, active_phone),
|
||||||
|
KEY idx_employees_store_status (store_id, status),
|
||||||
|
KEY idx_employees_deleted_at (deleted_at),
|
||||||
|
CONSTRAINT fk_employees_store_id FOREIGN KEY (store_id) REFERENCES stores (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='员工表';
|
||||||
|
|
||||||
|
-- 员工角色关系表:连接 employees 和 roles。
|
||||||
|
-- 一个员工可以有多个角色,一个角色也可以分配给多个员工,所以这是多对多关系表。
|
||||||
|
CREATE TABLE IF NOT EXISTS employee_roles (
|
||||||
|
employee_id INT UNSIGNED NOT NULL,
|
||||||
|
role_id INT UNSIGNED NOT NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (employee_id, role_id),
|
||||||
|
KEY idx_employee_roles_role_id (role_id),
|
||||||
|
CONSTRAINT fk_employee_roles_employee_id FOREIGN KEY (employee_id) REFERENCES employees (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_employee_roles_role_id FOREIGN KEY (role_id) REFERENCES roles (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='员工角色关系表';
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- 002_seed_demo_data.sql
|
||||||
|
-- 这个迁移文件负责写入项目启动时需要的演示数据。
|
||||||
|
-- 它依赖 001_initial_schema.sql 先创建好 stores 和 roles 表。
|
||||||
|
|
||||||
|
-- 初始化一个示例门店,方便本地直接创建员工并测试 CRUD。
|
||||||
|
-- 指定 id = 1 是为了 README 里的示例请求可以稳定使用 storeId: 1。
|
||||||
|
INSERT INTO stores (id, name, address, phone, status)
|
||||||
|
VALUES (1, '示例门店', '请改成你的真实门店地址', '13800000000', 'ACTIVE')
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
address = VALUES(address),
|
||||||
|
phone = VALUES(phone),
|
||||||
|
status = VALUES(status);
|
||||||
|
|
||||||
|
-- 初始化常见角色。
|
||||||
|
-- code 用于代码和接口里的稳定标识,name/description 用于页面或接口展示。
|
||||||
|
INSERT INTO roles (code, name, description)
|
||||||
|
VALUES
|
||||||
|
('store_manager', '店长', '负责门店日常管理、排班和权限审批'),
|
||||||
|
('cashier', '收银员', '负责收银、订单核对和基础会员操作'),
|
||||||
|
('kitchen', '后厨', '负责出品、备货和库存相关操作'),
|
||||||
|
('part_time', '兼职', '临时员工,默认只开放基础操作'),
|
||||||
|
('admin', '管理员', '系统管理角色,仅授予可信人员')
|
||||||
|
-- 如果重复执行迁移或本地重新导入数据,已存在的角色会更新名称和说明,避免重复插入报错。
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
description = VALUES(description);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "access-manage",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "门店员工权限管理 CRUD 学习项目",
|
||||||
|
"main": "dist/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "DOTENV_CONFIG_PATH=.env.development tsx watch src/server.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "DOTENV_CONFIG_PATH=.env.development node dist/server.js",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"db:migrate": "DOTENV_CONFIG_PATH=.env.development tsx src/db/migrate.ts",
|
||||||
|
"db:shell": "docker compose exec mysql mysql -uaccess_user -paccess_pass access_manage",
|
||||||
|
"mysql:up": "docker compose up -d mysql",
|
||||||
|
"mysql:down": "docker compose down"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"mysql",
|
||||||
|
"crud",
|
||||||
|
"fastify",
|
||||||
|
"typescript"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"fastify": "^5.8.5",
|
||||||
|
"mysql2": "^3.22.3",
|
||||||
|
"zod": "^4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.9.1",
|
||||||
|
"tsx": "^4.22.3",
|
||||||
|
"typescript": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+774
@@ -0,0 +1,774 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.:
|
||||||
|
dependencies:
|
||||||
|
dotenv:
|
||||||
|
specifier: ^17.4.2
|
||||||
|
version: 17.4.2
|
||||||
|
fastify:
|
||||||
|
specifier: ^5.8.5
|
||||||
|
version: 5.8.5
|
||||||
|
mysql2:
|
||||||
|
specifier: ^3.22.3
|
||||||
|
version: 3.22.3(@types/node@25.9.1)
|
||||||
|
zod:
|
||||||
|
specifier: ^4.4.3
|
||||||
|
version: 4.4.3
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^25.9.1
|
||||||
|
version: 25.9.1
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.22.3
|
||||||
|
version: 4.22.3
|
||||||
|
typescript:
|
||||||
|
specifier: ^6.0.3
|
||||||
|
version: 6.0.3
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
'@esbuild/aix-ppc64@0.28.0':
|
||||||
|
resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [aix]
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.28.0':
|
||||||
|
resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.28.0':
|
||||||
|
resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.28.0':
|
||||||
|
resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.28.0':
|
||||||
|
resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.28.0':
|
||||||
|
resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.28.0':
|
||||||
|
resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.28.0':
|
||||||
|
resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.28.0':
|
||||||
|
resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.28.0':
|
||||||
|
resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.28.0':
|
||||||
|
resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.28.0':
|
||||||
|
resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.28.0':
|
||||||
|
resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [mips64el]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.28.0':
|
||||||
|
resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.28.0':
|
||||||
|
resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.28.0':
|
||||||
|
resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.28.0':
|
||||||
|
resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/netbsd-arm64@0.28.0':
|
||||||
|
resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [netbsd]
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.28.0':
|
||||||
|
resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [netbsd]
|
||||||
|
|
||||||
|
'@esbuild/openbsd-arm64@0.28.0':
|
||||||
|
resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.28.0':
|
||||||
|
resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@esbuild/openharmony-arm64@0.28.0':
|
||||||
|
resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openharmony]
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.28.0':
|
||||||
|
resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [sunos]
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.28.0':
|
||||||
|
resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.28.0':
|
||||||
|
resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.28.0':
|
||||||
|
resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@fastify/ajv-compiler@4.0.5':
|
||||||
|
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
|
||||||
|
|
||||||
|
'@fastify/error@4.2.0':
|
||||||
|
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
|
||||||
|
|
||||||
|
'@fastify/fast-json-stringify-compiler@5.0.3':
|
||||||
|
resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==}
|
||||||
|
|
||||||
|
'@fastify/forwarded@3.0.1':
|
||||||
|
resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==}
|
||||||
|
|
||||||
|
'@fastify/merge-json-schemas@0.2.1':
|
||||||
|
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
|
||||||
|
|
||||||
|
'@fastify/proxy-addr@5.1.0':
|
||||||
|
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
|
||||||
|
|
||||||
|
'@pinojs/redact@0.4.0':
|
||||||
|
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||||
|
|
||||||
|
'@types/node@25.9.1':
|
||||||
|
resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==}
|
||||||
|
|
||||||
|
abstract-logging@2.0.1:
|
||||||
|
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
||||||
|
|
||||||
|
ajv-formats@3.0.1:
|
||||||
|
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
|
||||||
|
peerDependencies:
|
||||||
|
ajv: ^8.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
ajv:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
ajv@8.20.0:
|
||||||
|
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
|
||||||
|
|
||||||
|
atomic-sleep@1.0.0:
|
||||||
|
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||||
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
|
avvio@9.2.0:
|
||||||
|
resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==}
|
||||||
|
|
||||||
|
aws-ssl-profiles@1.1.2:
|
||||||
|
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
|
||||||
|
engines: {node: '>= 6.0.0'}
|
||||||
|
|
||||||
|
cookie@1.1.1:
|
||||||
|
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
denque@2.1.0:
|
||||||
|
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
||||||
|
engines: {node: '>=0.10'}
|
||||||
|
|
||||||
|
dequal@2.0.3:
|
||||||
|
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
dotenv@17.4.2:
|
||||||
|
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
esbuild@0.28.0:
|
||||||
|
resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
fast-decode-uri-component@1.0.1:
|
||||||
|
resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
|
||||||
|
|
||||||
|
fast-deep-equal@3.1.3:
|
||||||
|
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||||
|
|
||||||
|
fast-json-stringify@6.4.0:
|
||||||
|
resolution: {integrity: sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==}
|
||||||
|
|
||||||
|
fast-querystring@1.1.2:
|
||||||
|
resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
|
||||||
|
|
||||||
|
fast-uri@3.1.2:
|
||||||
|
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
|
||||||
|
|
||||||
|
fastify@5.8.5:
|
||||||
|
resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==}
|
||||||
|
|
||||||
|
fastq@1.20.1:
|
||||||
|
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||||
|
|
||||||
|
find-my-way@9.6.0:
|
||||||
|
resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
fsevents@2.3.3:
|
||||||
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
generate-function@2.3.1:
|
||||||
|
resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==}
|
||||||
|
|
||||||
|
iconv-lite@0.7.2:
|
||||||
|
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
ipaddr.js@2.4.0:
|
||||||
|
resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
|
||||||
|
is-property@1.0.2:
|
||||||
|
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
|
||||||
|
|
||||||
|
json-schema-ref-resolver@3.0.0:
|
||||||
|
resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==}
|
||||||
|
|
||||||
|
json-schema-traverse@1.0.0:
|
||||||
|
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||||
|
|
||||||
|
light-my-request@6.6.0:
|
||||||
|
resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
|
||||||
|
|
||||||
|
long@5.3.2:
|
||||||
|
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
|
||||||
|
|
||||||
|
lru.min@1.1.4:
|
||||||
|
resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==}
|
||||||
|
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
|
||||||
|
|
||||||
|
mysql2@3.22.3:
|
||||||
|
resolution: {integrity: sha512-uWWxvZSRvRhtBdh2CdcuK83YcOfPdmEeEYB069bAmPnV93QApDGVPuvCQOLjlh7tYHEWdgQPrn6kosDxHBVLkA==}
|
||||||
|
engines: {node: '>= 8.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>= 8'
|
||||||
|
|
||||||
|
named-placeholders@1.1.6:
|
||||||
|
resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==}
|
||||||
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
|
on-exit-leak-free@2.1.2:
|
||||||
|
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
|
pino-abstract-transport@3.0.0:
|
||||||
|
resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==}
|
||||||
|
|
||||||
|
pino-std-serializers@7.1.0:
|
||||||
|
resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
|
||||||
|
|
||||||
|
pino@10.3.1:
|
||||||
|
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
process-warning@4.0.1:
|
||||||
|
resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==}
|
||||||
|
|
||||||
|
process-warning@5.0.0:
|
||||||
|
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
|
||||||
|
|
||||||
|
quick-format-unescaped@4.0.4:
|
||||||
|
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||||
|
|
||||||
|
real-require@0.2.0:
|
||||||
|
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
|
||||||
|
engines: {node: '>= 12.13.0'}
|
||||||
|
|
||||||
|
real-require@1.0.0:
|
||||||
|
resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==}
|
||||||
|
|
||||||
|
require-from-string@2.0.2:
|
||||||
|
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
ret@0.5.0:
|
||||||
|
resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
reusify@1.1.0:
|
||||||
|
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||||
|
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||||
|
|
||||||
|
rfdc@1.4.1:
|
||||||
|
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||||
|
|
||||||
|
safe-regex2@5.1.1:
|
||||||
|
resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
safe-stable-stringify@2.5.0:
|
||||||
|
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
safer-buffer@2.1.2:
|
||||||
|
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||||
|
|
||||||
|
secure-json-parse@4.1.0:
|
||||||
|
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
|
||||||
|
|
||||||
|
semver@7.8.1:
|
||||||
|
resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
set-cookie-parser@2.7.2:
|
||||||
|
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||||
|
|
||||||
|
sonic-boom@4.2.1:
|
||||||
|
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
|
||||||
|
|
||||||
|
split2@4.2.0:
|
||||||
|
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||||
|
engines: {node: '>= 10.x'}
|
||||||
|
|
||||||
|
sql-escaper@1.3.3:
|
||||||
|
resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==}
|
||||||
|
engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'}
|
||||||
|
|
||||||
|
thread-stream@4.2.0:
|
||||||
|
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
toad-cache@3.7.1:
|
||||||
|
resolution: {integrity: sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
tsx@4.22.3:
|
||||||
|
resolution: {integrity: sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
typescript@6.0.3:
|
||||||
|
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
|
||||||
|
engines: {node: '>=14.17'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
undici-types@7.24.6:
|
||||||
|
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
|
||||||
|
|
||||||
|
zod@4.4.3:
|
||||||
|
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
'@esbuild/aix-ppc64@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/netbsd-arm64@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openbsd-arm64@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openharmony-arm64@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.28.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@fastify/ajv-compiler@4.0.5':
|
||||||
|
dependencies:
|
||||||
|
ajv: 8.20.0
|
||||||
|
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||||
|
fast-uri: 3.1.2
|
||||||
|
|
||||||
|
'@fastify/error@4.2.0': {}
|
||||||
|
|
||||||
|
'@fastify/fast-json-stringify-compiler@5.0.3':
|
||||||
|
dependencies:
|
||||||
|
fast-json-stringify: 6.4.0
|
||||||
|
|
||||||
|
'@fastify/forwarded@3.0.1': {}
|
||||||
|
|
||||||
|
'@fastify/merge-json-schemas@0.2.1':
|
||||||
|
dependencies:
|
||||||
|
dequal: 2.0.3
|
||||||
|
|
||||||
|
'@fastify/proxy-addr@5.1.0':
|
||||||
|
dependencies:
|
||||||
|
'@fastify/forwarded': 3.0.1
|
||||||
|
ipaddr.js: 2.4.0
|
||||||
|
|
||||||
|
'@pinojs/redact@0.4.0': {}
|
||||||
|
|
||||||
|
'@types/node@25.9.1':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 7.24.6
|
||||||
|
|
||||||
|
abstract-logging@2.0.1: {}
|
||||||
|
|
||||||
|
ajv-formats@3.0.1(ajv@8.20.0):
|
||||||
|
optionalDependencies:
|
||||||
|
ajv: 8.20.0
|
||||||
|
|
||||||
|
ajv@8.20.0:
|
||||||
|
dependencies:
|
||||||
|
fast-deep-equal: 3.1.3
|
||||||
|
fast-uri: 3.1.2
|
||||||
|
json-schema-traverse: 1.0.0
|
||||||
|
require-from-string: 2.0.2
|
||||||
|
|
||||||
|
atomic-sleep@1.0.0: {}
|
||||||
|
|
||||||
|
avvio@9.2.0:
|
||||||
|
dependencies:
|
||||||
|
'@fastify/error': 4.2.0
|
||||||
|
fastq: 1.20.1
|
||||||
|
|
||||||
|
aws-ssl-profiles@1.1.2: {}
|
||||||
|
|
||||||
|
cookie@1.1.1: {}
|
||||||
|
|
||||||
|
denque@2.1.0: {}
|
||||||
|
|
||||||
|
dequal@2.0.3: {}
|
||||||
|
|
||||||
|
dotenv@17.4.2: {}
|
||||||
|
|
||||||
|
esbuild@0.28.0:
|
||||||
|
optionalDependencies:
|
||||||
|
'@esbuild/aix-ppc64': 0.28.0
|
||||||
|
'@esbuild/android-arm': 0.28.0
|
||||||
|
'@esbuild/android-arm64': 0.28.0
|
||||||
|
'@esbuild/android-x64': 0.28.0
|
||||||
|
'@esbuild/darwin-arm64': 0.28.0
|
||||||
|
'@esbuild/darwin-x64': 0.28.0
|
||||||
|
'@esbuild/freebsd-arm64': 0.28.0
|
||||||
|
'@esbuild/freebsd-x64': 0.28.0
|
||||||
|
'@esbuild/linux-arm': 0.28.0
|
||||||
|
'@esbuild/linux-arm64': 0.28.0
|
||||||
|
'@esbuild/linux-ia32': 0.28.0
|
||||||
|
'@esbuild/linux-loong64': 0.28.0
|
||||||
|
'@esbuild/linux-mips64el': 0.28.0
|
||||||
|
'@esbuild/linux-ppc64': 0.28.0
|
||||||
|
'@esbuild/linux-riscv64': 0.28.0
|
||||||
|
'@esbuild/linux-s390x': 0.28.0
|
||||||
|
'@esbuild/linux-x64': 0.28.0
|
||||||
|
'@esbuild/netbsd-arm64': 0.28.0
|
||||||
|
'@esbuild/netbsd-x64': 0.28.0
|
||||||
|
'@esbuild/openbsd-arm64': 0.28.0
|
||||||
|
'@esbuild/openbsd-x64': 0.28.0
|
||||||
|
'@esbuild/openharmony-arm64': 0.28.0
|
||||||
|
'@esbuild/sunos-x64': 0.28.0
|
||||||
|
'@esbuild/win32-arm64': 0.28.0
|
||||||
|
'@esbuild/win32-ia32': 0.28.0
|
||||||
|
'@esbuild/win32-x64': 0.28.0
|
||||||
|
|
||||||
|
fast-decode-uri-component@1.0.1: {}
|
||||||
|
|
||||||
|
fast-deep-equal@3.1.3: {}
|
||||||
|
|
||||||
|
fast-json-stringify@6.4.0:
|
||||||
|
dependencies:
|
||||||
|
'@fastify/merge-json-schemas': 0.2.1
|
||||||
|
ajv: 8.20.0
|
||||||
|
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||||
|
fast-uri: 3.1.2
|
||||||
|
json-schema-ref-resolver: 3.0.0
|
||||||
|
rfdc: 1.4.1
|
||||||
|
|
||||||
|
fast-querystring@1.1.2:
|
||||||
|
dependencies:
|
||||||
|
fast-decode-uri-component: 1.0.1
|
||||||
|
|
||||||
|
fast-uri@3.1.2: {}
|
||||||
|
|
||||||
|
fastify@5.8.5:
|
||||||
|
dependencies:
|
||||||
|
'@fastify/ajv-compiler': 4.0.5
|
||||||
|
'@fastify/error': 4.2.0
|
||||||
|
'@fastify/fast-json-stringify-compiler': 5.0.3
|
||||||
|
'@fastify/proxy-addr': 5.1.0
|
||||||
|
abstract-logging: 2.0.1
|
||||||
|
avvio: 9.2.0
|
||||||
|
fast-json-stringify: 6.4.0
|
||||||
|
find-my-way: 9.6.0
|
||||||
|
light-my-request: 6.6.0
|
||||||
|
pino: 10.3.1
|
||||||
|
process-warning: 5.0.0
|
||||||
|
rfdc: 1.4.1
|
||||||
|
secure-json-parse: 4.1.0
|
||||||
|
semver: 7.8.1
|
||||||
|
toad-cache: 3.7.1
|
||||||
|
|
||||||
|
fastq@1.20.1:
|
||||||
|
dependencies:
|
||||||
|
reusify: 1.1.0
|
||||||
|
|
||||||
|
find-my-way@9.6.0:
|
||||||
|
dependencies:
|
||||||
|
fast-deep-equal: 3.1.3
|
||||||
|
fast-querystring: 1.1.2
|
||||||
|
safe-regex2: 5.1.1
|
||||||
|
|
||||||
|
fsevents@2.3.3:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
generate-function@2.3.1:
|
||||||
|
dependencies:
|
||||||
|
is-property: 1.0.2
|
||||||
|
|
||||||
|
iconv-lite@0.7.2:
|
||||||
|
dependencies:
|
||||||
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
|
ipaddr.js@2.4.0: {}
|
||||||
|
|
||||||
|
is-property@1.0.2: {}
|
||||||
|
|
||||||
|
json-schema-ref-resolver@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
dequal: 2.0.3
|
||||||
|
|
||||||
|
json-schema-traverse@1.0.0: {}
|
||||||
|
|
||||||
|
light-my-request@6.6.0:
|
||||||
|
dependencies:
|
||||||
|
cookie: 1.1.1
|
||||||
|
process-warning: 4.0.1
|
||||||
|
set-cookie-parser: 2.7.2
|
||||||
|
|
||||||
|
long@5.3.2: {}
|
||||||
|
|
||||||
|
lru.min@1.1.4: {}
|
||||||
|
|
||||||
|
mysql2@3.22.3(@types/node@25.9.1):
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 25.9.1
|
||||||
|
aws-ssl-profiles: 1.1.2
|
||||||
|
denque: 2.1.0
|
||||||
|
generate-function: 2.3.1
|
||||||
|
iconv-lite: 0.7.2
|
||||||
|
long: 5.3.2
|
||||||
|
lru.min: 1.1.4
|
||||||
|
named-placeholders: 1.1.6
|
||||||
|
sql-escaper: 1.3.3
|
||||||
|
|
||||||
|
named-placeholders@1.1.6:
|
||||||
|
dependencies:
|
||||||
|
lru.min: 1.1.4
|
||||||
|
|
||||||
|
on-exit-leak-free@2.1.2: {}
|
||||||
|
|
||||||
|
pino-abstract-transport@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
split2: 4.2.0
|
||||||
|
|
||||||
|
pino-std-serializers@7.1.0: {}
|
||||||
|
|
||||||
|
pino@10.3.1:
|
||||||
|
dependencies:
|
||||||
|
'@pinojs/redact': 0.4.0
|
||||||
|
atomic-sleep: 1.0.0
|
||||||
|
on-exit-leak-free: 2.1.2
|
||||||
|
pino-abstract-transport: 3.0.0
|
||||||
|
pino-std-serializers: 7.1.0
|
||||||
|
process-warning: 5.0.0
|
||||||
|
quick-format-unescaped: 4.0.4
|
||||||
|
real-require: 0.2.0
|
||||||
|
safe-stable-stringify: 2.5.0
|
||||||
|
sonic-boom: 4.2.1
|
||||||
|
thread-stream: 4.2.0
|
||||||
|
|
||||||
|
process-warning@4.0.1: {}
|
||||||
|
|
||||||
|
process-warning@5.0.0: {}
|
||||||
|
|
||||||
|
quick-format-unescaped@4.0.4: {}
|
||||||
|
|
||||||
|
real-require@0.2.0: {}
|
||||||
|
|
||||||
|
real-require@1.0.0: {}
|
||||||
|
|
||||||
|
require-from-string@2.0.2: {}
|
||||||
|
|
||||||
|
ret@0.5.0: {}
|
||||||
|
|
||||||
|
reusify@1.1.0: {}
|
||||||
|
|
||||||
|
rfdc@1.4.1: {}
|
||||||
|
|
||||||
|
safe-regex2@5.1.1:
|
||||||
|
dependencies:
|
||||||
|
ret: 0.5.0
|
||||||
|
|
||||||
|
safe-stable-stringify@2.5.0: {}
|
||||||
|
|
||||||
|
safer-buffer@2.1.2: {}
|
||||||
|
|
||||||
|
secure-json-parse@4.1.0: {}
|
||||||
|
|
||||||
|
semver@7.8.1: {}
|
||||||
|
|
||||||
|
set-cookie-parser@2.7.2: {}
|
||||||
|
|
||||||
|
sonic-boom@4.2.1:
|
||||||
|
dependencies:
|
||||||
|
atomic-sleep: 1.0.0
|
||||||
|
|
||||||
|
split2@4.2.0: {}
|
||||||
|
|
||||||
|
sql-escaper@1.3.3: {}
|
||||||
|
|
||||||
|
thread-stream@4.2.0:
|
||||||
|
dependencies:
|
||||||
|
real-require: 1.0.0
|
||||||
|
|
||||||
|
toad-cache@3.7.1: {}
|
||||||
|
|
||||||
|
tsx@4.22.3:
|
||||||
|
dependencies:
|
||||||
|
esbuild: 0.28.0
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
typescript@6.0.3: {}
|
||||||
|
|
||||||
|
undici-types@7.24.6: {}
|
||||||
|
|
||||||
|
zod@4.4.3: {}
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
import Fastify from "fastify";
|
||||||
|
import { ZodError } from "zod";
|
||||||
|
import { pingDatabase } from "./db/pool";
|
||||||
|
import { catalogRoutes } from "./modules/catalog/catalog.controller";
|
||||||
|
import { employeeRoutes } from "./modules/employees/employee.controller";
|
||||||
|
import { HttpError } from "./shared/http-error";
|
||||||
|
import { ok } from "./shared/response";
|
||||||
|
|
||||||
|
// createApp 只创建并配置 Fastify 实例,不直接监听端口。
|
||||||
|
// 这样 server.ts 可以负责启动服务,测试代码也可以单独创建 app 实例。
|
||||||
|
export function createApp() {
|
||||||
|
const app = Fastify({
|
||||||
|
logger: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 前端 Axios 默认会给 DELETE 带上 application/json;空 body 不应被当作服务端异常。
|
||||||
|
app.addContentTypeParser(
|
||||||
|
"application/json",
|
||||||
|
{ parseAs: "string" },
|
||||||
|
(_request, body, done) => {
|
||||||
|
if (body === "") {
|
||||||
|
done(null, undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
done(null, JSON.parse(body as string));
|
||||||
|
} catch (error) {
|
||||||
|
done(error as Error, undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 健康检查接口,供负载均衡器和监控系统使用。
|
||||||
|
app.get("/health", async () => {
|
||||||
|
await pingDatabase();
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
status: "ok",
|
||||||
|
database: "up",
|
||||||
|
now: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 注册业务路由,所有接口都以 /api 开头,便于区分静态资源和 API 请求。
|
||||||
|
app.register(catalogRoutes, { prefix: "/api" });
|
||||||
|
// 员工管理相关接口,包含员工的增删改查和状态更新等功能。
|
||||||
|
app.register(employeeRoutes, { prefix: "/api" });
|
||||||
|
|
||||||
|
// 全局错误处理器,捕获所有未处理的异常,并根据错误类型返回合适的 HTTP 状态码和错误信息。
|
||||||
|
app.setErrorHandler((error, request, reply) => {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: "VALIDATION_ERROR",
|
||||||
|
message: "请求参数不合法",
|
||||||
|
details: error.issues,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof HttpError) {
|
||||||
|
return reply.code(error.statusCode).send({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: error.code,
|
||||||
|
message: error.message,
|
||||||
|
details: error.details,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const mysqlCode = (error as { code?: string }).code;
|
||||||
|
|
||||||
|
// 数据库唯一索引冲突也转成统一的业务错误响应,避免把 MySQL 原始错误直接暴露给调用方。
|
||||||
|
if (mysqlCode === "ER_DUP_ENTRY") {
|
||||||
|
return reply.code(409).send({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: "数据已存在,请检查唯一字段",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
request.log.error({ error }, "未处理的服务异常");
|
||||||
|
|
||||||
|
return reply.code(500).send({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "服务器内部错误",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// 所有运行时配置都从环境变量读取,并在启动时一次性校验。
|
||||||
|
// 这样数据库密码、端口等配置错误会在服务启动阶段暴露,而不是等到请求进来才失败。
|
||||||
|
const envSchema = z.object({
|
||||||
|
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||||
|
PORT: z.coerce.number().int().positive().default(3000),
|
||||||
|
|
||||||
|
DB_HOST: z.string().min(1),
|
||||||
|
DB_PORT: z.coerce.number().int().positive().default(3306),
|
||||||
|
DB_USER: z.string().min(1),
|
||||||
|
DB_PASSWORD: z.string().min(1),
|
||||||
|
DB_NAME: z.string().min(1),
|
||||||
|
DB_CONNECTION_LIMIT: z.coerce.number().int().positive().default(10)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = envSchema.safeParse(process.env);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
// 这里直接退出进程,因为配置错误属于“服务无法安全启动”的问题。
|
||||||
|
console.error("环境变量配置错误,请对照 .env.example 检查:");
|
||||||
|
console.error(z.treeifyError(result.error));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他模块只从 env 读取已校验过的值,不再直接访问 process.env。
|
||||||
|
export const env = result.data;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { createConnection } from "mysql2/promise";
|
||||||
|
import { env } from "../config/env";
|
||||||
|
|
||||||
|
// 迁移脚本使用单独连接而不是连接池,因为它是一次性命令,不是长时间运行的 HTTP 服务。
|
||||||
|
async function migrate(): Promise<void> {
|
||||||
|
const connection = await createConnection({
|
||||||
|
host: env.DB_HOST,
|
||||||
|
port: env.DB_PORT,
|
||||||
|
user: env.DB_USER,
|
||||||
|
password: env.DB_PASSWORD,
|
||||||
|
database: env.DB_NAME,
|
||||||
|
// 迁移文件里可能包含多个 CREATE TABLE / INSERT 语句,所以需要允许多语句执行。
|
||||||
|
multipleStatements: true,
|
||||||
|
timezone: "+08:00"
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// schema_migrations 记录每个已经执行过的 SQL 文件名。
|
||||||
|
// 之后重复运行 pnpm db:migrate 时,已执行过的迁移会被跳过。
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version VARCHAR(255) NOT NULL,
|
||||||
|
applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (version)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='数据库迁移记录表';
|
||||||
|
`);
|
||||||
|
|
||||||
|
const migrationsDir = path.resolve(process.cwd(), "migrations");
|
||||||
|
const files = (await fs.readdir(migrationsDir))
|
||||||
|
.filter((file) => file.endsWith(".sql"))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
// 以文件名作为版本号,简单直观,适合这个学习项目。
|
||||||
|
const [rows] = await connection.query(
|
||||||
|
"SELECT version FROM schema_migrations WHERE version = ? LIMIT 1",
|
||||||
|
[file]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Array.isArray(rows) && rows.length > 0) {
|
||||||
|
console.log(`跳过已执行迁移:${file}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = await fs.readFile(path.join(migrationsDir, file), "utf8");
|
||||||
|
|
||||||
|
// 迁移文件按文件名顺序执行,便于团队协作时追踪每一次表结构变化。
|
||||||
|
await connection.query(sql);
|
||||||
|
await connection.query("INSERT INTO schema_migrations (version) VALUES (?)", [file]);
|
||||||
|
console.log(`已执行迁移:${file}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await connection.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate().catch((error: unknown) => {
|
||||||
|
console.error("数据库迁移失败:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { createPool } from "mysql2/promise";
|
||||||
|
import { env } from "../config/env";
|
||||||
|
|
||||||
|
// 企业项目里不要每次请求都创建连接。连接池负责复用连接,并限制最大并发连接数。
|
||||||
|
export const pool = createPool({
|
||||||
|
host: env.DB_HOST,
|
||||||
|
port: env.DB_PORT,
|
||||||
|
user: env.DB_USER,
|
||||||
|
password: env.DB_PASSWORD,
|
||||||
|
database: env.DB_NAME,
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: env.DB_CONNECTION_LIMIT,
|
||||||
|
namedPlaceholders: true,
|
||||||
|
timezone: "+08:00",
|
||||||
|
});
|
||||||
|
|
||||||
|
// pingDatabase 用于健康检查,确保数据库连接正常。
|
||||||
|
export async function pingDatabase(): Promise<void> {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await connection.ping();
|
||||||
|
} finally {
|
||||||
|
// getConnection 取出的连接必须 release,才能放回连接池继续复用。
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeDatabase 在服务停机时调用,确保所有连接都被正确关闭,避免资源泄漏。
|
||||||
|
export async function closeDatabase(): Promise<void> {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { created, ok } from "../../shared/response";
|
||||||
|
import { catalogService } from "./catalog.service";
|
||||||
|
import {
|
||||||
|
createRoleBodySchema,
|
||||||
|
createStoreBodySchema,
|
||||||
|
idParamSchema,
|
||||||
|
listStoresQuerySchema,
|
||||||
|
updateRoleBodySchema,
|
||||||
|
updateStoreBodySchema,
|
||||||
|
} from "./catalog.schema";
|
||||||
|
|
||||||
|
// catalogRoutes 管理“字典/基础资料”接口:门店和角色。
|
||||||
|
// controller 层只做三件事:校验入参、调用 service、包装 HTTP 响应。
|
||||||
|
export async function catalogRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/stores", async (request) => {
|
||||||
|
const query = listStoresQuerySchema.parse(request.query);
|
||||||
|
// 默认返回可选门店;需要管理后台列表时,可通过 includeInactive=true 带出停用门店。
|
||||||
|
const stores = query.includeInactive
|
||||||
|
? await catalogService.listStores(query)
|
||||||
|
: await catalogService.listActiveStoreOptions();
|
||||||
|
|
||||||
|
return ok(stores);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/stores/:id", async (request) => {
|
||||||
|
const params = idParamSchema.parse(request.params);
|
||||||
|
const store = await catalogService.getStoreById(params.id);
|
||||||
|
|
||||||
|
return ok(store);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/stores", async (request, reply) => {
|
||||||
|
const body = createStoreBodySchema.parse(request.body);
|
||||||
|
const store = await catalogService.createStore(body);
|
||||||
|
|
||||||
|
return reply.code(201).send(created(store));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/stores/:id", async (request) => {
|
||||||
|
const params = idParamSchema.parse(request.params);
|
||||||
|
const body = updateStoreBodySchema.parse(request.body);
|
||||||
|
const store = await catalogService.updateStore(params.id, body);
|
||||||
|
|
||||||
|
return ok(store);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/stores/:id", async (request, reply) => {
|
||||||
|
const params = idParamSchema.parse(request.params);
|
||||||
|
await catalogService.deleteStore(params.id);
|
||||||
|
|
||||||
|
// 204 表示删除成功且没有响应体。
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/roles", async () => {
|
||||||
|
const roles = await catalogService.listRoles();
|
||||||
|
|
||||||
|
return ok(roles);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/roles/:id", async (request) => {
|
||||||
|
const params = idParamSchema.parse(request.params);
|
||||||
|
const role = await catalogService.getRoleById(params.id);
|
||||||
|
|
||||||
|
return ok(role);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/roles", async (request, reply) => {
|
||||||
|
const body = createRoleBodySchema.parse(request.body);
|
||||||
|
const role = await catalogService.createRole(body);
|
||||||
|
|
||||||
|
return reply.code(201).send(created(role));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/roles/:id", async (request) => {
|
||||||
|
const params = idParamSchema.parse(request.params);
|
||||||
|
const body = updateRoleBodySchema.parse(request.body);
|
||||||
|
const role = await catalogService.updateRole(params.id, body);
|
||||||
|
|
||||||
|
return ok(role);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/roles/:id", async (request, reply) => {
|
||||||
|
const params = idParamSchema.parse(request.params);
|
||||||
|
await catalogService.deleteRole(params.id);
|
||||||
|
|
||||||
|
// 角色删除成功后同样不返回 body。
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
import type { ResultSetHeader, RowDataPacket } from "mysql2/promise";
|
||||||
|
import { pool } from "../../db/pool";
|
||||||
|
import type {
|
||||||
|
CreateRoleInput,
|
||||||
|
CreateStoreInput,
|
||||||
|
ListStoresQuery,
|
||||||
|
Role,
|
||||||
|
RoleOption,
|
||||||
|
Store,
|
||||||
|
StoreOption,
|
||||||
|
StoreStatus,
|
||||||
|
UpdateRoleInput,
|
||||||
|
UpdateStoreInput,
|
||||||
|
} from "./catalog.types";
|
||||||
|
|
||||||
|
type SqlParam = string | number | boolean | Date | null;
|
||||||
|
|
||||||
|
interface StoreRow extends RowDataPacket {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
address: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
status: StoreStatus;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoleRow extends RowDataPacket {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CountRow extends RowDataPacket {
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据库返回 Date 对象,接口层统一输出 ISO 字符串,方便前端和接口测试处理。
|
||||||
|
function toIso(value: Date): string {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// repository 层把数据库字段 snake_case 转换成接口使用的 camelCase。
|
||||||
|
function toStore(row: StoreRow): Store {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
address: row.address,
|
||||||
|
phone: row.phone,
|
||||||
|
status: row.status,
|
||||||
|
createdAt: toIso(row.created_at),
|
||||||
|
updatedAt: toIso(row.updated_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStoreOption(row: StoreRow): StoreOption {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
address: row.address,
|
||||||
|
phone: row.phone,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRole(row: RoleRow): Role {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
createdAt: toIso(row.created_at),
|
||||||
|
updatedAt: toIso(row.updated_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRoleOption(row: RoleRow): RoleOption {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const catalogRepository = {
|
||||||
|
async listStores(query: ListStoresQuery = {}): Promise<Store[]> {
|
||||||
|
// includeInactive=true 用于管理列表;默认只查启用且未软删除的门店。
|
||||||
|
const where = query.includeInactive
|
||||||
|
? "deleted_at IS NULL"
|
||||||
|
: "status = 'ACTIVE' AND deleted_at IS NULL";
|
||||||
|
|
||||||
|
const [rows] = await pool.execute<StoreRow[]>(
|
||||||
|
`
|
||||||
|
SELECT id, name, address, phone, status, created_at, updated_at
|
||||||
|
FROM stores
|
||||||
|
WHERE ${where}
|
||||||
|
ORDER BY id ASC
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map(toStore);
|
||||||
|
},
|
||||||
|
|
||||||
|
async listActiveStoreOptions(): Promise<StoreOption[]> {
|
||||||
|
const [rows] = await pool.execute<StoreRow[]>(
|
||||||
|
`
|
||||||
|
SELECT id, name, address, phone, status, created_at, updated_at
|
||||||
|
FROM stores
|
||||||
|
WHERE status = 'ACTIVE' AND deleted_at IS NULL
|
||||||
|
ORDER BY id ASC
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map(toStoreOption);
|
||||||
|
},
|
||||||
|
|
||||||
|
async findStoreById(id: number): Promise<Store | null> {
|
||||||
|
const [rows] = await pool.execute<StoreRow[]>(
|
||||||
|
`
|
||||||
|
SELECT id, name, address, phone, status, created_at, updated_at
|
||||||
|
FROM stores
|
||||||
|
WHERE id = ? AND deleted_at IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows[0] ? toStore(rows[0]) : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async findActiveStoreByName(
|
||||||
|
name: string,
|
||||||
|
excludeStoreId?: number,
|
||||||
|
): Promise<Store | null> {
|
||||||
|
const params: SqlParam[] = [name];
|
||||||
|
let excludeSql = "";
|
||||||
|
|
||||||
|
if (excludeStoreId !== undefined) {
|
||||||
|
// 修改门店名称时需要排除自己,否则会误判为名称重复。
|
||||||
|
excludeSql = " AND id <> ?";
|
||||||
|
params.push(excludeStoreId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await pool.execute<StoreRow[]>(
|
||||||
|
`
|
||||||
|
SELECT id, name, address, phone, status, created_at, updated_at
|
||||||
|
FROM stores
|
||||||
|
WHERE name = ?
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
${excludeSql}
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows[0] ? toStore(rows[0]) : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createStore(input: CreateStoreInput): Promise<number> {
|
||||||
|
const [result] = await pool.execute<ResultSetHeader>(
|
||||||
|
`
|
||||||
|
INSERT INTO stores (name, address, phone, status)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
[input.name, input.address ?? null, input.phone ?? null, input.status],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.insertId;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateStore(id: number, input: UpdateStoreInput): Promise<void> {
|
||||||
|
// PATCH 只更新调用方提交的字段;没有提交的字段保持数据库原值。
|
||||||
|
const fieldMap: Array<[keyof UpdateStoreInput, string]> = [
|
||||||
|
["name", "name"],
|
||||||
|
["address", "address"],
|
||||||
|
["phone", "phone"],
|
||||||
|
["status", "status"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const sets: string[] = [];
|
||||||
|
const params: SqlParam[] = [];
|
||||||
|
|
||||||
|
for (const [inputKey, columnName] of fieldMap) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(input, inputKey)) {
|
||||||
|
sets.push(`${columnName} = ?`);
|
||||||
|
params.push(input[inputKey] ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sets.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
|
||||||
|
await pool.execute(
|
||||||
|
`
|
||||||
|
UPDATE stores
|
||||||
|
SET ${sets.join(", ")}
|
||||||
|
WHERE id = ? AND deleted_at IS NULL
|
||||||
|
`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async softDeleteStore(id: number): Promise<void> {
|
||||||
|
// 门店使用软删除,保留历史数据;同时置为 INACTIVE,避免继续被业务使用。
|
||||||
|
await pool.execute(
|
||||||
|
`
|
||||||
|
UPDATE stores
|
||||||
|
SET status = 'INACTIVE', deleted_at = CURRENT_TIMESTAMP(3)
|
||||||
|
WHERE id = ? AND deleted_at IS NULL
|
||||||
|
`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async countEmployeesByStore(storeId: number): Promise<number> {
|
||||||
|
const [rows] = await pool.execute<CountRow[]>(
|
||||||
|
`
|
||||||
|
SELECT COUNT(*) AS total
|
||||||
|
FROM employees
|
||||||
|
WHERE store_id = ? AND deleted_at IS NULL
|
||||||
|
`,
|
||||||
|
[storeId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows[0]?.total ?? 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listRoles(): Promise<Role[]> {
|
||||||
|
const [rows] = await pool.execute<RoleRow[]>(
|
||||||
|
`
|
||||||
|
SELECT id, code, name, description, created_at, updated_at
|
||||||
|
FROM roles
|
||||||
|
ORDER BY id ASC
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map(toRole);
|
||||||
|
},
|
||||||
|
|
||||||
|
async listRoleOptions(): Promise<RoleOption[]> {
|
||||||
|
const [rows] = await pool.execute<RoleRow[]>(
|
||||||
|
`
|
||||||
|
SELECT id, code, name, description, created_at, updated_at
|
||||||
|
FROM roles
|
||||||
|
ORDER BY id ASC
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map(toRoleOption);
|
||||||
|
},
|
||||||
|
|
||||||
|
async findRoleById(id: number): Promise<Role | null> {
|
||||||
|
const [rows] = await pool.execute<RoleRow[]>(
|
||||||
|
`
|
||||||
|
SELECT id, code, name, description, created_at, updated_at
|
||||||
|
FROM roles
|
||||||
|
WHERE id = ?
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows[0] ? toRole(rows[0]) : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async findRoleByCode(
|
||||||
|
code: string,
|
||||||
|
excludeRoleId?: number,
|
||||||
|
): Promise<Role | null> {
|
||||||
|
const params: SqlParam[] = [code];
|
||||||
|
let excludeSql = "";
|
||||||
|
|
||||||
|
if (excludeRoleId !== undefined) {
|
||||||
|
// 修改角色编码时排除当前角色,避免自己和自己冲突。
|
||||||
|
excludeSql = " AND id <> ?";
|
||||||
|
params.push(excludeRoleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await pool.execute<RoleRow[]>(
|
||||||
|
`
|
||||||
|
SELECT id, code, name, description, created_at, updated_at
|
||||||
|
FROM roles
|
||||||
|
WHERE code = ?
|
||||||
|
${excludeSql}
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows[0] ? toRole(rows[0]) : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createRole(input: CreateRoleInput): Promise<number> {
|
||||||
|
const [result] = await pool.execute<ResultSetHeader>(
|
||||||
|
`
|
||||||
|
INSERT INTO roles (code, name, description)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`,
|
||||||
|
[input.code, input.name, input.description ?? null],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.insertId;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateRole(id: number, input: UpdateRoleInput): Promise<void> {
|
||||||
|
// 和门店更新一样,角色 PATCH 也只更新请求里明确出现的字段。
|
||||||
|
const fieldMap: Array<[keyof UpdateRoleInput, string]> = [
|
||||||
|
["code", "code"],
|
||||||
|
["name", "name"],
|
||||||
|
["description", "description"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const sets: string[] = [];
|
||||||
|
const params: SqlParam[] = [];
|
||||||
|
|
||||||
|
for (const [inputKey, columnName] of fieldMap) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(input, inputKey)) {
|
||||||
|
sets.push(`${columnName} = ?`);
|
||||||
|
params.push(input[inputKey] ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sets.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
|
||||||
|
await pool.execute(
|
||||||
|
`
|
||||||
|
UPDATE roles
|
||||||
|
SET ${sets.join(", ")}
|
||||||
|
WHERE id = ?
|
||||||
|
`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteRole(id: number): Promise<void> {
|
||||||
|
await pool.execute("DELETE FROM roles WHERE id = ?", [id]);
|
||||||
|
},
|
||||||
|
|
||||||
|
async countEmployeesByRole(roleId: number): Promise<number> {
|
||||||
|
const [rows] = await pool.execute<CountRow[]>(
|
||||||
|
`
|
||||||
|
SELECT COUNT(*) AS total
|
||||||
|
FROM employee_roles
|
||||||
|
WHERE role_id = ?
|
||||||
|
`,
|
||||||
|
[roleId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows[0]?.total ?? 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { STORE_STATUS } from "./catalog.types";
|
||||||
|
|
||||||
|
// URL 查询参数里空字符串通常表示“没有筛选条件”,这里统一转成 undefined。
|
||||||
|
const emptyStringToUndefined = (value: unknown) => {
|
||||||
|
if (typeof value === "string" && value.trim() === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fastify 接收到的 query 参数都是字符串,这里把 "true"/"false" 转成布尔值。
|
||||||
|
const stringToBoolean = (value: unknown) => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
if (value === "true") return true;
|
||||||
|
if (value === "false") return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 可选文本字段允许不传,也允许传空字符串;空字符串会被保存成 NULL。
|
||||||
|
const nullableText = (max: number) =>
|
||||||
|
z.preprocess((value) => {
|
||||||
|
if (typeof value === "string" && value.trim() === "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}, z.string().trim().max(max).nullable().optional());
|
||||||
|
|
||||||
|
export const idParamSchema = z.object({
|
||||||
|
id: z.coerce.number().int().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listStoresQuerySchema = z.object({
|
||||||
|
includeInactive: z.preprocess(
|
||||||
|
(value) => stringToBoolean(emptyStringToUndefined(value)),
|
||||||
|
z.boolean().optional(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createStoreBodySchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(100),
|
||||||
|
address: nullableText(255),
|
||||||
|
phone: nullableText(30),
|
||||||
|
status: z.enum(STORE_STATUS).default("ACTIVE"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateStoreBodySchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().trim().min(1).max(100).optional(),
|
||||||
|
address: nullableText(255),
|
||||||
|
phone: nullableText(30),
|
||||||
|
status: z.enum(STORE_STATUS).optional(),
|
||||||
|
})
|
||||||
|
.refine((value) => Object.keys(value).length > 0, {
|
||||||
|
message: "至少需要提交一个要修改的字段",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createRoleBodySchema = z.object({
|
||||||
|
code: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.max(50)
|
||||||
|
// code 作为程序里的稳定标识,限制成简单格式可以减少大小写和特殊字符带来的混乱。
|
||||||
|
.regex(
|
||||||
|
/^[a-z][a-z0-9_]*$/,
|
||||||
|
"角色编码只能使用小写字母、数字和下划线,并以字母开头",
|
||||||
|
),
|
||||||
|
name: z.string().trim().min(1).max(50),
|
||||||
|
description: nullableText(255),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateRoleBodySchema = createRoleBodySchema
|
||||||
|
.partial()
|
||||||
|
.refine((value) => Object.keys(value).length > 0, {
|
||||||
|
message: "至少需要提交一个要修改的字段",
|
||||||
|
});
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { conflict, notFound } from "../../shared/http-error";
|
||||||
|
import { catalogRepository } from "./catalog.repository";
|
||||||
|
import type {
|
||||||
|
CreateRoleInput,
|
||||||
|
CreateStoreInput,
|
||||||
|
ListStoresQuery,
|
||||||
|
Role,
|
||||||
|
RoleOption,
|
||||||
|
Store,
|
||||||
|
StoreOption,
|
||||||
|
UpdateRoleInput,
|
||||||
|
UpdateStoreInput,
|
||||||
|
} from "./catalog.types";
|
||||||
|
|
||||||
|
// service 层承载业务规则。
|
||||||
|
// repository 只管 SQL,controller 只管 HTTP,业务判断集中在这里更容易维护。
|
||||||
|
export const catalogService = {
|
||||||
|
async listStores(query: ListStoresQuery): Promise<Store[]> {
|
||||||
|
return catalogRepository.listStores(query);
|
||||||
|
},
|
||||||
|
|
||||||
|
async listActiveStoreOptions(): Promise<StoreOption[]> {
|
||||||
|
return catalogRepository.listActiveStoreOptions();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getStoreById(id: number): Promise<Store> {
|
||||||
|
const store = await catalogRepository.findStoreById(id);
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
throw notFound("门店不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
return store;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createStore(input: CreateStoreInput): Promise<Store> {
|
||||||
|
// 门店名称在“未删除门店”范围内保持唯一,避免管理后台出现两个同名门店。
|
||||||
|
const duplicatedStore = await catalogRepository.findActiveStoreByName(
|
||||||
|
input.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (duplicatedStore) {
|
||||||
|
throw conflict("门店名称已存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建类 SQL 只返回 insertId,再查询一次完整记录,保证接口返回格式和详情接口一致。
|
||||||
|
const storeId = await catalogRepository.createStore(input);
|
||||||
|
return this.getStoreById(storeId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateStore(id: number, input: UpdateStoreInput): Promise<Store> {
|
||||||
|
const currentStore = await this.getStoreById(id);
|
||||||
|
|
||||||
|
if (input.name !== undefined) {
|
||||||
|
const duplicatedStore = await catalogRepository.findActiveStoreByName(
|
||||||
|
input.name,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (duplicatedStore) {
|
||||||
|
throw conflict("门店名称已存在");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果门店下还有员工,停用门店会让员工数据失去可用归属,所以这里先阻止。
|
||||||
|
if (currentStore.status === "ACTIVE" && input.status === "INACTIVE") {
|
||||||
|
const employeeCount = await catalogRepository.countEmployeesByStore(id);
|
||||||
|
|
||||||
|
if (employeeCount > 0) {
|
||||||
|
throw conflict("门店下还有员工,不能停用");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await catalogRepository.updateStore(id, input);
|
||||||
|
return this.getStoreById(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteStore(id: number): Promise<void> {
|
||||||
|
await this.getStoreById(id);
|
||||||
|
|
||||||
|
// 有员工绑定时不允许删除门店,避免产生孤立员工。
|
||||||
|
const employeeCount = await catalogRepository.countEmployeesByStore(id);
|
||||||
|
|
||||||
|
if (employeeCount > 0) {
|
||||||
|
throw conflict("门店下还有员工,不能删除");
|
||||||
|
}
|
||||||
|
|
||||||
|
await catalogRepository.softDeleteStore(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
async listRoles(): Promise<Role[]> {
|
||||||
|
return catalogRepository.listRoles();
|
||||||
|
},
|
||||||
|
|
||||||
|
async listRoleOptions(): Promise<RoleOption[]> {
|
||||||
|
return catalogRepository.listRoleOptions();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRoleById(id: number): Promise<Role> {
|
||||||
|
const role = await catalogRepository.findRoleById(id);
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
throw notFound("角色不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
return role;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createRole(input: CreateRoleInput): Promise<Role> {
|
||||||
|
// code 是权限判断用的稳定编码,必须唯一。
|
||||||
|
const duplicatedRole = await catalogRepository.findRoleByCode(input.code);
|
||||||
|
|
||||||
|
if (duplicatedRole) {
|
||||||
|
throw conflict("角色编码已存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleId = await catalogRepository.createRole(input);
|
||||||
|
return this.getRoleById(roleId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateRole(id: number, input: UpdateRoleInput): Promise<Role> {
|
||||||
|
await this.getRoleById(id);
|
||||||
|
|
||||||
|
if (input.code !== undefined) {
|
||||||
|
const duplicatedRole = await catalogRepository.findRoleByCode(
|
||||||
|
input.code,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (duplicatedRole) {
|
||||||
|
throw conflict("角色编码已存在");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await catalogRepository.updateRole(id, input);
|
||||||
|
return this.getRoleById(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteRole(id: number): Promise<void> {
|
||||||
|
await this.getRoleById(id);
|
||||||
|
|
||||||
|
// 角色被员工使用时不允许删除,避免员工详情里出现失效角色。
|
||||||
|
const employeeCount = await catalogRepository.countEmployeesByRole(id);
|
||||||
|
|
||||||
|
if (employeeCount > 0) {
|
||||||
|
throw conflict("角色已绑定员工,不能删除");
|
||||||
|
}
|
||||||
|
|
||||||
|
await catalogRepository.deleteRole(id);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export const STORE_STATUS = ["ACTIVE", "INACTIVE"] as const;
|
||||||
|
|
||||||
|
// 从常量数组推导联合类型,避免状态枚举在 schema 和类型定义里写两遍。
|
||||||
|
export type StoreStatus = (typeof STORE_STATUS)[number];
|
||||||
|
|
||||||
|
// Option 类型用于下拉框等轻量接口,只返回页面选择所需字段。
|
||||||
|
export interface StoreOption {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
address: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoleOption {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store/Role 是详情或管理列表使用的完整返回结构。
|
||||||
|
export interface Store extends StoreOption {
|
||||||
|
status: StoreStatus;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Role extends RoleOption {
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListStoresQuery {
|
||||||
|
includeInactive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateStoreInput {
|
||||||
|
name: string;
|
||||||
|
address?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
status: StoreStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateStoreInput {
|
||||||
|
name?: string;
|
||||||
|
address?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
status?: StoreStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRoleInput {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateRoleInput {
|
||||||
|
code?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { created, ok, paginated } from "../../shared/response";
|
||||||
|
import { employeeService } from "./employee.service";
|
||||||
|
import {
|
||||||
|
createEmployeeBodySchema,
|
||||||
|
idParamSchema,
|
||||||
|
listEmployeesQuerySchema,
|
||||||
|
updateEmployeeBodySchema,
|
||||||
|
updateEmployeeStatusBodySchema
|
||||||
|
} from "./employee.schema";
|
||||||
|
|
||||||
|
// 员工接口是本项目的核心 CRUD。
|
||||||
|
// controller 层保持轻量:解析请求参数,调用 service,返回统一响应。
|
||||||
|
export async function employeeRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/employees", async (request) => {
|
||||||
|
const query = listEmployeesQuerySchema.parse(request.query);
|
||||||
|
const result = await employeeService.list(query);
|
||||||
|
|
||||||
|
return paginated(result.items, query.page, query.pageSize, result.total);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/employees/:id", async (request) => {
|
||||||
|
const params = idParamSchema.parse(request.params);
|
||||||
|
const employee = await employeeService.getById(params.id);
|
||||||
|
|
||||||
|
return ok(employee);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/employees", async (request, reply) => {
|
||||||
|
const body = createEmployeeBodySchema.parse(request.body);
|
||||||
|
const employee = await employeeService.create(body);
|
||||||
|
|
||||||
|
return reply.code(201).send(created(employee));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/employees/:id", async (request) => {
|
||||||
|
const params = idParamSchema.parse(request.params);
|
||||||
|
const body = updateEmployeeBodySchema.parse(request.body);
|
||||||
|
const employee = await employeeService.update(params.id, body);
|
||||||
|
|
||||||
|
return ok(employee);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/employees/:id/status", async (request) => {
|
||||||
|
const params = idParamSchema.parse(request.params);
|
||||||
|
const body = updateEmployeeStatusBodySchema.parse(request.body);
|
||||||
|
// 单独提供状态接口,方便前端做“启用/停用”开关,而不必提交完整员工表单。
|
||||||
|
const employee = await employeeService.updateStatus(params.id, body.status);
|
||||||
|
|
||||||
|
return ok(employee);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/employees/:id", async (request, reply) => {
|
||||||
|
const params = idParamSchema.parse(request.params);
|
||||||
|
await employeeService.delete(params.id);
|
||||||
|
|
||||||
|
// 删除使用软删除实现;接口层仍返回标准的 204 No Content。
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
import type { PoolConnection, ResultSetHeader, RowDataPacket } from "mysql2/promise";
|
||||||
|
import { pool } from "../../db/pool";
|
||||||
|
import type {
|
||||||
|
CreateEmployeeInput,
|
||||||
|
Employee,
|
||||||
|
EmployeeStatus,
|
||||||
|
ListEmployeesQuery,
|
||||||
|
RoleSummary,
|
||||||
|
UpdateEmployeeInput
|
||||||
|
} from "./employee.types";
|
||||||
|
|
||||||
|
type DbExecutor = typeof pool | PoolConnection;
|
||||||
|
type SqlParam = string | number | boolean | Date | null;
|
||||||
|
|
||||||
|
interface EmployeeRow extends RowDataPacket {
|
||||||
|
id: number;
|
||||||
|
store_id: number;
|
||||||
|
store_name: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
status: EmployeeStatus;
|
||||||
|
remark: string | null;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoleRow extends RowDataPacket {
|
||||||
|
employee_id: number;
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CountRow extends RowDataPacket {
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据库层统一把 Date 转成字符串,避免响应里混入运行时对象。
|
||||||
|
function toIso(value: Date): string {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把 JOIN 查询出来的数据库行转换成接口返回结构。
|
||||||
|
function toEmployee(row: EmployeeRow, roles: RoleSummary[] = []): Employee {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
storeId: row.store_id,
|
||||||
|
storeName: row.store_name,
|
||||||
|
name: row.name,
|
||||||
|
phone: row.phone,
|
||||||
|
status: row.status,
|
||||||
|
remark: row.remark,
|
||||||
|
roles,
|
||||||
|
createdAt: toIso(row.created_at),
|
||||||
|
updatedAt: toIso(row.updated_at)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 员工列表支持多个筛选条件,where 和 params 必须一起构造,保持参数化查询。
|
||||||
|
function buildListWhere(query: ListEmployeesQuery): { whereSql: string; params: SqlParam[] } {
|
||||||
|
const where: string[] = ["e.deleted_at IS NULL"];
|
||||||
|
const params: SqlParam[] = [];
|
||||||
|
|
||||||
|
if (query.storeId !== undefined) {
|
||||||
|
where.push("e.store_id = ?");
|
||||||
|
params.push(query.storeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.status !== undefined) {
|
||||||
|
where.push("e.status = ?");
|
||||||
|
params.push(query.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.keyword !== undefined) {
|
||||||
|
where.push("(e.name LIKE ? OR e.phone LIKE ?)");
|
||||||
|
params.push(`%${query.keyword}%`, `%${query.keyword}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
whereSql: where.join(" AND "),
|
||||||
|
params
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const employeeRepository = {
|
||||||
|
async withTransaction<T>(handler: (connection: PoolConnection) => Promise<T>): Promise<T> {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 所有传入 handler 的 SQL 都使用同一个连接,才能处在同一个事务里。
|
||||||
|
await connection.beginTransaction();
|
||||||
|
const result = await handler(connection);
|
||||||
|
await connection.commit();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async storeExists(storeId: number, db: DbExecutor = pool): Promise<boolean> {
|
||||||
|
// 员工只能绑定到启用且未删除的门店。
|
||||||
|
const [rows] = await db.execute<CountRow[]>(
|
||||||
|
"SELECT COUNT(*) AS total FROM stores WHERE id = ? AND status = 'ACTIVE' AND deleted_at IS NULL",
|
||||||
|
[storeId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows[0]?.total > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
async existingRoleIds(roleIds: number[], db: DbExecutor = pool): Promise<number[]> {
|
||||||
|
if (roleIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// IN 条件的占位符数量必须和 roleIds 长度一致,仍然使用参数化查询避免 SQL 注入。
|
||||||
|
const placeholders = roleIds.map(() => "?").join(", ");
|
||||||
|
const [rows] = await db.execute<(RowDataPacket & { id: number })[]>(
|
||||||
|
`SELECT id FROM roles WHERE id IN (${placeholders})`,
|
||||||
|
roleIds
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((row) => row.id);
|
||||||
|
},
|
||||||
|
|
||||||
|
async findActiveByStoreAndPhone(
|
||||||
|
storeId: number,
|
||||||
|
phone: string,
|
||||||
|
excludeEmployeeId?: number,
|
||||||
|
db: DbExecutor = pool
|
||||||
|
): Promise<Employee | null> {
|
||||||
|
const params: SqlParam[] = [storeId, phone];
|
||||||
|
let excludeSql = "";
|
||||||
|
|
||||||
|
if (excludeEmployeeId !== undefined) {
|
||||||
|
// 更新员工时排除当前员工,否则手机号不变也会被误判重复。
|
||||||
|
excludeSql = " AND e.id <> ?";
|
||||||
|
params.push(excludeEmployeeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await db.execute<EmployeeRow[]>(
|
||||||
|
`
|
||||||
|
SELECT e.*, s.name AS store_name
|
||||||
|
FROM employees e
|
||||||
|
INNER JOIN stores s ON s.id = e.store_id
|
||||||
|
WHERE e.store_id = ?
|
||||||
|
AND e.phone = ?
|
||||||
|
AND e.deleted_at IS NULL
|
||||||
|
${excludeSql}
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows[0] ? toEmployee(rows[0]) : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async list(query: ListEmployeesQuery): Promise<{ items: Employee[]; total: number }> {
|
||||||
|
const { whereSql, params } = buildListWhere(query);
|
||||||
|
const offset = (query.page - 1) * query.pageSize;
|
||||||
|
|
||||||
|
// 列表总数和分页数据分开查,前端才能正确渲染分页器。
|
||||||
|
const [countRows] = await pool.execute<CountRow[]>(
|
||||||
|
`
|
||||||
|
SELECT COUNT(*) AS total
|
||||||
|
FROM employees e
|
||||||
|
INNER JOIN stores s ON s.id = e.store_id
|
||||||
|
WHERE ${whereSql}
|
||||||
|
`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// LIMIT/OFFSET 已经过 zod 校验成受控正整数;这里直接拼接数字,避免部分 MySQL 驱动对分页占位符的兼容问题。
|
||||||
|
const [rows] = await pool.execute<EmployeeRow[]>(
|
||||||
|
`
|
||||||
|
SELECT e.*, s.name AS store_name
|
||||||
|
FROM employees e
|
||||||
|
INNER JOIN stores s ON s.id = e.store_id
|
||||||
|
WHERE ${whereSql}
|
||||||
|
ORDER BY e.id DESC
|
||||||
|
LIMIT ${query.pageSize} OFFSET ${offset}
|
||||||
|
`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
const rolesByEmployeeId = await this.findRolesByEmployeeIds(rows.map((row) => row.id));
|
||||||
|
const items = rows.map((row) => toEmployee(row, rolesByEmployeeId.get(row.id) ?? []));
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total: countRows[0]?.total ?? 0
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async findById(id: number, db: DbExecutor = pool): Promise<Employee | null> {
|
||||||
|
const [rows] = await db.execute<EmployeeRow[]>(
|
||||||
|
`
|
||||||
|
SELECT e.*, s.name AS store_name
|
||||||
|
FROM employees e
|
||||||
|
INNER JOIN stores s ON s.id = e.store_id
|
||||||
|
WHERE e.id = ? AND e.deleted_at IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows[0]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rolesByEmployeeId = await this.findRolesByEmployeeIds([id], db);
|
||||||
|
return toEmployee(rows[0], rolesByEmployeeId.get(id) ?? []);
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(input: CreateEmployeeInput, db: DbExecutor = pool): Promise<number> {
|
||||||
|
const [result] = await db.execute<ResultSetHeader>(
|
||||||
|
`
|
||||||
|
INSERT INTO employees (store_id, name, phone, status, remark)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
[input.storeId, input.name, input.phone, input.status, input.remark ?? null]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.insertId;
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: number, input: Omit<UpdateEmployeeInput, "roleIds">, db: DbExecutor = pool): Promise<void> {
|
||||||
|
// PATCH 只更新请求中出现的字段,未出现字段保持原值。
|
||||||
|
const fieldMap: Array<[keyof typeof input, string]> = [
|
||||||
|
["storeId", "store_id"],
|
||||||
|
["name", "name"],
|
||||||
|
["phone", "phone"],
|
||||||
|
["status", "status"],
|
||||||
|
["remark", "remark"]
|
||||||
|
];
|
||||||
|
|
||||||
|
const sets: string[] = [];
|
||||||
|
const params: SqlParam[] = [];
|
||||||
|
|
||||||
|
for (const [inputKey, columnName] of fieldMap) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(input, inputKey)) {
|
||||||
|
sets.push(`${columnName} = ?`);
|
||||||
|
params.push(input[inputKey] ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sets.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
`
|
||||||
|
UPDATE employees
|
||||||
|
SET ${sets.join(", ")}
|
||||||
|
WHERE id = ? AND deleted_at IS NULL
|
||||||
|
`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async softDelete(id: number): Promise<void> {
|
||||||
|
// 员工删除采用软删除:保留历史记录,同时将状态置为 INACTIVE。
|
||||||
|
await pool.execute(
|
||||||
|
`
|
||||||
|
UPDATE employees
|
||||||
|
SET status = 'INACTIVE', deleted_at = CURRENT_TIMESTAMP(3)
|
||||||
|
WHERE id = ? AND deleted_at IS NULL
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async replaceRoles(employeeId: number, roleIds: number[], db: DbExecutor = pool): Promise<void> {
|
||||||
|
// 简化多对多更新:先删旧关系,再批量插入新关系。调用方用事务包住它。
|
||||||
|
await db.execute("DELETE FROM employee_roles WHERE employee_id = ?", [employeeId]);
|
||||||
|
|
||||||
|
if (roleIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
`
|
||||||
|
INSERT INTO employee_roles (employee_id, role_id)
|
||||||
|
VALUES ${roleIds.map(() => "(?, ?)").join(", ")}
|
||||||
|
`,
|
||||||
|
roleIds.flatMap((roleId) => [employeeId, roleId])
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async findRolesByEmployeeIds(
|
||||||
|
employeeIds: number[],
|
||||||
|
db: DbExecutor = pool
|
||||||
|
): Promise<Map<number, RoleSummary[]>> {
|
||||||
|
const result = new Map<number, RoleSummary[]>();
|
||||||
|
|
||||||
|
if (employeeIds.length === 0) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholders = employeeIds.map(() => "?").join(", ");
|
||||||
|
// 一次性查出多个员工的角色,避免列表接口为每个员工单独查询产生 N+1 问题。
|
||||||
|
const [rows] = await db.execute<RoleRow[]>(
|
||||||
|
`
|
||||||
|
SELECT er.employee_id, r.id, r.code, r.name
|
||||||
|
FROM employee_roles er
|
||||||
|
INNER JOIN roles r ON r.id = er.role_id
|
||||||
|
WHERE er.employee_id IN (${placeholders})
|
||||||
|
ORDER BY r.id ASC
|
||||||
|
`,
|
||||||
|
employeeIds
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const roles = result.get(row.employee_id) ?? [];
|
||||||
|
roles.push({
|
||||||
|
id: row.id,
|
||||||
|
code: row.code,
|
||||||
|
name: row.name
|
||||||
|
});
|
||||||
|
result.set(row.employee_id, roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { EMPLOYEE_STATUS } from "./employee.types";
|
||||||
|
|
||||||
|
// 查询参数里的空字符串统一当作“未传”,方便前端表单清空筛选条件。
|
||||||
|
const emptyStringToUndefined = (value: unknown) => {
|
||||||
|
if (typeof value === "string" && value.trim() === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const phoneSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
// 这里先按中国大陆手机号做校验;如果你的店里有港澳台或海外员工,可以改成更宽松的规则。
|
||||||
|
.regex(/^1[3-9]\d{9}$/, "手机号格式不正确");
|
||||||
|
|
||||||
|
const roleIdsSchema = z
|
||||||
|
.array(z.coerce.number().int().positive())
|
||||||
|
// 角色过多通常说明权限模型需要重新设计,这里先给出合理上限。
|
||||||
|
.max(20, "一个员工不建议绑定过多角色")
|
||||||
|
.default([]);
|
||||||
|
|
||||||
|
export const idParamSchema = z.object({
|
||||||
|
id: z.coerce.number().int().positive()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listEmployeesQuerySchema = z.object({
|
||||||
|
// z.coerce 用于把 query string 转成数字,例如 ?page=1。
|
||||||
|
storeId: z.preprocess(emptyStringToUndefined, z.coerce.number().int().positive().optional()),
|
||||||
|
status: z.preprocess(emptyStringToUndefined, z.enum(EMPLOYEE_STATUS).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 createEmployeeBodySchema = z.object({
|
||||||
|
storeId: z.coerce.number().int().positive(),
|
||||||
|
name: z.string().trim().min(1).max(50),
|
||||||
|
phone: phoneSchema,
|
||||||
|
status: z.enum(EMPLOYEE_STATUS).default("ACTIVE"),
|
||||||
|
remark: z.string().trim().max(500).nullable().optional(),
|
||||||
|
roleIds: roleIdsSchema
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateEmployeeBodySchema = z
|
||||||
|
.object({
|
||||||
|
storeId: z.coerce.number().int().positive().optional(),
|
||||||
|
name: z.string().trim().min(1).max(50).optional(),
|
||||||
|
phone: phoneSchema.optional(),
|
||||||
|
status: z.enum(EMPLOYEE_STATUS).optional(),
|
||||||
|
remark: z.string().trim().max(500).nullable().optional(),
|
||||||
|
roleIds: roleIdsSchema.optional()
|
||||||
|
})
|
||||||
|
// PATCH 不允许提交空对象,否则调用方无法判断到底修改了什么。
|
||||||
|
.refine((value) => Object.keys(value).length > 0, {
|
||||||
|
message: "至少需要提交一个要修改的字段"
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateEmployeeStatusBodySchema = z.object({
|
||||||
|
status: z.enum(EMPLOYEE_STATUS)
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { badRequest, conflict, notFound } from "../../shared/http-error";
|
||||||
|
import { employeeRepository } from "./employee.repository";
|
||||||
|
import type { CreateEmployeeInput, Employee, ListEmployeesQuery, UpdateEmployeeInput } from "./employee.types";
|
||||||
|
|
||||||
|
// 角色 id 可能从前端重复提交;进入数据库前先去重,减少无意义 SQL 和主键冲突。
|
||||||
|
function uniqueIds(ids: number[]): number[] {
|
||||||
|
return [...new Set(ids)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建或修改员工时,必须保证门店存在且处于启用状态。
|
||||||
|
async function assertStoreExists(storeId: number): Promise<void> {
|
||||||
|
const exists = await employeeRepository.storeExists(storeId);
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
throw badRequest("门店不存在或已停用");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交的角色必须全部存在;只要有一个不存在,就拒绝整个请求。
|
||||||
|
async function assertRolesExist(roleIds: number[]): Promise<number[]> {
|
||||||
|
const dedupedRoleIds = uniqueIds(roleIds);
|
||||||
|
const existingRoleIds = await employeeRepository.existingRoleIds(dedupedRoleIds);
|
||||||
|
|
||||||
|
if (existingRoleIds.length !== dedupedRoleIds.length) {
|
||||||
|
throw badRequest("提交的角色包含不存在的角色");
|
||||||
|
}
|
||||||
|
|
||||||
|
return dedupedRoleIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 员工业务规则集中在 service:门店有效性、角色有效性、手机号唯一性和事务边界。
|
||||||
|
export const employeeService = {
|
||||||
|
async list(query: ListEmployeesQuery): Promise<{ items: Employee[]; total: number }> {
|
||||||
|
return employeeRepository.list(query);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(id: number): Promise<Employee> {
|
||||||
|
const employee = await employeeRepository.findById(id);
|
||||||
|
|
||||||
|
if (!employee) {
|
||||||
|
throw notFound("员工不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
return employee;
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(input: CreateEmployeeInput): Promise<Employee> {
|
||||||
|
await assertStoreExists(input.storeId);
|
||||||
|
const roleIds = await assertRolesExist(input.roleIds);
|
||||||
|
|
||||||
|
// 手机号只要求在同一个未删除门店内唯一;不同门店可以存在同一手机号。
|
||||||
|
const duplicatedEmployee = await employeeRepository.findActiveByStoreAndPhone(input.storeId, input.phone);
|
||||||
|
|
||||||
|
if (duplicatedEmployee) {
|
||||||
|
throw conflict("同一门店下手机号已存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建员工和绑定角色必须放在一个事务里,避免员工创建成功但角色绑定失败。
|
||||||
|
const employeeId = await employeeRepository.withTransaction(async (connection) => {
|
||||||
|
const createdEmployeeId = await employeeRepository.create({ ...input, roleIds }, connection);
|
||||||
|
await employeeRepository.replaceRoles(createdEmployeeId, roleIds, connection);
|
||||||
|
return createdEmployeeId;
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.getById(employeeId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: number, input: UpdateEmployeeInput): Promise<Employee> {
|
||||||
|
const currentEmployee = await this.getById(id);
|
||||||
|
|
||||||
|
if (input.storeId !== undefined) {
|
||||||
|
await assertStoreExists(input.storeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStoreId = input.storeId ?? currentEmployee.storeId;
|
||||||
|
const nextPhone = input.phone ?? currentEmployee.phone;
|
||||||
|
|
||||||
|
// 只有门店或手机号发生变化时才需要重新检查唯一性。
|
||||||
|
if (input.storeId !== undefined || input.phone !== undefined) {
|
||||||
|
const duplicatedEmployee = await employeeRepository.findActiveByStoreAndPhone(nextStoreId, nextPhone, id);
|
||||||
|
|
||||||
|
if (duplicatedEmployee) {
|
||||||
|
throw conflict("同一门店下手机号已存在");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// roleIds 没传表示不修改角色;传空数组表示清空角色。
|
||||||
|
const roleIds = input.roleIds === undefined ? undefined : await assertRolesExist(input.roleIds);
|
||||||
|
const { roleIds: _roleIds, ...employeeFields } = input;
|
||||||
|
|
||||||
|
// 员工基础信息和角色关系要么一起成功,要么一起回滚。
|
||||||
|
await employeeRepository.withTransaction(async (connection) => {
|
||||||
|
await employeeRepository.update(id, employeeFields, connection);
|
||||||
|
|
||||||
|
if (roleIds !== undefined) {
|
||||||
|
await employeeRepository.replaceRoles(id, roleIds, connection);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.getById(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateStatus(id: number, status: Employee["status"]): Promise<Employee> {
|
||||||
|
await this.getById(id);
|
||||||
|
// 这里只改变状态,不影响角色、手机号和门店关系。
|
||||||
|
await employeeRepository.update(id, { status });
|
||||||
|
return this.getById(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: number): Promise<void> {
|
||||||
|
await this.getById(id);
|
||||||
|
// 软删除保留历史数据,也能配合 active_phone 释放手机号唯一约束。
|
||||||
|
await employeeRepository.softDelete(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export const EMPLOYEE_STATUS = ["ACTIVE", "INACTIVE"] as const;
|
||||||
|
|
||||||
|
// 从状态常量推导类型,确保 schema 校验和 TypeScript 类型保持一致。
|
||||||
|
export type EmployeeStatus = (typeof EMPLOYEE_STATUS)[number];
|
||||||
|
|
||||||
|
// 员工详情里只需要角色摘要,完整角色管理由 catalog 模块负责。
|
||||||
|
export interface RoleSummary {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Employee 是接口返回给前端的员工结构,字段名使用 camelCase。
|
||||||
|
export interface Employee {
|
||||||
|
id: number;
|
||||||
|
storeId: number;
|
||||||
|
storeName: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
status: EmployeeStatus;
|
||||||
|
remark: string | null;
|
||||||
|
roles: RoleSummary[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListEmployeesQuery {
|
||||||
|
storeId?: number;
|
||||||
|
status?: EmployeeStatus;
|
||||||
|
keyword?: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateEmployeeInput {
|
||||||
|
storeId: number;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
status: EmployeeStatus;
|
||||||
|
remark?: string | null;
|
||||||
|
roleIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateEmployeeInput {
|
||||||
|
storeId?: number;
|
||||||
|
name?: string;
|
||||||
|
phone?: string;
|
||||||
|
status?: EmployeeStatus;
|
||||||
|
remark?: string | null;
|
||||||
|
roleIds?: number[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { env } from "./config/env";
|
||||||
|
import { closeDatabase } from "./db/pool";
|
||||||
|
import { createApp } from "./app";
|
||||||
|
|
||||||
|
// server.ts 只负责“把应用真正启动起来”。
|
||||||
|
// 路由、错误处理和插件注册都放在 app.ts,方便以后写测试时直接复用 createApp()。
|
||||||
|
async function bootstrap(): Promise<void> {
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
// 优雅停机,确保正在处理的请求完成后再关闭服务和数据库连接。
|
||||||
|
const shutdown = async () => {
|
||||||
|
await app.close();
|
||||||
|
await closeDatabase();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听系统信号,触发优雅停机流程。
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
shutdown().finally(() => process.exit(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("SIGTERM", () => {
|
||||||
|
shutdown().finally(() => process.exit(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.listen({
|
||||||
|
// 监听 0.0.0.0 方便 Docker、局域网或本机不同网络接口访问。
|
||||||
|
host: "0.0.0.0",
|
||||||
|
port: env.PORT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap().catch((error: unknown) => {
|
||||||
|
console.error("服务启动失败:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// 统一的业务错误类型。
|
||||||
|
// service 层抛出 HttpError,app.ts 的全局错误处理器负责转换成 HTTP 响应。
|
||||||
|
export class HttpError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly statusCode: number,
|
||||||
|
public readonly code: string,
|
||||||
|
message: string,
|
||||||
|
public readonly details?: unknown,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "HttpError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下面这些工厂函数让业务代码只表达语义,不需要到处手写状态码和错误码。
|
||||||
|
export function badRequest(message: string, details?: unknown): HttpError {
|
||||||
|
return new HttpError(400, "BAD_REQUEST", message, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notFound(message: string): HttpError {
|
||||||
|
return new HttpError(404, "NOT_FOUND", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function conflict(message: string): HttpError {
|
||||||
|
return new HttpError(409, "CONFLICT", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function internalServerError(message: string): HttpError {
|
||||||
|
return new HttpError(500, "INTERNAL_SERVER_ERROR", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unauthorized(message: string): HttpError {
|
||||||
|
return new HttpError(401, "UNAUTHORIZED", message);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// 统一成功响应结构,便于前端稳定读取 success/data 字段。
|
||||||
|
export function ok<T>(data: T) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 201 Created 通常用于 POST 请求,表示资源创建成功,并返回新资源的详细信息。
|
||||||
|
export function created<T>(data: T) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表接口统一返回 items + pagination,避免每个 controller 自己拼分页结构。
|
||||||
|
export function paginated<T>(
|
||||||
|
items: T[],
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
total: number,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / pageSize),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "Node16",
|
||||||
|
"moduleResolution": "Node16",
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user