docs: 完善项目说明和注释

This commit is contained in:
湛兮
2026-05-26 11:06:13 +08:00
commit cd70caafbc
29 changed files with 3165 additions and 0 deletions
@@ -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.
+9
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
.env
.env.*
!.env.example
.DS_Store
*.log
+1
View File
@@ -0,0 +1 @@
@RTK.md
+469
View File
@@ -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、索引、事务和参数绑定是如何工作的。
+15
View File
@@ -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.
+22
View File
@@ -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
+68
View File
@@ -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='员工角色关系表';
+27
View File
@@ -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);
+35
View File
@@ -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"
}
}
+774
View File
@@ -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
View File
@@ -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;
}
+28
View File
@@ -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;
+62
View File
@@ -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);
});
+31
View File
@@ -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();
}
+91
View File
@@ -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();
});
}
+361
View File
@@ -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;
},
};
+81
View File
@@ -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: "至少需要提交一个要修改的字段",
});
+151
View File
@@ -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 只管 SQLcontroller 只管 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);
},
};
+61
View File
@@ -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;
}
};
+63
View File
@@ -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)
});
+115
View File
@@ -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);
}
};
+51
View File
@@ -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[];
}
+35
View File
@@ -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);
});
+34
View File
@@ -0,0 +1,34 @@
// 统一的业务错误类型。
// service 层抛出 HttpErrorapp.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);
}
+36
View File
@@ -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),
},
},
};
}
+16
View File
@@ -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"]
}