feat: 增加登录鉴权和固定角色权限
This commit is contained in:
@@ -11,14 +11,19 @@
|
|||||||
- MySQL 8.4
|
- MySQL 8.4
|
||||||
- mysql2
|
- mysql2
|
||||||
- zod
|
- zod
|
||||||
|
- @fastify/jwt
|
||||||
- Docker Compose
|
- Docker Compose
|
||||||
|
|
||||||
## 项目能力
|
## 项目能力
|
||||||
|
|
||||||
- 门店管理:查询、新增、修改、软删除门店。
|
- 门店管理:查询、新增、修改、软删除门店。
|
||||||
- 角色管理:查询、新增、修改、删除角色。
|
- 角色管理:查询服务端固定角色,用于员工权限分配。
|
||||||
- 员工管理:分页查询、新增、修改、启用/停用、软删除员工。
|
- 员工管理:分页查询、新增、修改、启用/停用、软删除员工。
|
||||||
- 员工角色:一个员工可以绑定多个角色。
|
- 员工角色:一个员工可以绑定多个角色。
|
||||||
|
- 登录账号:超级管理员和员工都可以登录。
|
||||||
|
- 后台权限:超级管理员拥有所有权限;员工只有绑定 `admin` 角色时才能访问后台管理接口。
|
||||||
|
- 固定角色:店长、收银员、后厨、兼职、管理员是服务端固定角色,不提供角色新增、修改、删除接口。
|
||||||
|
- JWT 鉴权:登录后签发 token,除健康检查和登录外,接口都需要 Bearer token。
|
||||||
- 数据校验:使用 zod 校验路径参数、查询参数和请求体。
|
- 数据校验:使用 zod 校验路径参数、查询参数和请求体。
|
||||||
- 数据库迁移:使用 `migrations/*.sql` 管理建表和初始化数据。
|
- 数据库迁移:使用 `migrations/*.sql` 管理建表和初始化数据。
|
||||||
- 事务处理:创建/更新员工和角色绑定时使用事务,避免部分成功。
|
- 事务处理:创建/更新员工和角色绑定时使用事务,避免部分成功。
|
||||||
@@ -30,13 +35,16 @@
|
|||||||
├── .agents/
|
├── .agents/
|
||||||
│ └── skills/
|
│ └── skills/
|
||||||
│ └── readme-structure-sync/ # README 和目录结构同步维护规则
|
│ └── readme-structure-sync/ # README 和目录结构同步维护规则
|
||||||
├── .env.development # 本地开发环境变量文件,不提交到仓库
|
├── .env.development # 默认本地开发环境变量文件,不提交到仓库
|
||||||
|
├── .env.local / .env.production # 可选环境变量文件,不提交到仓库
|
||||||
├── .gitignore # Git 忽略规则,排除本地配置、依赖和编译产物
|
├── .gitignore # Git 忽略规则,排除本地配置、依赖和编译产物
|
||||||
├── AGENTS.md # Codex/Agent 入口指令,当前指向 RTK.md
|
├── AGENTS.md # Codex/Agent 入口指令,当前指向 RTK.md
|
||||||
├── RTK.md # 项目协作规则和开发约定
|
├── RTK.md # 项目协作规则和开发约定
|
||||||
├── migrations/ # 数据库迁移 SQL
|
├── migrations/ # 数据库迁移 SQL
|
||||||
│ ├── 001_initial_schema.sql # 创建基础表结构
|
│ ├── 001_initial_schema.sql # 创建基础表结构
|
||||||
│ └── 002_seed_demo_data.sql # 初始化演示门店和角色
|
│ ├── 002_seed_demo_data.sql # 初始化演示门店和角色
|
||||||
|
│ ├── 003_create_super_admins.sql # 创建超级管理员表和默认账号
|
||||||
|
│ └── 004_add_employee_login_fields.sql # 给员工补充登录字段
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── app.ts # 创建 Fastify 应用、注册路由、统一错误处理
|
│ ├── app.ts # 创建 Fastify 应用、注册路由、统一错误处理
|
||||||
│ ├── server.ts # 启动 HTTP 服务和优雅停机
|
│ ├── server.ts # 启动 HTTP 服务和优雅停机
|
||||||
@@ -46,6 +54,7 @@
|
|||||||
│ │ ├── migrate.ts # 执行 migrations 目录下的 SQL
|
│ │ ├── migrate.ts # 执行 migrations 目录下的 SQL
|
||||||
│ │ └── pool.ts # MySQL 连接池
|
│ │ └── pool.ts # MySQL 连接池
|
||||||
│ ├── modules/
|
│ ├── modules/
|
||||||
|
│ │ ├── auth/ # 登录、当前用户和 JWT 鉴权模块
|
||||||
│ │ ├── catalog/ # 门店和角色模块
|
│ │ ├── catalog/ # 门店和角色模块
|
||||||
│ │ └── employees/ # 员工 CRUD 模块
|
│ │ └── employees/ # 员工 CRUD 模块
|
||||||
│ └── shared/ # 通用响应结构和业务错误
|
│ └── shared/ # 通用响应结构和业务错误
|
||||||
@@ -61,7 +70,8 @@
|
|||||||
| 路径 | 作用 |
|
| 路径 | 作用 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `.agents/skills/readme-structure-sync/` | 项目内 skill。约定当目录、重要文件或 `package.json` 脚本变化时,同步更新 README。 |
|
| `.agents/skills/readme-structure-sync/` | 项目内 skill。约定当目录、重要文件或 `package.json` 脚本变化时,同步更新 README。 |
|
||||||
| `.env.development` | 当前项目本地开发使用的环境变量文件,`package.json` 脚本会显式读取它;该文件只保留在本机,不提交到仓库。 |
|
| `.env.development` | 当前 `package.json` 脚本默认读取的本地开发环境变量文件;该文件只保留在本机,不提交到仓库。 |
|
||||||
|
| `.env.local` / `.env.production` | 本机已有的其他环境变量文件;代码已允许 `NODE_ENV=local` 和 `NODE_ENV=production`,切换脚本时可以复用。 |
|
||||||
| `.gitignore` | 忽略本地环境变量、依赖目录、编译产物和系统文件,避免把无关文件推到仓库。 |
|
| `.gitignore` | 忽略本地环境变量、依赖目录、编译产物和系统文件,避免把无关文件推到仓库。 |
|
||||||
| `AGENTS.md` | Agent 工具读取的入口文件,当前通过 `@RTK.md` 引入项目规则。 |
|
| `AGENTS.md` | Agent 工具读取的入口文件,当前通过 `@RTK.md` 引入项目规则。 |
|
||||||
| `RTK.md` | 本项目的协作规则,例如使用中文说明、保持分层、改目录时同步 README。 |
|
| `RTK.md` | 本项目的协作规则,例如使用中文说明、保持分层、改目录时同步 README。 |
|
||||||
@@ -71,6 +81,7 @@
|
|||||||
| `src/config/env.ts` | 使用 zod 校验 `.env.development` 中的环境变量,避免配置错误拖到请求阶段才暴露。 |
|
| `src/config/env.ts` | 使用 zod 校验 `.env.development` 中的环境变量,避免配置错误拖到请求阶段才暴露。 |
|
||||||
| `src/db/migrate.ts` | 执行 `migrations/*.sql`,并用 `schema_migrations` 记录已执行迁移。 |
|
| `src/db/migrate.ts` | 执行 `migrations/*.sql`,并用 `schema_migrations` 记录已执行迁移。 |
|
||||||
| `src/db/pool.ts` | 创建 MySQL 连接池,提供数据库健康检查和关闭连接的方法。 |
|
| `src/db/pool.ts` | 创建 MySQL 连接池,提供数据库健康检查和关闭连接的方法。 |
|
||||||
|
| `src/modules/auth/` | 登录鉴权模块,负责超级管理员和员工登录、密码校验、JWT 签发、当前用户查询和后台权限 guard。 |
|
||||||
| `src/modules/catalog/` | 门店和角色模块,负责基础资料接口。 |
|
| `src/modules/catalog/` | 门店和角色模块,负责基础资料接口。 |
|
||||||
| `src/modules/employees/` | 员工模块,负责员工分页、详情、新增、修改、状态变更和软删除。 |
|
| `src/modules/employees/` | 员工模块,负责员工分页、详情、新增、修改、状态变更和软删除。 |
|
||||||
| `src/shared/` | 跨模块复用的响应结构和业务错误类型。 |
|
| `src/shared/` | 跨模块复用的响应结构和业务错误类型。 |
|
||||||
@@ -105,7 +116,7 @@
|
|||||||
pnpm install
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
本地开发直接使用现有的 `.env.development`,当前配置如下:
|
本地开发默认使用现有的 `.env.development`,当前需要这些变量:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
@@ -117,8 +128,13 @@ DB_USER=access_user
|
|||||||
DB_PASSWORD=access_pass
|
DB_PASSWORD=access_pass
|
||||||
DB_NAME=access_manage
|
DB_NAME=access_manage
|
||||||
DB_CONNECTION_LIMIT=10
|
DB_CONNECTION_LIMIT=10
|
||||||
|
|
||||||
|
JWT_SECRET=请使用至少 32 位的随机字符串
|
||||||
|
JWT_EXPIRES_IN=2h
|
||||||
```
|
```
|
||||||
|
|
||||||
|
代码允许的 `NODE_ENV` 值是:`local`、`development`、`test`、`production`。如果改用 `.env.local` 或 `.env.production` 启动,也需要包含同样的 `JWT_SECRET` 和 `JWT_EXPIRES_IN`。
|
||||||
|
|
||||||
## 启动步骤
|
## 启动步骤
|
||||||
|
|
||||||
1. 启动 MySQL:
|
1. 启动 MySQL:
|
||||||
@@ -141,6 +157,7 @@ pnpm db:migrate
|
|||||||
- `roles`:角色表
|
- `roles`:角色表
|
||||||
- `employees`:员工表
|
- `employees`:员工表
|
||||||
- `employee_roles`:员工角色关系表
|
- `employee_roles`:员工角色关系表
|
||||||
|
- `super_admins`:超级管理员表
|
||||||
- `schema_migrations`:迁移记录表
|
- `schema_migrations`:迁移记录表
|
||||||
|
|
||||||
3. 启动后端:
|
3. 启动后端:
|
||||||
@@ -238,24 +255,83 @@ pnpm dev
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 登录和鉴权
|
||||||
|
|
||||||
|
本项目有两类可登录账号:
|
||||||
|
|
||||||
|
- 超级管理员:拥有所有后台管理权限。
|
||||||
|
- 员工:都可以登录;只有绑定 `admin` 角色的员工才能访问后台管理接口。
|
||||||
|
|
||||||
|
默认本地超级管理员账号由 [003_create_super_admins.sql](./migrations/003_create_super_admins.sql) 初始化:
|
||||||
|
|
||||||
|
```text
|
||||||
|
账号:admin
|
||||||
|
密码:Admin@123456
|
||||||
|
```
|
||||||
|
|
||||||
|
员工登录字段由 [004_add_employee_login_fields.sql](./migrations/004_add_employee_login_fields.sql) 初始化。已有员工和新建员工默认密码是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
账号:员工手机号
|
||||||
|
密码:Employee@123456
|
||||||
|
```
|
||||||
|
|
||||||
|
登录获取 token:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3500/api/auth/login \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "Admin@123456"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
响应里的 `data.token` 就是后续接口要使用的 JWT。
|
||||||
|
响应里的 `data.user.canManage` 表示当前账号是否能访问后台管理接口。
|
||||||
|
|
||||||
|
为了方便测试,可以先把 token 保存成 shell 变量:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN="把登录响应里的 data.token 粘贴到这里"
|
||||||
|
```
|
||||||
|
|
||||||
|
获取当前登录用户:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3500/api/auth/me \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
除 `/health` 和 `/api/auth/login` 外,当前接口都需要带上:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
如果员工账号没有 `admin` 角色,可以登录并访问 `/api/auth/me`,但访问门店、角色、员工等后台管理接口会返回 `403 FORBIDDEN`。
|
||||||
|
|
||||||
## 门店接口示例
|
## 门店接口示例
|
||||||
|
|
||||||
查询门店选项:
|
查询门店选项:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:3500/api/stores
|
curl http://localhost:3500/api/stores \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
```
|
```
|
||||||
|
|
||||||
查询包含停用门店的列表:
|
查询包含停用门店的列表:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl 'http://localhost:3500/api/stores?includeInactive=true'
|
curl 'http://localhost:3500/api/stores?includeInactive=true' \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
```
|
```
|
||||||
|
|
||||||
新增门店:
|
新增门店:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:3500/api/stores \
|
curl -X POST http://localhost:3500/api/stores \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{
|
-d '{
|
||||||
"name": "人民广场店",
|
"name": "人民广场店",
|
||||||
@@ -269,6 +345,7 @@ curl -X POST http://localhost:3500/api/stores \
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X PATCH http://localhost:3500/api/stores/1 \
|
curl -X PATCH http://localhost:3500/api/stores/1 \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{
|
-d '{
|
||||||
"phone": "021-87654321"
|
"phone": "021-87654321"
|
||||||
@@ -278,55 +355,30 @@ curl -X PATCH http://localhost:3500/api/stores/1 \
|
|||||||
删除门店:
|
删除门店:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X DELETE http://localhost:3500/api/stores/1
|
curl -X DELETE http://localhost:3500/api/stores/1 \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
```
|
```
|
||||||
|
|
||||||
门店下还有员工时,不能停用或删除门店。
|
门店下还有员工时,不能停用或删除门店。
|
||||||
|
|
||||||
## 角色接口示例
|
## 角色接口示例
|
||||||
|
|
||||||
|
角色是服务端固定权限集合,只允许查询,不允许通过接口新增、修改或删除。
|
||||||
|
|
||||||
查询角色:
|
查询角色:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:3500/api/roles
|
curl http://localhost:3500/api/roles \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
```
|
```
|
||||||
|
|
||||||
新增角色:
|
|
||||||
|
|
||||||
```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
|
```bash
|
||||||
curl -X POST http://localhost:3500/api/employees \
|
curl -X POST http://localhost:3500/api/employees \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{
|
-d '{
|
||||||
"storeId": 1,
|
"storeId": 1,
|
||||||
@@ -340,31 +392,36 @@ curl -X POST http://localhost:3500/api/employees \
|
|||||||
查询员工列表:
|
查询员工列表:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl 'http://localhost:3500/api/employees?page=1&pageSize=20'
|
curl 'http://localhost:3500/api/employees?page=1&pageSize=20' \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
```
|
```
|
||||||
|
|
||||||
按门店和状态筛选:
|
按门店和状态筛选:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl 'http://localhost:3500/api/employees?storeId=1&status=ACTIVE&page=1&pageSize=20'
|
curl 'http://localhost:3500/api/employees?storeId=1&status=ACTIVE&page=1&pageSize=20' \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
```
|
```
|
||||||
|
|
||||||
按姓名或手机号搜索:
|
按姓名或手机号搜索:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl 'http://localhost:3500/api/employees?keyword=张三'
|
curl 'http://localhost:3500/api/employees?keyword=张三' \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
```
|
```
|
||||||
|
|
||||||
查询员工详情:
|
查询员工详情:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:3500/api/employees/1
|
curl http://localhost:3500/api/employees/1 \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
```
|
```
|
||||||
|
|
||||||
修改员工:
|
修改员工:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X PATCH http://localhost:3500/api/employees/1 \
|
curl -X PATCH http://localhost:3500/api/employees/1 \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{
|
-d '{
|
||||||
"name": "张三丰",
|
"name": "张三丰",
|
||||||
@@ -376,6 +433,7 @@ curl -X PATCH http://localhost:3500/api/employees/1 \
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X PATCH http://localhost:3500/api/employees/1/status \
|
curl -X PATCH http://localhost:3500/api/employees/1/status \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"status": "INACTIVE"}'
|
-d '{"status": "INACTIVE"}'
|
||||||
```
|
```
|
||||||
@@ -383,7 +441,8 @@ curl -X PATCH http://localhost:3500/api/employees/1/status \
|
|||||||
软删除员工:
|
软删除员工:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X DELETE http://localhost:3500/api/employees/1
|
curl -X DELETE http://localhost:3500/api/employees/1 \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
```
|
```
|
||||||
|
|
||||||
同一个门店下,未删除员工的手机号不能重复。员工软删除后,可以重新录入相同手机号。
|
同一个门店下,未删除员工的手机号不能重复。员工软删除后,可以重新录入相同手机号。
|
||||||
@@ -396,6 +455,8 @@ curl -X DELETE http://localhost:3500/api/employees/1
|
|||||||
|
|
||||||
- [001_initial_schema.sql](./migrations/001_initial_schema.sql):创建门店、角色、员工、员工角色关系表。
|
- [001_initial_schema.sql](./migrations/001_initial_schema.sql):创建门店、角色、员工、员工角色关系表。
|
||||||
- [002_seed_demo_data.sql](./migrations/002_seed_demo_data.sql):写入一个示例门店和几个常见角色。
|
- [002_seed_demo_data.sql](./migrations/002_seed_demo_data.sql):写入一个示例门店和几个常见角色。
|
||||||
|
- [003_create_super_admins.sql](./migrations/003_create_super_admins.sql):创建超级管理员表,并初始化本地登录账号。
|
||||||
|
- [004_add_employee_login_fields.sql](./migrations/004_add_employee_login_fields.sql):给员工补充登录密码哈希和最后登录时间。
|
||||||
|
|
||||||
执行 `pnpm db:migrate` 时,脚本会:
|
执行 `pnpm db:migrate` 时,脚本会:
|
||||||
|
|
||||||
@@ -416,10 +477,14 @@ migrations/003_add_employee_email.sql
|
|||||||
|
|
||||||
- `stores.deleted_at` 和 `employees.deleted_at` 用于软删除。
|
- `stores.deleted_at` 和 `employees.deleted_at` 用于软删除。
|
||||||
- `employees.active_phone` 是生成列,用来实现“同一门店未删除员工手机号唯一”。
|
- `employees.active_phone` 是生成列,用来实现“同一门店未删除员工手机号唯一”。
|
||||||
|
- `employees.password_hash` 让员工也能登录,默认本地密码是 `Employee@123456`。
|
||||||
- `employee_roles` 是多对多关系表。
|
- `employee_roles` 是多对多关系表。
|
||||||
|
- `super_admins` 保存超级管理员账号,密码使用 PBKDF2 哈希,禁止存明文。
|
||||||
|
- 角色定义由服务端固定,`admin` 角色用于判断员工是否能访问后台管理接口。
|
||||||
|
- JWT 鉴权在 `src/modules/auth/` 中实现,`managementGuard` 统一保护后台管理接口。
|
||||||
- `repository` 使用参数化查询,避免 SQL 注入。
|
- `repository` 使用参数化查询,避免 SQL 注入。
|
||||||
- `service` 使用事务保证员工信息和角色绑定同时成功或同时失败。
|
- `service` 使用事务保证员工信息和角色绑定同时成功或同时失败。
|
||||||
- `app.ts` 统一处理 zod 校验错误、业务错误和数据库唯一索引冲突。
|
- `app.ts` 统一注册 JWT、业务路由和错误处理,并处理 zod 校验错误、业务错误和数据库唯一索引冲突。
|
||||||
|
|
||||||
## README 维护规则
|
## README 维护规则
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- 003_create_super_admins.sql
|
||||||
|
-- 这个迁移文件新增超级管理员表,用于后台登录和后续所有管理接口的 JWT 鉴权。
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS super_admins (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
username VARCHAR(50) NOT NULL COMMENT '登录账号',
|
||||||
|
password_hash VARCHAR(255) NOT NULL COMMENT '密码哈希,禁止存储明文密码',
|
||||||
|
display_name VARCHAR(50) NOT NULL COMMENT '展示名称',
|
||||||
|
status ENUM('ACTIVE', 'INACTIVE') NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态',
|
||||||
|
last_login_at DATETIME(3) NULL COMMENT '最后登录时间',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_super_admins_username (username),
|
||||||
|
KEY idx_super_admins_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='超级管理员表';
|
||||||
|
|
||||||
|
-- 初始化一个本地开发用超级管理员账号。
|
||||||
|
-- 账号:admin
|
||||||
|
-- 密码:Admin@123456
|
||||||
|
-- password_hash 使用 PBKDF2-SHA256 生成,登录校验逻辑在 src/modules/auth/password.ts。
|
||||||
|
INSERT INTO super_admins (id, username, password_hash, display_name, status)
|
||||||
|
VALUES (
|
||||||
|
1,
|
||||||
|
'admin',
|
||||||
|
'pbkdf2$sha256$310000$vXBBypwmLVKkGa1Og8Z3SQ$yAV40TqZjrzx1-xg27ucQqL3Hc5HQ44t-sarPwpjLUA',
|
||||||
|
'超级管理员',
|
||||||
|
'ACTIVE'
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
display_name = VALUES(display_name),
|
||||||
|
status = VALUES(status);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- 004_add_employee_login_fields.sql
|
||||||
|
-- 员工也可以登录系统,因此在 employees 表上补充密码哈希和最后登录时间。
|
||||||
|
|
||||||
|
ALTER TABLE employees
|
||||||
|
ADD COLUMN password_hash VARCHAR(255) NULL COMMENT '员工登录密码哈希,禁止存储明文密码' AFTER phone,
|
||||||
|
ADD COLUMN last_login_at DATETIME(3) NULL COMMENT '员工最后登录时间' AFTER deleted_at;
|
||||||
|
|
||||||
|
-- 给已有员工设置本地开发默认密码。
|
||||||
|
-- 默认密码:Employee@123456
|
||||||
|
-- 生产环境应在真实上线前改成独立密码或补充重置密码流程。
|
||||||
|
UPDATE employees
|
||||||
|
SET password_hash = 'pbkdf2$sha256$310000$Vd5Mh3XgZPZ4ozECQzmviA$YnzG8OAqy9bZE9ZmA2yT1RpUl0bbC0yA9LpYUO8LltQ'
|
||||||
|
WHERE password_hash IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE employees
|
||||||
|
MODIFY password_hash VARCHAR(255) NOT NULL DEFAULT 'pbkdf2$sha256$310000$Vd5Mh3XgZPZ4ozECQzmviA$YnzG8OAqy9bZE9ZmA2yT1RpUl0bbC0yA9LpYUO8LltQ' COMMENT '员工登录密码哈希,禁止存储明文密码';
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fastify/jwt": "^10.1.0",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
"mysql2": "^3.22.3",
|
"mysql2": "^3.22.3",
|
||||||
|
|||||||
Generated
+127
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@fastify/jwt':
|
||||||
|
specifier: ^10.1.0
|
||||||
|
version: 10.1.0
|
||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^17.4.2
|
specifier: ^17.4.2
|
||||||
version: 17.4.2
|
version: 17.4.2
|
||||||
@@ -201,12 +204,19 @@ packages:
|
|||||||
'@fastify/forwarded@3.0.1':
|
'@fastify/forwarded@3.0.1':
|
||||||
resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==}
|
resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==}
|
||||||
|
|
||||||
|
'@fastify/jwt@10.1.0':
|
||||||
|
resolution: {integrity: sha512-U1y8ZbxoH1Pjon3euzPJmbCkuYBM+hrQlFWLQWvKmJGCNT6mVsAolnVJdEWfXeQOKpgmuRVCIsPll5RLZxj10A==}
|
||||||
|
|
||||||
'@fastify/merge-json-schemas@0.2.1':
|
'@fastify/merge-json-schemas@0.2.1':
|
||||||
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
|
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
|
||||||
|
|
||||||
'@fastify/proxy-addr@5.1.0':
|
'@fastify/proxy-addr@5.1.0':
|
||||||
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
|
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
|
||||||
|
|
||||||
|
'@lukeed/ms@2.0.2':
|
||||||
|
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
'@pinojs/redact@0.4.0':
|
'@pinojs/redact@0.4.0':
|
||||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||||
|
|
||||||
@@ -227,6 +237,9 @@ packages:
|
|||||||
ajv@8.20.0:
|
ajv@8.20.0:
|
||||||
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
|
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
|
||||||
|
|
||||||
|
asn1.js@5.4.1:
|
||||||
|
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
|
||||||
|
|
||||||
atomic-sleep@1.0.0:
|
atomic-sleep@1.0.0:
|
||||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||||
engines: {node: '>=8.0.0'}
|
engines: {node: '>=8.0.0'}
|
||||||
@@ -238,6 +251,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
|
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
|
||||||
engines: {node: '>= 6.0.0'}
|
engines: {node: '>= 6.0.0'}
|
||||||
|
|
||||||
|
bn.js@4.12.3:
|
||||||
|
resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==}
|
||||||
|
|
||||||
cookie@1.1.1:
|
cookie@1.1.1:
|
||||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -254,6 +270,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
|
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||||
|
|
||||||
esbuild@0.28.0:
|
esbuild@0.28.0:
|
||||||
resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==}
|
resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -268,18 +287,35 @@ packages:
|
|||||||
fast-json-stringify@6.4.0:
|
fast-json-stringify@6.4.0:
|
||||||
resolution: {integrity: sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==}
|
resolution: {integrity: sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==}
|
||||||
|
|
||||||
|
fast-jwt@6.2.4:
|
||||||
|
resolution: {integrity: sha512-IoQa53wI6TbARU2yelb0L44ggFQnP2qVcwswCSYHbCAWuwpr70icDb3QjG0v01I8Tt01rVGDkN/rRvpk0lKFTA==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
fast-querystring@1.1.2:
|
fast-querystring@1.1.2:
|
||||||
resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
|
resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
|
||||||
|
|
||||||
fast-uri@3.1.2:
|
fast-uri@3.1.2:
|
||||||
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
|
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
|
||||||
|
|
||||||
|
fastfall@1.5.1:
|
||||||
|
resolution: {integrity: sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
fastify-plugin@5.1.0:
|
||||||
|
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
|
||||||
|
|
||||||
fastify@5.8.5:
|
fastify@5.8.5:
|
||||||
resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==}
|
resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==}
|
||||||
|
|
||||||
|
fastparallel@2.4.1:
|
||||||
|
resolution: {integrity: sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==}
|
||||||
|
|
||||||
fastq@1.20.1:
|
fastq@1.20.1:
|
||||||
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||||
|
|
||||||
|
fastseries@1.7.2:
|
||||||
|
resolution: {integrity: sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==}
|
||||||
|
|
||||||
find-my-way@9.6.0:
|
find-my-way@9.6.0:
|
||||||
resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==}
|
resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -296,6 +332,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
inherits@2.0.4:
|
||||||
|
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||||
|
|
||||||
ipaddr.js@2.4.0:
|
ipaddr.js@2.4.0:
|
||||||
resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==}
|
resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
@@ -319,6 +358,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==}
|
resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==}
|
||||||
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
|
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
|
||||||
|
|
||||||
|
minimalistic-assert@1.0.1:
|
||||||
|
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
|
||||||
|
|
||||||
|
mnemonist@0.40.4:
|
||||||
|
resolution: {integrity: sha512-ZAv+KNavneRVzu4tUeOgzkScI3W5BGwZ3rkxIpKtzzVgfTtWQFN1CgX0U72cyvyh3iTuHL3SiSmrQxTlryEIcw==}
|
||||||
|
|
||||||
mysql2@3.22.3:
|
mysql2@3.22.3:
|
||||||
resolution: {integrity: sha512-uWWxvZSRvRhtBdh2CdcuK83YcOfPdmEeEYB069bAmPnV93QApDGVPuvCQOLjlh7tYHEWdgQPrn6kosDxHBVLkA==}
|
resolution: {integrity: sha512-uWWxvZSRvRhtBdh2CdcuK83YcOfPdmEeEYB069bAmPnV93QApDGVPuvCQOLjlh7tYHEWdgQPrn6kosDxHBVLkA==}
|
||||||
engines: {node: '>= 8.0'}
|
engines: {node: '>= 8.0'}
|
||||||
@@ -329,6 +374,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==}
|
resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==}
|
||||||
engines: {node: '>=8.0.0'}
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
|
obliterator@2.0.5:
|
||||||
|
resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==}
|
||||||
|
|
||||||
on-exit-leak-free@2.1.2:
|
on-exit-leak-free@2.1.2:
|
||||||
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
@@ -374,6 +422,9 @@ packages:
|
|||||||
rfdc@1.4.1:
|
rfdc@1.4.1:
|
||||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||||
|
|
||||||
|
safe-buffer@5.2.1:
|
||||||
|
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||||
|
|
||||||
safe-regex2@5.1.1:
|
safe-regex2@5.1.1:
|
||||||
resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==}
|
resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -407,6 +458,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==}
|
resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==}
|
||||||
engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'}
|
engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'}
|
||||||
|
|
||||||
|
steed@1.1.3:
|
||||||
|
resolution: {integrity: sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==}
|
||||||
|
|
||||||
thread-stream@4.2.0:
|
thread-stream@4.2.0:
|
||||||
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
|
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -428,6 +482,10 @@ packages:
|
|||||||
undici-types@7.24.6:
|
undici-types@7.24.6:
|
||||||
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
|
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
|
||||||
|
|
||||||
|
xtend@4.0.2:
|
||||||
|
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||||
|
engines: {node: '>=0.4'}
|
||||||
|
|
||||||
zod@4.4.3:
|
zod@4.4.3:
|
||||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||||
|
|
||||||
@@ -525,6 +583,14 @@ snapshots:
|
|||||||
|
|
||||||
'@fastify/forwarded@3.0.1': {}
|
'@fastify/forwarded@3.0.1': {}
|
||||||
|
|
||||||
|
'@fastify/jwt@10.1.0':
|
||||||
|
dependencies:
|
||||||
|
'@fastify/error': 4.2.0
|
||||||
|
'@lukeed/ms': 2.0.2
|
||||||
|
fast-jwt: 6.2.4
|
||||||
|
fastify-plugin: 5.1.0
|
||||||
|
steed: 1.1.3
|
||||||
|
|
||||||
'@fastify/merge-json-schemas@0.2.1':
|
'@fastify/merge-json-schemas@0.2.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
dequal: 2.0.3
|
dequal: 2.0.3
|
||||||
@@ -534,6 +600,8 @@ snapshots:
|
|||||||
'@fastify/forwarded': 3.0.1
|
'@fastify/forwarded': 3.0.1
|
||||||
ipaddr.js: 2.4.0
|
ipaddr.js: 2.4.0
|
||||||
|
|
||||||
|
'@lukeed/ms@2.0.2': {}
|
||||||
|
|
||||||
'@pinojs/redact@0.4.0': {}
|
'@pinojs/redact@0.4.0': {}
|
||||||
|
|
||||||
'@types/node@25.9.1':
|
'@types/node@25.9.1':
|
||||||
@@ -553,6 +621,13 @@ snapshots:
|
|||||||
json-schema-traverse: 1.0.0
|
json-schema-traverse: 1.0.0
|
||||||
require-from-string: 2.0.2
|
require-from-string: 2.0.2
|
||||||
|
|
||||||
|
asn1.js@5.4.1:
|
||||||
|
dependencies:
|
||||||
|
bn.js: 4.12.3
|
||||||
|
inherits: 2.0.4
|
||||||
|
minimalistic-assert: 1.0.1
|
||||||
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
atomic-sleep@1.0.0: {}
|
atomic-sleep@1.0.0: {}
|
||||||
|
|
||||||
avvio@9.2.0:
|
avvio@9.2.0:
|
||||||
@@ -562,6 +637,8 @@ snapshots:
|
|||||||
|
|
||||||
aws-ssl-profiles@1.1.2: {}
|
aws-ssl-profiles@1.1.2: {}
|
||||||
|
|
||||||
|
bn.js@4.12.3: {}
|
||||||
|
|
||||||
cookie@1.1.1: {}
|
cookie@1.1.1: {}
|
||||||
|
|
||||||
denque@2.1.0: {}
|
denque@2.1.0: {}
|
||||||
@@ -570,6 +647,10 @@ snapshots:
|
|||||||
|
|
||||||
dotenv@17.4.2: {}
|
dotenv@17.4.2: {}
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
esbuild@0.28.0:
|
esbuild@0.28.0:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@esbuild/aix-ppc64': 0.28.0
|
'@esbuild/aix-ppc64': 0.28.0
|
||||||
@@ -612,12 +693,26 @@ snapshots:
|
|||||||
json-schema-ref-resolver: 3.0.0
|
json-schema-ref-resolver: 3.0.0
|
||||||
rfdc: 1.4.1
|
rfdc: 1.4.1
|
||||||
|
|
||||||
|
fast-jwt@6.2.4:
|
||||||
|
dependencies:
|
||||||
|
'@lukeed/ms': 2.0.2
|
||||||
|
asn1.js: 5.4.1
|
||||||
|
ecdsa-sig-formatter: 1.0.11
|
||||||
|
mnemonist: 0.40.4
|
||||||
|
safe-regex2: 5.1.1
|
||||||
|
|
||||||
fast-querystring@1.1.2:
|
fast-querystring@1.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-decode-uri-component: 1.0.1
|
fast-decode-uri-component: 1.0.1
|
||||||
|
|
||||||
fast-uri@3.1.2: {}
|
fast-uri@3.1.2: {}
|
||||||
|
|
||||||
|
fastfall@1.5.1:
|
||||||
|
dependencies:
|
||||||
|
reusify: 1.1.0
|
||||||
|
|
||||||
|
fastify-plugin@5.1.0: {}
|
||||||
|
|
||||||
fastify@5.8.5:
|
fastify@5.8.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fastify/ajv-compiler': 4.0.5
|
'@fastify/ajv-compiler': 4.0.5
|
||||||
@@ -636,10 +731,20 @@ snapshots:
|
|||||||
semver: 7.8.1
|
semver: 7.8.1
|
||||||
toad-cache: 3.7.1
|
toad-cache: 3.7.1
|
||||||
|
|
||||||
|
fastparallel@2.4.1:
|
||||||
|
dependencies:
|
||||||
|
reusify: 1.1.0
|
||||||
|
xtend: 4.0.2
|
||||||
|
|
||||||
fastq@1.20.1:
|
fastq@1.20.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
reusify: 1.1.0
|
reusify: 1.1.0
|
||||||
|
|
||||||
|
fastseries@1.7.2:
|
||||||
|
dependencies:
|
||||||
|
reusify: 1.1.0
|
||||||
|
xtend: 4.0.2
|
||||||
|
|
||||||
find-my-way@9.6.0:
|
find-my-way@9.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-deep-equal: 3.1.3
|
fast-deep-equal: 3.1.3
|
||||||
@@ -657,6 +762,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
|
inherits@2.0.4: {}
|
||||||
|
|
||||||
ipaddr.js@2.4.0: {}
|
ipaddr.js@2.4.0: {}
|
||||||
|
|
||||||
is-property@1.0.2: {}
|
is-property@1.0.2: {}
|
||||||
@@ -677,6 +784,12 @@ snapshots:
|
|||||||
|
|
||||||
lru.min@1.1.4: {}
|
lru.min@1.1.4: {}
|
||||||
|
|
||||||
|
minimalistic-assert@1.0.1: {}
|
||||||
|
|
||||||
|
mnemonist@0.40.4:
|
||||||
|
dependencies:
|
||||||
|
obliterator: 2.0.5
|
||||||
|
|
||||||
mysql2@3.22.3(@types/node@25.9.1):
|
mysql2@3.22.3(@types/node@25.9.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 25.9.1
|
'@types/node': 25.9.1
|
||||||
@@ -693,6 +806,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
lru.min: 1.1.4
|
lru.min: 1.1.4
|
||||||
|
|
||||||
|
obliterator@2.0.5: {}
|
||||||
|
|
||||||
on-exit-leak-free@2.1.2: {}
|
on-exit-leak-free@2.1.2: {}
|
||||||
|
|
||||||
pino-abstract-transport@3.0.0:
|
pino-abstract-transport@3.0.0:
|
||||||
@@ -733,6 +848,8 @@ snapshots:
|
|||||||
|
|
||||||
rfdc@1.4.1: {}
|
rfdc@1.4.1: {}
|
||||||
|
|
||||||
|
safe-buffer@5.2.1: {}
|
||||||
|
|
||||||
safe-regex2@5.1.1:
|
safe-regex2@5.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
ret: 0.5.0
|
ret: 0.5.0
|
||||||
@@ -755,6 +872,14 @@ snapshots:
|
|||||||
|
|
||||||
sql-escaper@1.3.3: {}
|
sql-escaper@1.3.3: {}
|
||||||
|
|
||||||
|
steed@1.1.3:
|
||||||
|
dependencies:
|
||||||
|
fastfall: 1.5.1
|
||||||
|
fastparallel: 2.4.1
|
||||||
|
fastq: 1.20.1
|
||||||
|
fastseries: 1.7.2
|
||||||
|
reusify: 1.1.0
|
||||||
|
|
||||||
thread-stream@4.2.0:
|
thread-stream@4.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
real-require: 1.0.0
|
real-require: 1.0.0
|
||||||
@@ -771,4 +896,6 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@7.24.6: {}
|
undici-types@7.24.6: {}
|
||||||
|
|
||||||
|
xtend@4.0.2: {}
|
||||||
|
|
||||||
zod@4.4.3: {}
|
zod@4.4.3: {}
|
||||||
|
|||||||
+24
-4
@@ -1,6 +1,10 @@
|
|||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
|
import fastifyJwt from "@fastify/jwt";
|
||||||
import { ZodError } from "zod";
|
import { ZodError } from "zod";
|
||||||
|
import { env } from "./config/env";
|
||||||
import { pingDatabase } from "./db/pool";
|
import { pingDatabase } from "./db/pool";
|
||||||
|
import { authRoutes } from "./modules/auth/auth.controller";
|
||||||
|
import { managementGuard } from "./modules/auth/auth.guard";
|
||||||
import { catalogRoutes } from "./modules/catalog/catalog.controller";
|
import { catalogRoutes } from "./modules/catalog/catalog.controller";
|
||||||
import { employeeRoutes } from "./modules/employees/employee.controller";
|
import { employeeRoutes } from "./modules/employees/employee.controller";
|
||||||
import { HttpError } from "./shared/http-error";
|
import { HttpError } from "./shared/http-error";
|
||||||
@@ -31,6 +35,14 @@ export function createApp() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 注册 JWT 能力。登录接口负责签发 token,受保护接口通过 authGuard 校验 token。
|
||||||
|
app.register(fastifyJwt, {
|
||||||
|
secret: env.JWT_SECRET,
|
||||||
|
sign: {
|
||||||
|
expiresIn: env.JWT_EXPIRES_IN,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// 健康检查接口,供负载均衡器和监控系统使用。
|
// 健康检查接口,供负载均衡器和监控系统使用。
|
||||||
app.get("/health", async () => {
|
app.get("/health", async () => {
|
||||||
await pingDatabase();
|
await pingDatabase();
|
||||||
@@ -42,10 +54,18 @@ export function createApp() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 注册业务路由,所有接口都以 /api 开头,便于区分静态资源和 API 请求。
|
// 登录接口不需要 token;/auth/me 在 authRoutes 内部单独加了 authGuard。
|
||||||
app.register(catalogRoutes, { prefix: "/api" });
|
app.register(authRoutes, { prefix: "/api" });
|
||||||
// 员工管理相关接口,包含员工的增删改查和状态更新等功能。
|
|
||||||
app.register(employeeRoutes, { prefix: "/api" });
|
// 业务管理接口统一要求后台权限:超级管理员或拥有 admin 角色的员工。
|
||||||
|
app.register(
|
||||||
|
async (protectedApp) => {
|
||||||
|
protectedApp.addHook("preHandler", managementGuard);
|
||||||
|
protectedApp.register(catalogRoutes);
|
||||||
|
protectedApp.register(employeeRoutes);
|
||||||
|
},
|
||||||
|
{ prefix: "/api" },
|
||||||
|
);
|
||||||
|
|
||||||
// 全局错误处理器,捕获所有未处理的异常,并根据错误类型返回合适的 HTTP 状态码和错误信息。
|
// 全局错误处理器,捕获所有未处理的异常,并根据错误类型返回合适的 HTTP 状态码和错误信息。
|
||||||
app.setErrorHandler((error, request, reply) => {
|
app.setErrorHandler((error, request, reply) => {
|
||||||
|
|||||||
+7
-2
@@ -4,7 +4,9 @@ import { z } from "zod";
|
|||||||
// 所有运行时配置都从环境变量读取,并在启动时一次性校验。
|
// 所有运行时配置都从环境变量读取,并在启动时一次性校验。
|
||||||
// 这样数据库密码、端口等配置错误会在服务启动阶段暴露,而不是等到请求进来才失败。
|
// 这样数据库密码、端口等配置错误会在服务启动阶段暴露,而不是等到请求进来才失败。
|
||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
NODE_ENV: z
|
||||||
|
.enum(["local", "development", "test", "production"])
|
||||||
|
.default("development"),
|
||||||
PORT: z.coerce.number().int().positive().default(3000),
|
PORT: z.coerce.number().int().positive().default(3000),
|
||||||
|
|
||||||
DB_HOST: z.string().min(1),
|
DB_HOST: z.string().min(1),
|
||||||
@@ -12,7 +14,10 @@ const envSchema = z.object({
|
|||||||
DB_USER: z.string().min(1),
|
DB_USER: z.string().min(1),
|
||||||
DB_PASSWORD: z.string().min(1),
|
DB_PASSWORD: z.string().min(1),
|
||||||
DB_NAME: z.string().min(1),
|
DB_NAME: z.string().min(1),
|
||||||
DB_CONNECTION_LIMIT: z.coerce.number().int().positive().default(10)
|
DB_CONNECTION_LIMIT: z.coerce.number().int().positive().default(10),
|
||||||
|
|
||||||
|
JWT_SECRET: z.string().min(32),
|
||||||
|
JWT_EXPIRES_IN: z.string().min(1).default("2h")
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = envSchema.safeParse(process.env);
|
const result = envSchema.safeParse(process.env);
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { env } from "../../config/env";
|
||||||
|
import { ok } from "../../shared/response";
|
||||||
|
import { authGuard } from "./auth.guard";
|
||||||
|
import { loginBodySchema } from "./auth.schema";
|
||||||
|
import { authService } from "./auth.service";
|
||||||
|
|
||||||
|
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.post("/auth/login", async (request) => {
|
||||||
|
const body = loginBodySchema.parse(request.body);
|
||||||
|
const { user, payload } = await authService.login(body);
|
||||||
|
const token = app.jwt.sign(payload);
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
token,
|
||||||
|
tokenType: "Bearer",
|
||||||
|
expiresIn: env.JWT_EXPIRES_IN,
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/auth/me", { preHandler: authGuard }, async (request) => {
|
||||||
|
const user = await authService.getCurrentUser(request.user);
|
||||||
|
|
||||||
|
return ok(user);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { FastifyRequest } from "fastify";
|
||||||
|
import { forbidden, unauthorized } from "../../shared/http-error";
|
||||||
|
import { authService } from "./auth.service";
|
||||||
|
|
||||||
|
// 统一 JWT 鉴权入口。后续新增需要登录的路由,复用这个 guard 即可。
|
||||||
|
export async function authGuard(request: FastifyRequest): Promise<void> {
|
||||||
|
const authorization = request.headers.authorization;
|
||||||
|
|
||||||
|
if (!authorization?.startsWith("Bearer ")) {
|
||||||
|
throw unauthorized("请先登录");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await request.jwtVerify();
|
||||||
|
} catch {
|
||||||
|
throw unauthorized("登录已过期,请重新登录");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后台管理系统只允许超级管理员和拥有 admin 角色的员工访问。
|
||||||
|
export async function managementGuard(request: FastifyRequest): Promise<void> {
|
||||||
|
await authGuard(request);
|
||||||
|
|
||||||
|
const user = await authService.getCurrentUser(request.user);
|
||||||
|
|
||||||
|
if (!user.canManage) {
|
||||||
|
throw forbidden("当前账号没有后台管理权限");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import type { RowDataPacket } from "mysql2/promise";
|
||||||
|
import { pool } from "../../db/pool";
|
||||||
|
import type {
|
||||||
|
EmployeeLoginAccount,
|
||||||
|
SuperAdminStatus,
|
||||||
|
SuperAdminWithPassword,
|
||||||
|
} from "./auth.types";
|
||||||
|
|
||||||
|
interface SuperAdminRow extends RowDataPacket {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
password_hash: string;
|
||||||
|
display_name: string;
|
||||||
|
status: SuperAdminStatus;
|
||||||
|
last_login_at: Date | null;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmployeeLoginRow extends RowDataPacket {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
password_hash: string;
|
||||||
|
display_name: string;
|
||||||
|
store_id: number;
|
||||||
|
store_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmployeeRoleRow extends RowDataPacket {
|
||||||
|
employee_id: number;
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIso(value: Date | null): string | null {
|
||||||
|
return value ? value.toISOString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSuperAdmin(row: SuperAdminRow): SuperAdminWithPassword {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
passwordHash: row.password_hash,
|
||||||
|
displayName: row.display_name,
|
||||||
|
status: row.status,
|
||||||
|
lastLoginAt: toIso(row.last_login_at),
|
||||||
|
createdAt: toIso(row.created_at) ?? "",
|
||||||
|
updatedAt: toIso(row.updated_at) ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authRepository = {
|
||||||
|
async findActiveByUsername(
|
||||||
|
username: string,
|
||||||
|
): Promise<SuperAdminWithPassword | null> {
|
||||||
|
const [rows] = await pool.execute<SuperAdminRow[]>(
|
||||||
|
`
|
||||||
|
SELECT id, username, password_hash, display_name, status, last_login_at, created_at, updated_at
|
||||||
|
FROM super_admins
|
||||||
|
WHERE username = ? AND status = 'ACTIVE'
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[username],
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows[0] ? toSuperAdmin(rows[0]) : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async findActiveById(id: number): Promise<SuperAdminWithPassword | null> {
|
||||||
|
const [rows] = await pool.execute<SuperAdminRow[]>(
|
||||||
|
`
|
||||||
|
SELECT id, username, password_hash, display_name, status, last_login_at, created_at, updated_at
|
||||||
|
FROM super_admins
|
||||||
|
WHERE id = ? AND status = 'ACTIVE'
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows[0] ? toSuperAdmin(rows[0]) : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateLastLoginAt(id: number): Promise<void> {
|
||||||
|
await pool.execute(
|
||||||
|
`
|
||||||
|
UPDATE super_admins
|
||||||
|
SET last_login_at = CURRENT_TIMESTAMP(3)
|
||||||
|
WHERE id = ?
|
||||||
|
`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async findActiveEmployeeByPhone(
|
||||||
|
phone: string,
|
||||||
|
): Promise<EmployeeLoginAccount | null> {
|
||||||
|
const [rows] = await pool.execute<EmployeeLoginRow[]>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
e.id,
|
||||||
|
e.phone AS username,
|
||||||
|
e.password_hash,
|
||||||
|
e.name AS display_name,
|
||||||
|
e.store_id,
|
||||||
|
s.name AS store_name
|
||||||
|
FROM employees e
|
||||||
|
INNER JOIN stores s ON s.id = e.store_id
|
||||||
|
WHERE e.phone = ?
|
||||||
|
AND e.status = 'ACTIVE'
|
||||||
|
AND e.deleted_at IS NULL
|
||||||
|
AND s.status = 'ACTIVE'
|
||||||
|
AND s.deleted_at IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[phone],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows[0]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rolesByEmployeeId = await this.findRolesByEmployeeIds([rows[0].id]);
|
||||||
|
return toEmployeeLoginAccount(
|
||||||
|
rows[0],
|
||||||
|
rolesByEmployeeId.get(rows[0].id) ?? [],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async findActiveEmployeeById(
|
||||||
|
id: number,
|
||||||
|
): Promise<EmployeeLoginAccount | null> {
|
||||||
|
const [rows] = await pool.execute<EmployeeLoginRow[]>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
e.id,
|
||||||
|
e.phone AS username,
|
||||||
|
e.password_hash,
|
||||||
|
e.name AS display_name,
|
||||||
|
e.store_id,
|
||||||
|
s.name AS store_name
|
||||||
|
FROM employees e
|
||||||
|
INNER JOIN stores s ON s.id = e.store_id
|
||||||
|
WHERE e.id = ?
|
||||||
|
AND e.status = 'ACTIVE'
|
||||||
|
AND e.deleted_at IS NULL
|
||||||
|
AND s.status = 'ACTIVE'
|
||||||
|
AND s.deleted_at IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows[0]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rolesByEmployeeId = await this.findRolesByEmployeeIds([rows[0].id]);
|
||||||
|
return toEmployeeLoginAccount(
|
||||||
|
rows[0],
|
||||||
|
rolesByEmployeeId.get(rows[0].id) ?? [],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateEmployeeLastLoginAt(id: number): Promise<void> {
|
||||||
|
await pool.execute(
|
||||||
|
`
|
||||||
|
UPDATE employees
|
||||||
|
SET last_login_at = CURRENT_TIMESTAMP(3)
|
||||||
|
WHERE id = ?
|
||||||
|
`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async findRolesByEmployeeIds(
|
||||||
|
employeeIds: number[],
|
||||||
|
): Promise<Map<number, EmployeeLoginAccount["roles"]>> {
|
||||||
|
const result = new Map<number, EmployeeLoginAccount["roles"]>();
|
||||||
|
|
||||||
|
if (employeeIds.length === 0) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholders = employeeIds.map(() => "?").join(", ");
|
||||||
|
const [rows] = await pool.execute<EmployeeRoleRow[]>(
|
||||||
|
`
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function toEmployeeLoginAccount(
|
||||||
|
row: EmployeeLoginRow,
|
||||||
|
roles: EmployeeLoginAccount["roles"],
|
||||||
|
): EmployeeLoginAccount {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
passwordHash: row.password_hash,
|
||||||
|
displayName: row.display_name,
|
||||||
|
storeId: row.store_id,
|
||||||
|
storeName: row.store_name,
|
||||||
|
roles,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const loginBodySchema = z.object({
|
||||||
|
username: z.string().trim().min(1).max(50),
|
||||||
|
password: z.string().min(8).max(128),
|
||||||
|
});
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { unauthorized } from "../../shared/http-error";
|
||||||
|
import { authRepository } from "./auth.repository";
|
||||||
|
import type {
|
||||||
|
AuthJwtPayload,
|
||||||
|
AuthUser,
|
||||||
|
EmployeeLoginAccount,
|
||||||
|
LoginInput,
|
||||||
|
SuperAdmin,
|
||||||
|
} from "./auth.types";
|
||||||
|
import { verifyPassword } from "./password";
|
||||||
|
|
||||||
|
function toAuthUser(admin: SuperAdmin): AuthUser {
|
||||||
|
return {
|
||||||
|
id: admin.id,
|
||||||
|
username: admin.username,
|
||||||
|
displayName: admin.displayName,
|
||||||
|
accountType: "SUPER_ADMIN",
|
||||||
|
roles: [
|
||||||
|
{
|
||||||
|
id: 0,
|
||||||
|
code: "super_admin",
|
||||||
|
name: "超级管理员",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
permissions: ["*"],
|
||||||
|
canManage: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEmployeeAuthUser(employee: EmployeeLoginAccount): AuthUser {
|
||||||
|
const canManage = employee.roles.some((role) => role.code === "admin");
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: employee.id,
|
||||||
|
username: employee.username,
|
||||||
|
displayName: employee.displayName,
|
||||||
|
accountType: "EMPLOYEE",
|
||||||
|
storeId: employee.storeId,
|
||||||
|
storeName: employee.storeName,
|
||||||
|
roles: employee.roles,
|
||||||
|
permissions: canManage ? ["*"] : [],
|
||||||
|
canManage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toJwtPayload(user: AuthUser): AuthJwtPayload {
|
||||||
|
const subjectPrefix =
|
||||||
|
user.accountType === "SUPER_ADMIN" ? "super_admin" : "employee";
|
||||||
|
|
||||||
|
return {
|
||||||
|
sub: `${subjectPrefix}:${user.id}`,
|
||||||
|
accountType: user.accountType,
|
||||||
|
adminId: user.accountType === "SUPER_ADMIN" ? user.id : undefined,
|
||||||
|
employeeId: user.accountType === "EMPLOYEE" ? user.id : undefined,
|
||||||
|
username: user.username,
|
||||||
|
roleCodes: user.roles.map((role) => role.code),
|
||||||
|
permissions: user.permissions,
|
||||||
|
canManage: user.canManage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authService = {
|
||||||
|
async login(input: LoginInput): Promise<{
|
||||||
|
user: AuthUser;
|
||||||
|
payload: AuthJwtPayload;
|
||||||
|
}> {
|
||||||
|
const admin = await authRepository.findActiveByUsername(input.username);
|
||||||
|
|
||||||
|
if (admin) {
|
||||||
|
const passwordMatched = await verifyPassword(
|
||||||
|
input.password,
|
||||||
|
admin.passwordHash,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!passwordMatched) {
|
||||||
|
throw unauthorized("用户名或密码错误");
|
||||||
|
}
|
||||||
|
|
||||||
|
await authRepository.updateLastLoginAt(admin.id);
|
||||||
|
|
||||||
|
const user = toAuthUser(admin);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
payload: toJwtPayload(user),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const employee = await authRepository.findActiveEmployeeByPhone(
|
||||||
|
input.username,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!employee) {
|
||||||
|
throw unauthorized("用户名或密码错误");
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordMatched = await verifyPassword(
|
||||||
|
input.password,
|
||||||
|
employee.passwordHash,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!passwordMatched) {
|
||||||
|
throw unauthorized("用户名或密码错误");
|
||||||
|
}
|
||||||
|
|
||||||
|
await authRepository.updateEmployeeLastLoginAt(employee.id);
|
||||||
|
|
||||||
|
const user = toEmployeeAuthUser(employee);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
payload: toJwtPayload(user),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCurrentUser(payload: AuthJwtPayload): Promise<AuthUser> {
|
||||||
|
if (payload.accountType === "SUPER_ADMIN" && payload.adminId) {
|
||||||
|
const admin = await authRepository.findActiveById(payload.adminId);
|
||||||
|
|
||||||
|
if (!admin) {
|
||||||
|
throw unauthorized("登录已失效,请重新登录");
|
||||||
|
}
|
||||||
|
|
||||||
|
return toAuthUser(admin);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.accountType === "EMPLOYEE" && payload.employeeId) {
|
||||||
|
const employee = await authRepository.findActiveEmployeeById(
|
||||||
|
payload.employeeId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!employee) {
|
||||||
|
throw unauthorized("登录已失效,请重新登录");
|
||||||
|
}
|
||||||
|
|
||||||
|
return toEmployeeAuthUser(employee);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw unauthorized("登录已失效,请重新登录");
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
export const SUPER_ADMIN_STATUS = ["ACTIVE", "INACTIVE"] as const;
|
||||||
|
|
||||||
|
export type SuperAdminStatus = (typeof SUPER_ADMIN_STATUS)[number];
|
||||||
|
export type AuthAccountType = "SUPER_ADMIN" | "EMPLOYEE";
|
||||||
|
|
||||||
|
export interface LoginInput {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuperAdmin {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
status: SuperAdminStatus;
|
||||||
|
lastLoginAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuperAdminWithPassword extends SuperAdmin {
|
||||||
|
passwordHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmployeeLoginAccount {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
passwordHash: string;
|
||||||
|
displayName: string;
|
||||||
|
storeId: number;
|
||||||
|
storeName: string;
|
||||||
|
roles: Array<{
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
accountType: AuthAccountType;
|
||||||
|
storeId?: number;
|
||||||
|
storeName?: string;
|
||||||
|
roles: Array<{
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
permissions: string[];
|
||||||
|
canManage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthJwtPayload {
|
||||||
|
sub: string;
|
||||||
|
accountType: AuthAccountType;
|
||||||
|
adminId?: number;
|
||||||
|
employeeId?: number;
|
||||||
|
username: string;
|
||||||
|
roleCodes: string[];
|
||||||
|
permissions: string[];
|
||||||
|
canManage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@fastify/jwt" {
|
||||||
|
interface FastifyJWT {
|
||||||
|
payload: AuthJwtPayload;
|
||||||
|
user: AuthJwtPayload;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { pbkdf2, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const pbkdf2Async = promisify(pbkdf2);
|
||||||
|
const HASH_SCHEME = "pbkdf2";
|
||||||
|
const HASH_ALGORITHM = "sha256";
|
||||||
|
const ITERATIONS = 310000;
|
||||||
|
const KEY_LENGTH = 32;
|
||||||
|
|
||||||
|
function toBase64Url(buffer: Buffer): string {
|
||||||
|
return buffer.toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromBase64Url(value: string): Buffer {
|
||||||
|
return Buffer.from(value, "base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 密码哈希格式:pbkdf2$sha256$310000$salt$hash。
|
||||||
|
// 把算法、迭代次数和 salt 都放进字符串,后续升级算法时也能兼容旧密码。
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
const salt = randomBytes(16);
|
||||||
|
const derivedKey = (await pbkdf2Async(
|
||||||
|
password,
|
||||||
|
salt,
|
||||||
|
ITERATIONS,
|
||||||
|
KEY_LENGTH,
|
||||||
|
HASH_ALGORITHM,
|
||||||
|
)) as Buffer;
|
||||||
|
|
||||||
|
return [
|
||||||
|
HASH_SCHEME,
|
||||||
|
HASH_ALGORITHM,
|
||||||
|
String(ITERATIONS),
|
||||||
|
toBase64Url(salt),
|
||||||
|
toBase64Url(derivedKey),
|
||||||
|
].join("$");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(
|
||||||
|
password: string,
|
||||||
|
storedHash: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const [scheme, algorithm, iterationsText, saltText, hashText] =
|
||||||
|
storedHash.split("$");
|
||||||
|
|
||||||
|
if (
|
||||||
|
scheme !== HASH_SCHEME ||
|
||||||
|
algorithm !== HASH_ALGORITHM ||
|
||||||
|
!iterationsText ||
|
||||||
|
!saltText ||
|
||||||
|
!hashText
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iterations = Number(iterationsText);
|
||||||
|
|
||||||
|
if (!Number.isInteger(iterations) || iterations <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const salt = fromBase64Url(saltText);
|
||||||
|
const expectedHash = fromBase64Url(hashText);
|
||||||
|
const derivedKey = (await pbkdf2Async(
|
||||||
|
password,
|
||||||
|
salt,
|
||||||
|
iterations,
|
||||||
|
expectedHash.length,
|
||||||
|
algorithm,
|
||||||
|
)) as Buffer;
|
||||||
|
|
||||||
|
return (
|
||||||
|
expectedHash.length === derivedKey.length &&
|
||||||
|
timingSafeEqual(expectedHash, derivedKey)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,11 +2,9 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import { created, ok } from "../../shared/response";
|
import { created, ok } from "../../shared/response";
|
||||||
import { catalogService } from "./catalog.service";
|
import { catalogService } from "./catalog.service";
|
||||||
import {
|
import {
|
||||||
createRoleBodySchema,
|
|
||||||
createStoreBodySchema,
|
createStoreBodySchema,
|
||||||
idParamSchema,
|
idParamSchema,
|
||||||
listStoresQuerySchema,
|
listStoresQuerySchema,
|
||||||
updateRoleBodySchema,
|
|
||||||
updateStoreBodySchema,
|
updateStoreBodySchema,
|
||||||
} from "./catalog.schema";
|
} from "./catalog.schema";
|
||||||
|
|
||||||
@@ -66,26 +64,5 @@ export async function catalogRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return ok(role);
|
return ok(role);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/roles", async (request, reply) => {
|
// roles 是服务端固定权限集合,只允许查询,不提供新增、修改、删除接口。
|
||||||
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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ResultSetHeader, RowDataPacket } from "mysql2/promise";
|
import type { ResultSetHeader, RowDataPacket } from "mysql2/promise";
|
||||||
import { pool } from "../../db/pool";
|
import { pool } from "../../db/pool";
|
||||||
|
import { FIXED_ROLE_CODES } from "./catalog.types";
|
||||||
import type {
|
import type {
|
||||||
CreateRoleInput,
|
|
||||||
CreateStoreInput,
|
CreateStoreInput,
|
||||||
ListStoresQuery,
|
ListStoresQuery,
|
||||||
Role,
|
Role,
|
||||||
@@ -9,11 +9,11 @@ import type {
|
|||||||
Store,
|
Store,
|
||||||
StoreOption,
|
StoreOption,
|
||||||
StoreStatus,
|
StoreStatus,
|
||||||
UpdateRoleInput,
|
|
||||||
UpdateStoreInput,
|
UpdateStoreInput,
|
||||||
} from "./catalog.types";
|
} from "./catalog.types";
|
||||||
|
|
||||||
type SqlParam = string | number | boolean | Date | null;
|
type SqlParam = string | number | boolean | Date | null;
|
||||||
|
const fixedRoleCodePlaceholders = FIXED_ROLE_CODES.map(() => "?").join(", ");
|
||||||
|
|
||||||
interface StoreRow extends RowDataPacket {
|
interface StoreRow extends RowDataPacket {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -236,8 +236,10 @@ export const catalogRepository = {
|
|||||||
`
|
`
|
||||||
SELECT id, code, name, description, created_at, updated_at
|
SELECT id, code, name, description, created_at, updated_at
|
||||||
FROM roles
|
FROM roles
|
||||||
|
WHERE code IN (${fixedRoleCodePlaceholders})
|
||||||
ORDER BY id ASC
|
ORDER BY id ASC
|
||||||
`,
|
`,
|
||||||
|
[...FIXED_ROLE_CODES],
|
||||||
);
|
);
|
||||||
|
|
||||||
return rows.map(toRole);
|
return rows.map(toRole);
|
||||||
@@ -248,8 +250,10 @@ export const catalogRepository = {
|
|||||||
`
|
`
|
||||||
SELECT id, code, name, description, created_at, updated_at
|
SELECT id, code, name, description, created_at, updated_at
|
||||||
FROM roles
|
FROM roles
|
||||||
|
WHERE code IN (${fixedRoleCodePlaceholders})
|
||||||
ORDER BY id ASC
|
ORDER BY id ASC
|
||||||
`,
|
`,
|
||||||
|
[...FIXED_ROLE_CODES],
|
||||||
);
|
);
|
||||||
|
|
||||||
return rows.map(toRoleOption);
|
return rows.map(toRoleOption);
|
||||||
@@ -260,102 +264,13 @@ export const catalogRepository = {
|
|||||||
`
|
`
|
||||||
SELECT id, code, name, description, created_at, updated_at
|
SELECT id, code, name, description, created_at, updated_at
|
||||||
FROM roles
|
FROM roles
|
||||||
WHERE id = ?
|
WHERE id = ? AND code IN (${fixedRoleCodePlaceholders})
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`,
|
`,
|
||||||
[id],
|
[id, ...FIXED_ROLE_CODES],
|
||||||
);
|
);
|
||||||
|
|
||||||
return rows[0] ? toRole(rows[0]) : null;
|
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;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,24 +58,3 @@ export const updateStoreBodySchema = z
|
|||||||
.refine((value) => Object.keys(value).length > 0, {
|
.refine((value) => Object.keys(value).length > 0, {
|
||||||
message: "至少需要提交一个要修改的字段",
|
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: "至少需要提交一个要修改的字段",
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { conflict, notFound } from "../../shared/http-error";
|
import { conflict, notFound } from "../../shared/http-error";
|
||||||
import { catalogRepository } from "./catalog.repository";
|
import { catalogRepository } from "./catalog.repository";
|
||||||
import type {
|
import type {
|
||||||
CreateRoleInput,
|
|
||||||
CreateStoreInput,
|
CreateStoreInput,
|
||||||
ListStoresQuery,
|
ListStoresQuery,
|
||||||
Role,
|
Role,
|
||||||
RoleOption,
|
RoleOption,
|
||||||
Store,
|
Store,
|
||||||
StoreOption,
|
StoreOption,
|
||||||
UpdateRoleInput,
|
|
||||||
UpdateStoreInput,
|
UpdateStoreInput,
|
||||||
} from "./catalog.types";
|
} from "./catalog.types";
|
||||||
|
|
||||||
@@ -106,46 +104,5 @@ export const catalogService = {
|
|||||||
return role;
|
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);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,38 @@
|
|||||||
export const STORE_STATUS = ["ACTIVE", "INACTIVE"] as const;
|
export const STORE_STATUS = ["ACTIVE", "INACTIVE"] as const;
|
||||||
|
|
||||||
|
export const FIXED_ROLE_DEFINITIONS = [
|
||||||
|
{
|
||||||
|
code: "store_manager",
|
||||||
|
name: "店长",
|
||||||
|
description: "负责门店日常管理、排班和权限审批",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "cashier",
|
||||||
|
name: "收银员",
|
||||||
|
description: "负责收银、订单核对和基础会员操作",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "kitchen",
|
||||||
|
name: "后厨",
|
||||||
|
description: "负责出品、备货和库存相关操作",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "part_time",
|
||||||
|
name: "兼职",
|
||||||
|
description: "临时员工,默认只开放基础操作",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "admin",
|
||||||
|
name: "管理员",
|
||||||
|
description: "系统管理角色,仅授予可信人员",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const FIXED_ROLE_CODES = FIXED_ROLE_DEFINITIONS.map((role) => role.code);
|
||||||
|
|
||||||
// 从常量数组推导联合类型,避免状态枚举在 schema 和类型定义里写两遍。
|
// 从常量数组推导联合类型,避免状态枚举在 schema 和类型定义里写两遍。
|
||||||
export type StoreStatus = (typeof STORE_STATUS)[number];
|
export type StoreStatus = (typeof STORE_STATUS)[number];
|
||||||
|
export type FixedRoleCode = (typeof FIXED_ROLE_DEFINITIONS)[number]["code"];
|
||||||
|
|
||||||
// Option 类型用于下拉框等轻量接口,只返回页面选择所需字段。
|
// Option 类型用于下拉框等轻量接口,只返回页面选择所需字段。
|
||||||
export interface StoreOption {
|
export interface StoreOption {
|
||||||
@@ -47,15 +78,3 @@ export interface UpdateStoreInput {
|
|||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
status?: StoreStatus;
|
status?: StoreStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateRoleInput {
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateRoleInput {
|
|
||||||
code?: string;
|
|
||||||
name?: string;
|
|
||||||
description?: string | null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PoolConnection, ResultSetHeader, RowDataPacket } from "mysql2/promise";
|
import type { PoolConnection, ResultSetHeader, RowDataPacket } from "mysql2/promise";
|
||||||
import { pool } from "../../db/pool";
|
import { pool } from "../../db/pool";
|
||||||
|
import { FIXED_ROLE_CODES } from "../catalog/catalog.types";
|
||||||
import type {
|
import type {
|
||||||
CreateEmployeeInput,
|
CreateEmployeeInput,
|
||||||
Employee,
|
Employee,
|
||||||
@@ -11,6 +12,9 @@ import type {
|
|||||||
|
|
||||||
type DbExecutor = typeof pool | PoolConnection;
|
type DbExecutor = typeof pool | PoolConnection;
|
||||||
type SqlParam = string | number | boolean | Date | null;
|
type SqlParam = string | number | boolean | Date | null;
|
||||||
|
const fixedRoleCodePlaceholders = FIXED_ROLE_CODES.map(() => "?").join(", ");
|
||||||
|
const DEFAULT_EMPLOYEE_PASSWORD_HASH =
|
||||||
|
"pbkdf2$sha256$310000$Vd5Mh3XgZPZ4ozECQzmviA$YnzG8OAqy9bZE9ZmA2yT1RpUl0bbC0yA9LpYUO8LltQ";
|
||||||
|
|
||||||
interface EmployeeRow extends RowDataPacket {
|
interface EmployeeRow extends RowDataPacket {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -118,8 +122,13 @@ export const employeeRepository = {
|
|||||||
// IN 条件的占位符数量必须和 roleIds 长度一致,仍然使用参数化查询避免 SQL 注入。
|
// IN 条件的占位符数量必须和 roleIds 长度一致,仍然使用参数化查询避免 SQL 注入。
|
||||||
const placeholders = roleIds.map(() => "?").join(", ");
|
const placeholders = roleIds.map(() => "?").join(", ");
|
||||||
const [rows] = await db.execute<(RowDataPacket & { id: number })[]>(
|
const [rows] = await db.execute<(RowDataPacket & { id: number })[]>(
|
||||||
`SELECT id FROM roles WHERE id IN (${placeholders})`,
|
`
|
||||||
roleIds
|
SELECT id
|
||||||
|
FROM roles
|
||||||
|
WHERE id IN (${placeholders})
|
||||||
|
AND code IN (${fixedRoleCodePlaceholders})
|
||||||
|
`,
|
||||||
|
[...roleIds, ...FIXED_ROLE_CODES]
|
||||||
);
|
);
|
||||||
|
|
||||||
return rows.map((row) => row.id);
|
return rows.map((row) => row.id);
|
||||||
@@ -217,10 +226,17 @@ export const employeeRepository = {
|
|||||||
async create(input: CreateEmployeeInput, db: DbExecutor = pool): Promise<number> {
|
async create(input: CreateEmployeeInput, db: DbExecutor = pool): Promise<number> {
|
||||||
const [result] = await db.execute<ResultSetHeader>(
|
const [result] = await db.execute<ResultSetHeader>(
|
||||||
`
|
`
|
||||||
INSERT INTO employees (store_id, name, phone, status, remark)
|
INSERT INTO employees (store_id, name, phone, password_hash, status, remark)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
[input.storeId, input.name, input.phone, input.status, input.remark ?? null]
|
[
|
||||||
|
input.storeId,
|
||||||
|
input.name,
|
||||||
|
input.phone,
|
||||||
|
DEFAULT_EMPLOYEE_PASSWORD_HASH,
|
||||||
|
input.status,
|
||||||
|
input.remark ?? null
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.insertId;
|
return result.insertId;
|
||||||
|
|||||||
@@ -32,3 +32,7 @@ export function internalServerError(message: string): HttpError {
|
|||||||
export function unauthorized(message: string): HttpError {
|
export function unauthorized(message: string): HttpError {
|
||||||
return new HttpError(401, "UNAUTHORIZED", message);
|
return new HttpError(401, "UNAUTHORIZED", message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function forbidden(message: string): HttpError {
|
||||||
|
return new HttpError(403, "FORBIDDEN", message);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user