From cd70caafbc9350e7cc9472a41512ec56ae2dd64f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=9B=E5=85=AE?= Date: Tue, 26 May 2026 11:06:13 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=AE=8C=E5=96=84=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E5=92=8C=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/readme-structure-sync/SKILL.md | 34 + .env.example | 9 + .gitignore | 7 + AGENTS.md | 1 + README.md | 469 +++++++++++ RTK.md | 15 + docker-compose.yml | 22 + migrations/001_initial_schema.sql | 68 ++ migrations/002_seed_demo_data.sql | 27 + package.json | 35 + pnpm-lock.yaml | 774 ++++++++++++++++++ src/app.ts | 99 +++ src/config/env.ts | 28 + src/db/migrate.ts | 62 ++ src/db/pool.ts | 31 + src/modules/catalog/catalog.controller.ts | 91 ++ src/modules/catalog/catalog.repository.ts | 361 ++++++++ src/modules/catalog/catalog.schema.ts | 81 ++ src/modules/catalog/catalog.service.ts | 151 ++++ src/modules/catalog/catalog.types.ts | 61 ++ src/modules/employees/employee.controller.ts | 60 ++ src/modules/employees/employee.repository.ts | 329 ++++++++ src/modules/employees/employee.schema.ts | 63 ++ src/modules/employees/employee.service.ts | 115 +++ src/modules/employees/employee.types.ts | 51 ++ src/server.ts | 35 + src/shared/http-error.ts | 34 + src/shared/response.ts | 36 + tsconfig.json | 16 + 29 files changed, 3165 insertions(+) create mode 100644 .agents/skills/readme-structure-sync/SKILL.md create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 RTK.md create mode 100644 docker-compose.yml create mode 100644 migrations/001_initial_schema.sql create mode 100644 migrations/002_seed_demo_data.sql create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 src/app.ts create mode 100644 src/config/env.ts create mode 100644 src/db/migrate.ts create mode 100644 src/db/pool.ts create mode 100644 src/modules/catalog/catalog.controller.ts create mode 100644 src/modules/catalog/catalog.repository.ts create mode 100644 src/modules/catalog/catalog.schema.ts create mode 100644 src/modules/catalog/catalog.service.ts create mode 100644 src/modules/catalog/catalog.types.ts create mode 100644 src/modules/employees/employee.controller.ts create mode 100644 src/modules/employees/employee.repository.ts create mode 100644 src/modules/employees/employee.schema.ts create mode 100644 src/modules/employees/employee.service.ts create mode 100644 src/modules/employees/employee.types.ts create mode 100644 src/server.ts create mode 100644 src/shared/http-error.ts create mode 100644 src/shared/response.ts create mode 100644 tsconfig.json diff --git a/.agents/skills/readme-structure-sync/SKILL.md b/.agents/skills/readme-structure-sync/SKILL.md new file mode 100644 index 0000000..8686b6b --- /dev/null +++ b/.agents/skills/readme-structure-sync/SKILL.md @@ -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. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..76d0592 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4996c7e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +.env +.env.* +!.env.example +.DS_Store +*.log diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..351259c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +@RTK.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..4b5e966 --- /dev/null +++ b/README.md @@ -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、索引、事务和参数绑定是如何工作的。 diff --git a/RTK.md b/RTK.md new file mode 100644 index 0000000..2ba19e5 --- /dev/null +++ b/RTK.md @@ -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. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7db60ab --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/migrations/001_initial_schema.sql b/migrations/001_initial_schema.sql new file mode 100644 index 0000000..080f6b3 --- /dev/null +++ b/migrations/001_initial_schema.sql @@ -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='员工角色关系表'; diff --git a/migrations/002_seed_demo_data.sql b/migrations/002_seed_demo_data.sql new file mode 100644 index 0000000..7c2f455 --- /dev/null +++ b/migrations/002_seed_demo_data.sql @@ -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); diff --git a/package.json b/package.json new file mode 100644 index 0000000..472e611 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..a2958f5 --- /dev/null +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..19957fc --- /dev/null +++ b/src/app.ts @@ -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; +} diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 0000000..260db11 --- /dev/null +++ b/src/config/env.ts @@ -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; diff --git a/src/db/migrate.ts b/src/db/migrate.ts new file mode 100644 index 0000000..e98122e --- /dev/null +++ b/src/db/migrate.ts @@ -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 { + 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); +}); diff --git a/src/db/pool.ts b/src/db/pool.ts new file mode 100644 index 0000000..8664595 --- /dev/null +++ b/src/db/pool.ts @@ -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 { + const connection = await pool.getConnection(); + try { + await connection.ping(); + } finally { + // getConnection 取出的连接必须 release,才能放回连接池继续复用。 + connection.release(); + } +} + +// closeDatabase 在服务停机时调用,确保所有连接都被正确关闭,避免资源泄漏。 +export async function closeDatabase(): Promise { + await pool.end(); +} diff --git a/src/modules/catalog/catalog.controller.ts b/src/modules/catalog/catalog.controller.ts new file mode 100644 index 0000000..fcfc7d1 --- /dev/null +++ b/src/modules/catalog/catalog.controller.ts @@ -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 { + 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(); + }); +} diff --git a/src/modules/catalog/catalog.repository.ts b/src/modules/catalog/catalog.repository.ts new file mode 100644 index 0000000..dddc66f --- /dev/null +++ b/src/modules/catalog/catalog.repository.ts @@ -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 { + // includeInactive=true 用于管理列表;默认只查启用且未软删除的门店。 + const where = query.includeInactive + ? "deleted_at IS NULL" + : "status = 'ACTIVE' AND deleted_at IS NULL"; + + const [rows] = await pool.execute( + ` + 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 { + const [rows] = await pool.execute( + ` + 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 { + const [rows] = await pool.execute( + ` + 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 { + const params: SqlParam[] = [name]; + let excludeSql = ""; + + if (excludeStoreId !== undefined) { + // 修改门店名称时需要排除自己,否则会误判为名称重复。 + excludeSql = " AND id <> ?"; + params.push(excludeStoreId); + } + + const [rows] = await pool.execute( + ` + 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 { + const [result] = await pool.execute( + ` + 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 { + // 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 { + // 门店使用软删除,保留历史数据;同时置为 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 { + const [rows] = await pool.execute( + ` + SELECT COUNT(*) AS total + FROM employees + WHERE store_id = ? AND deleted_at IS NULL + `, + [storeId], + ); + + return rows[0]?.total ?? 0; + }, + + async listRoles(): Promise { + const [rows] = await pool.execute( + ` + SELECT id, code, name, description, created_at, updated_at + FROM roles + ORDER BY id ASC + `, + ); + + return rows.map(toRole); + }, + + async listRoleOptions(): Promise { + const [rows] = await pool.execute( + ` + SELECT id, code, name, description, created_at, updated_at + FROM roles + ORDER BY id ASC + `, + ); + + return rows.map(toRoleOption); + }, + + async findRoleById(id: number): Promise { + const [rows] = await pool.execute( + ` + 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 { + const params: SqlParam[] = [code]; + let excludeSql = ""; + + if (excludeRoleId !== undefined) { + // 修改角色编码时排除当前角色,避免自己和自己冲突。 + excludeSql = " AND id <> ?"; + params.push(excludeRoleId); + } + + const [rows] = await pool.execute( + ` + 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 { + const [result] = await pool.execute( + ` + INSERT INTO roles (code, name, description) + VALUES (?, ?, ?) + `, + [input.code, input.name, input.description ?? null], + ); + + return result.insertId; + }, + + async updateRole(id: number, input: UpdateRoleInput): Promise { + // 和门店更新一样,角色 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 { + await pool.execute("DELETE FROM roles WHERE id = ?", [id]); + }, + + async countEmployeesByRole(roleId: number): Promise { + const [rows] = await pool.execute( + ` + SELECT COUNT(*) AS total + FROM employee_roles + WHERE role_id = ? + `, + [roleId], + ); + + return rows[0]?.total ?? 0; + }, +}; diff --git a/src/modules/catalog/catalog.schema.ts b/src/modules/catalog/catalog.schema.ts new file mode 100644 index 0000000..d557edd --- /dev/null +++ b/src/modules/catalog/catalog.schema.ts @@ -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: "至少需要提交一个要修改的字段", + }); diff --git a/src/modules/catalog/catalog.service.ts b/src/modules/catalog/catalog.service.ts new file mode 100644 index 0000000..ce5f345 --- /dev/null +++ b/src/modules/catalog/catalog.service.ts @@ -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 { + return catalogRepository.listStores(query); + }, + + async listActiveStoreOptions(): Promise { + return catalogRepository.listActiveStoreOptions(); + }, + + async getStoreById(id: number): Promise { + const store = await catalogRepository.findStoreById(id); + + if (!store) { + throw notFound("门店不存在"); + } + + return store; + }, + + async createStore(input: CreateStoreInput): Promise { + // 门店名称在“未删除门店”范围内保持唯一,避免管理后台出现两个同名门店。 + 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 { + 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 { + await this.getStoreById(id); + + // 有员工绑定时不允许删除门店,避免产生孤立员工。 + const employeeCount = await catalogRepository.countEmployeesByStore(id); + + if (employeeCount > 0) { + throw conflict("门店下还有员工,不能删除"); + } + + await catalogRepository.softDeleteStore(id); + }, + + async listRoles(): Promise { + return catalogRepository.listRoles(); + }, + + async listRoleOptions(): Promise { + return catalogRepository.listRoleOptions(); + }, + + async getRoleById(id: number): Promise { + const role = await catalogRepository.findRoleById(id); + + if (!role) { + throw notFound("角色不存在"); + } + + return role; + }, + + async createRole(input: CreateRoleInput): Promise { + // 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 { + 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 { + await this.getRoleById(id); + + // 角色被员工使用时不允许删除,避免员工详情里出现失效角色。 + const employeeCount = await catalogRepository.countEmployeesByRole(id); + + if (employeeCount > 0) { + throw conflict("角色已绑定员工,不能删除"); + } + + await catalogRepository.deleteRole(id); + }, +}; diff --git a/src/modules/catalog/catalog.types.ts b/src/modules/catalog/catalog.types.ts new file mode 100644 index 0000000..1ffeee9 --- /dev/null +++ b/src/modules/catalog/catalog.types.ts @@ -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; +} diff --git a/src/modules/employees/employee.controller.ts b/src/modules/employees/employee.controller.ts new file mode 100644 index 0000000..97ff467 --- /dev/null +++ b/src/modules/employees/employee.controller.ts @@ -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 { + 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(); + }); +} diff --git a/src/modules/employees/employee.repository.ts b/src/modules/employees/employee.repository.ts new file mode 100644 index 0000000..ca8d372 --- /dev/null +++ b/src/modules/employees/employee.repository.ts @@ -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(handler: (connection: PoolConnection) => Promise): Promise { + 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 { + // 员工只能绑定到启用且未删除的门店。 + const [rows] = await db.execute( + "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 { + 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 { + const params: SqlParam[] = [storeId, phone]; + let excludeSql = ""; + + if (excludeEmployeeId !== undefined) { + // 更新员工时排除当前员工,否则手机号不变也会被误判重复。 + excludeSql = " AND e.id <> ?"; + params.push(excludeEmployeeId); + } + + const [rows] = await db.execute( + ` + 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( + ` + 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( + ` + 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 { + const [rows] = await db.execute( + ` + 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 { + const [result] = await db.execute( + ` + 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, db: DbExecutor = pool): Promise { + // 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 { + // 员工删除采用软删除:保留历史记录,同时将状态置为 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 { + // 简化多对多更新:先删旧关系,再批量插入新关系。调用方用事务包住它。 + 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> { + const result = new Map(); + + if (employeeIds.length === 0) { + return result; + } + + const placeholders = employeeIds.map(() => "?").join(", "); + // 一次性查出多个员工的角色,避免列表接口为每个员工单独查询产生 N+1 问题。 + const [rows] = await db.execute( + ` + 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; + } +}; diff --git a/src/modules/employees/employee.schema.ts b/src/modules/employees/employee.schema.ts new file mode 100644 index 0000000..469ae23 --- /dev/null +++ b/src/modules/employees/employee.schema.ts @@ -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) +}); diff --git a/src/modules/employees/employee.service.ts b/src/modules/employees/employee.service.ts new file mode 100644 index 0000000..8e2947e --- /dev/null +++ b/src/modules/employees/employee.service.ts @@ -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 { + const exists = await employeeRepository.storeExists(storeId); + + if (!exists) { + throw badRequest("门店不存在或已停用"); + } +} + +// 提交的角色必须全部存在;只要有一个不存在,就拒绝整个请求。 +async function assertRolesExist(roleIds: number[]): Promise { + 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 { + const employee = await employeeRepository.findById(id); + + if (!employee) { + throw notFound("员工不存在"); + } + + return employee; + }, + + async create(input: CreateEmployeeInput): Promise { + 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 { + 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 { + await this.getById(id); + // 这里只改变状态,不影响角色、手机号和门店关系。 + await employeeRepository.update(id, { status }); + return this.getById(id); + }, + + async delete(id: number): Promise { + await this.getById(id); + // 软删除保留历史数据,也能配合 active_phone 释放手机号唯一约束。 + await employeeRepository.softDelete(id); + } +}; diff --git a/src/modules/employees/employee.types.ts b/src/modules/employees/employee.types.ts new file mode 100644 index 0000000..f94c4cc --- /dev/null +++ b/src/modules/employees/employee.types.ts @@ -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[]; +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..c7173b9 --- /dev/null +++ b/src/server.ts @@ -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 { + 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); +}); diff --git a/src/shared/http-error.ts b/src/shared/http-error.ts new file mode 100644 index 0000000..0907876 --- /dev/null +++ b/src/shared/http-error.ts @@ -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); +} diff --git a/src/shared/response.ts b/src/shared/response.ts new file mode 100644 index 0000000..a124af2 --- /dev/null +++ b/src/shared/response.ts @@ -0,0 +1,36 @@ +// 统一成功响应结构,便于前端稳定读取 success/data 字段。 +export function ok(data: T) { + return { + success: true, + data, + }; +} + +// 201 Created 通常用于 POST 请求,表示资源创建成功,并返回新资源的详细信息。 +export function created(data: T) { + return { + success: true, + data, + }; +} + +// 列表接口统一返回 items + pagination,避免每个 controller 自己拼分页结构。 +export function paginated( + items: T[], + page: number, + pageSize: number, + total: number, +) { + return { + success: true, + data: { + items, + pagination: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + }, + }, + }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..dd26fa5 --- /dev/null +++ b/tsconfig.json @@ -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"] +}