diff --git a/README.md b/README.md index 91c4980..5ce3fc5 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,19 @@ - MySQL 8.4 - mysql2 - zod +- @fastify/jwt - Docker Compose ## 项目能力 - 门店管理:查询、新增、修改、软删除门店。 -- 角色管理:查询、新增、修改、删除角色。 +- 角色管理:查询服务端固定角色,用于员工权限分配。 - 员工管理:分页查询、新增、修改、启用/停用、软删除员工。 - 员工角色:一个员工可以绑定多个角色。 +- 登录账号:超级管理员和员工都可以登录。 +- 后台权限:超级管理员拥有所有权限;员工只有绑定 `admin` 角色时才能访问后台管理接口。 +- 固定角色:店长、收银员、后厨、兼职、管理员是服务端固定角色,不提供角色新增、修改、删除接口。 +- JWT 鉴权:登录后签发 token,除健康检查和登录外,接口都需要 Bearer token。 - 数据校验:使用 zod 校验路径参数、查询参数和请求体。 - 数据库迁移:使用 `migrations/*.sql` 管理建表和初始化数据。 - 事务处理:创建/更新员工和角色绑定时使用事务,避免部分成功。 @@ -30,13 +35,16 @@ ├── .agents/ │ └── skills/ │ └── readme-structure-sync/ # README 和目录结构同步维护规则 -├── .env.development # 本地开发环境变量文件,不提交到仓库 +├── .env.development # 默认本地开发环境变量文件,不提交到仓库 +├── .env.local / .env.production # 可选环境变量文件,不提交到仓库 ├── .gitignore # Git 忽略规则,排除本地配置、依赖和编译产物 ├── AGENTS.md # Codex/Agent 入口指令,当前指向 RTK.md ├── RTK.md # 项目协作规则和开发约定 ├── migrations/ # 数据库迁移 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/ │ ├── app.ts # 创建 Fastify 应用、注册路由、统一错误处理 │ ├── server.ts # 启动 HTTP 服务和优雅停机 @@ -46,6 +54,7 @@ │ │ ├── migrate.ts # 执行 migrations 目录下的 SQL │ │ └── pool.ts # MySQL 连接池 │ ├── modules/ +│ │ ├── auth/ # 登录、当前用户和 JWT 鉴权模块 │ │ ├── catalog/ # 门店和角色模块 │ │ └── employees/ # 员工 CRUD 模块 │ └── shared/ # 通用响应结构和业务错误 @@ -61,7 +70,8 @@ | 路径 | 作用 | | --- | --- | | `.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` | 忽略本地环境变量、依赖目录、编译产物和系统文件,避免把无关文件推到仓库。 | | `AGENTS.md` | Agent 工具读取的入口文件,当前通过 `@RTK.md` 引入项目规则。 | | `RTK.md` | 本项目的协作规则,例如使用中文说明、保持分层、改目录时同步 README。 | @@ -71,6 +81,7 @@ | `src/config/env.ts` | 使用 zod 校验 `.env.development` 中的环境变量,避免配置错误拖到请求阶段才暴露。 | | `src/db/migrate.ts` | 执行 `migrations/*.sql`,并用 `schema_migrations` 记录已执行迁移。 | | `src/db/pool.ts` | 创建 MySQL 连接池,提供数据库健康检查和关闭连接的方法。 | +| `src/modules/auth/` | 登录鉴权模块,负责超级管理员和员工登录、密码校验、JWT 签发、当前用户查询和后台权限 guard。 | | `src/modules/catalog/` | 门店和角色模块,负责基础资料接口。 | | `src/modules/employees/` | 员工模块,负责员工分页、详情、新增、修改、状态变更和软删除。 | | `src/shared/` | 跨模块复用的响应结构和业务错误类型。 | @@ -105,7 +116,7 @@ pnpm install ``` -本地开发直接使用现有的 `.env.development`,当前配置如下: +本地开发默认使用现有的 `.env.development`,当前需要这些变量: ```env NODE_ENV=development @@ -117,8 +128,13 @@ DB_USER=access_user DB_PASSWORD=access_pass DB_NAME=access_manage 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: @@ -141,6 +157,7 @@ pnpm db:migrate - `roles`:角色表 - `employees`:员工表 - `employee_roles`:员工角色关系表 +- `super_admins`:超级管理员表 - `schema_migrations`:迁移记录表 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 -curl http://localhost:3500/api/stores +curl http://localhost:3500/api/stores \ + -H "Authorization: Bearer $TOKEN" ``` 查询包含停用门店的列表: ```bash -curl 'http://localhost:3500/api/stores?includeInactive=true' +curl 'http://localhost:3500/api/stores?includeInactive=true' \ + -H "Authorization: Bearer $TOKEN" ``` 新增门店: ```bash curl -X POST http://localhost:3500/api/stores \ + -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "name": "人民广场店", @@ -269,6 +345,7 @@ curl -X POST http://localhost:3500/api/stores \ ```bash curl -X PATCH http://localhost:3500/api/stores/1 \ + -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "phone": "021-87654321" @@ -278,55 +355,30 @@ curl -X PATCH http://localhost:3500/api/stores/1 \ 删除门店: ```bash -curl -X DELETE http://localhost:3500/api/stores/1 +curl -X DELETE http://localhost:3500/api/stores/1 \ + -H "Authorization: Bearer $TOKEN" ``` 门店下还有员工时,不能停用或删除门店。 ## 角色接口示例 +角色是服务端固定权限集合,只允许查询,不允许通过接口新增、修改或删除。 + 查询角色: ```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 curl -X POST http://localhost:3500/api/employees \ + -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "storeId": 1, @@ -340,31 +392,36 @@ curl -X POST http://localhost:3500/api/employees \ 查询员工列表: ```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 -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 -curl 'http://localhost:3500/api/employees?keyword=张三' +curl 'http://localhost:3500/api/employees?keyword=张三' \ + -H "Authorization: Bearer $TOKEN" ``` 查询员工详情: ```bash -curl http://localhost:3500/api/employees/1 +curl http://localhost:3500/api/employees/1 \ + -H "Authorization: Bearer $TOKEN" ``` 修改员工: ```bash curl -X PATCH http://localhost:3500/api/employees/1 \ + -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "name": "张三丰", @@ -376,6 +433,7 @@ curl -X PATCH http://localhost:3500/api/employees/1 \ ```bash curl -X PATCH http://localhost:3500/api/employees/1/status \ + -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"status": "INACTIVE"}' ``` @@ -383,7 +441,8 @@ curl -X PATCH http://localhost:3500/api/employees/1/status \ 软删除员工: ```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):创建门店、角色、员工、员工角色关系表。 - [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` 时,脚本会: @@ -416,10 +477,14 @@ migrations/003_add_employee_email.sql - `stores.deleted_at` 和 `employees.deleted_at` 用于软删除。 - `employees.active_phone` 是生成列,用来实现“同一门店未删除员工手机号唯一”。 +- `employees.password_hash` 让员工也能登录,默认本地密码是 `Employee@123456`。 - `employee_roles` 是多对多关系表。 +- `super_admins` 保存超级管理员账号,密码使用 PBKDF2 哈希,禁止存明文。 +- 角色定义由服务端固定,`admin` 角色用于判断员工是否能访问后台管理接口。 +- JWT 鉴权在 `src/modules/auth/` 中实现,`managementGuard` 统一保护后台管理接口。 - `repository` 使用参数化查询,避免 SQL 注入。 - `service` 使用事务保证员工信息和角色绑定同时成功或同时失败。 -- `app.ts` 统一处理 zod 校验错误、业务错误和数据库唯一索引冲突。 +- `app.ts` 统一注册 JWT、业务路由和错误处理,并处理 zod 校验错误、业务错误和数据库唯一索引冲突。 ## README 维护规则 diff --git a/migrations/003_create_super_admins.sql b/migrations/003_create_super_admins.sql new file mode 100644 index 0000000..364e8e5 --- /dev/null +++ b/migrations/003_create_super_admins.sql @@ -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); diff --git a/migrations/004_add_employee_login_fields.sql b/migrations/004_add_employee_login_fields.sql new file mode 100644 index 0000000..53211d2 --- /dev/null +++ b/migrations/004_add_employee_login_fields.sql @@ -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 '员工登录密码哈希,禁止存储明文密码'; diff --git a/package.json b/package.json index 472e611..fbfa543 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "author": "", "license": "ISC", "dependencies": { + "@fastify/jwt": "^10.1.0", "dotenv": "^17.4.2", "fastify": "^5.8.5", "mysql2": "^3.22.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2958f5..5d4a941 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@fastify/jwt': + specifier: ^10.1.0 + version: 10.1.0 dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -201,12 +204,19 @@ packages: '@fastify/forwarded@3.0.1': resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + '@fastify/jwt@10.1.0': + resolution: {integrity: sha512-U1y8ZbxoH1Pjon3euzPJmbCkuYBM+hrQlFWLQWvKmJGCNT6mVsAolnVJdEWfXeQOKpgmuRVCIsPll5RLZxj10A==} + '@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==} + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -227,6 +237,9 @@ packages: ajv@8.20.0: 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: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -238,6 +251,9 @@ packages: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} + bn.js@4.12.3: + resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -254,6 +270,9 @@ packages: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + esbuild@0.28.0: resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} engines: {node: '>=18'} @@ -268,18 +287,35 @@ packages: fast-json-stringify@6.4.0: 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: resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} fast-uri@3.1.2: 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: resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} + fastparallel@2.4.1: + resolution: {integrity: sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fastseries@1.7.2: + resolution: {integrity: sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==} + find-my-way@9.6.0: resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} engines: {node: '>=20'} @@ -296,6 +332,9 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ipaddr.js@2.4.0: resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} engines: {node: '>= 10'} @@ -319,6 +358,12 @@ packages: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} 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: resolution: {integrity: sha512-uWWxvZSRvRhtBdh2CdcuK83YcOfPdmEeEYB069bAmPnV93QApDGVPuvCQOLjlh7tYHEWdgQPrn6kosDxHBVLkA==} engines: {node: '>= 8.0'} @@ -329,6 +374,9 @@ packages: resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} engines: {node: '>=8.0.0'} + obliterator@2.0.5: + resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} + on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} @@ -374,6 +422,9 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-regex2@5.1.1: resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} hasBin: true @@ -407,6 +458,9 @@ packages: resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} 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: resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} engines: {node: '>=20'} @@ -428,6 +482,10 @@ packages: undici-types@7.24.6: 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: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -525,6 +583,14 @@ snapshots: '@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': dependencies: dequal: 2.0.3 @@ -534,6 +600,8 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.4.0 + '@lukeed/ms@2.0.2': {} + '@pinojs/redact@0.4.0': {} '@types/node@25.9.1': @@ -553,6 +621,13 @@ snapshots: json-schema-traverse: 1.0.0 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: {} avvio@9.2.0: @@ -562,6 +637,8 @@ snapshots: aws-ssl-profiles@1.1.2: {} + bn.js@4.12.3: {} + cookie@1.1.1: {} denque@2.1.0: {} @@ -570,6 +647,10 @@ snapshots: dotenv@17.4.2: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + esbuild@0.28.0: optionalDependencies: '@esbuild/aix-ppc64': 0.28.0 @@ -612,12 +693,26 @@ snapshots: json-schema-ref-resolver: 3.0.0 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: dependencies: fast-decode-uri-component: 1.0.1 fast-uri@3.1.2: {} + fastfall@1.5.1: + dependencies: + reusify: 1.1.0 + + fastify-plugin@5.1.0: {} + fastify@5.8.5: dependencies: '@fastify/ajv-compiler': 4.0.5 @@ -636,10 +731,20 @@ snapshots: semver: 7.8.1 toad-cache: 3.7.1 + fastparallel@2.4.1: + dependencies: + reusify: 1.1.0 + xtend: 4.0.2 + fastq@1.20.1: dependencies: reusify: 1.1.0 + fastseries@1.7.2: + dependencies: + reusify: 1.1.0 + xtend: 4.0.2 + find-my-way@9.6.0: dependencies: fast-deep-equal: 3.1.3 @@ -657,6 +762,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + inherits@2.0.4: {} + ipaddr.js@2.4.0: {} is-property@1.0.2: {} @@ -677,6 +784,12 @@ snapshots: 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): dependencies: '@types/node': 25.9.1 @@ -693,6 +806,8 @@ snapshots: dependencies: lru.min: 1.1.4 + obliterator@2.0.5: {} + on-exit-leak-free@2.1.2: {} pino-abstract-transport@3.0.0: @@ -733,6 +848,8 @@ snapshots: rfdc@1.4.1: {} + safe-buffer@5.2.1: {} + safe-regex2@5.1.1: dependencies: ret: 0.5.0 @@ -755,6 +872,14 @@ snapshots: 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: dependencies: real-require: 1.0.0 @@ -771,4 +896,6 @@ snapshots: undici-types@7.24.6: {} + xtend@4.0.2: {} + zod@4.4.3: {} diff --git a/src/app.ts b/src/app.ts index 19957fc..e7c3f94 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,6 +1,10 @@ import Fastify from "fastify"; +import fastifyJwt from "@fastify/jwt"; import { ZodError } from "zod"; +import { env } from "./config/env"; import { pingDatabase } from "./db/pool"; +import { authRoutes } from "./modules/auth/auth.controller"; +import { managementGuard } from "./modules/auth/auth.guard"; import { catalogRoutes } from "./modules/catalog/catalog.controller"; import { employeeRoutes } from "./modules/employees/employee.controller"; 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 () => { await pingDatabase(); @@ -42,10 +54,18 @@ export function createApp() { }); }); - // 注册业务路由,所有接口都以 /api 开头,便于区分静态资源和 API 请求。 - app.register(catalogRoutes, { prefix: "/api" }); - // 员工管理相关接口,包含员工的增删改查和状态更新等功能。 - app.register(employeeRoutes, { prefix: "/api" }); + // 登录接口不需要 token;/auth/me 在 authRoutes 内部单独加了 authGuard。 + app.register(authRoutes, { prefix: "/api" }); + + // 业务管理接口统一要求后台权限:超级管理员或拥有 admin 角色的员工。 + app.register( + async (protectedApp) => { + protectedApp.addHook("preHandler", managementGuard); + protectedApp.register(catalogRoutes); + protectedApp.register(employeeRoutes); + }, + { prefix: "/api" }, + ); // 全局错误处理器,捕获所有未处理的异常,并根据错误类型返回合适的 HTTP 状态码和错误信息。 app.setErrorHandler((error, request, reply) => { diff --git a/src/config/env.ts b/src/config/env.ts index c31e42d..b3e844e 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -4,7 +4,9 @@ import { z } from "zod"; // 所有运行时配置都从环境变量读取,并在启动时一次性校验。 // 这样数据库密码、端口等配置错误会在服务启动阶段暴露,而不是等到请求进来才失败。 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), DB_HOST: z.string().min(1), @@ -12,7 +14,10 @@ const envSchema = z.object({ 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) + 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); diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..e5e5e82 --- /dev/null +++ b/src/modules/auth/auth.controller.ts @@ -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 { + 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); + }); +} diff --git a/src/modules/auth/auth.guard.ts b/src/modules/auth/auth.guard.ts new file mode 100644 index 0000000..c014441 --- /dev/null +++ b/src/modules/auth/auth.guard.ts @@ -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 { + 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 { + await authGuard(request); + + const user = await authService.getCurrentUser(request.user); + + if (!user.canManage) { + throw forbidden("当前账号没有后台管理权限"); + } +} diff --git a/src/modules/auth/auth.repository.ts b/src/modules/auth/auth.repository.ts new file mode 100644 index 0000000..d672ed5 --- /dev/null +++ b/src/modules/auth/auth.repository.ts @@ -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 { + const [rows] = await pool.execute( + ` + 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 { + const [rows] = await pool.execute( + ` + 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 { + await pool.execute( + ` + UPDATE super_admins + SET last_login_at = CURRENT_TIMESTAMP(3) + WHERE id = ? + `, + [id], + ); + }, + + async findActiveEmployeeByPhone( + phone: string, + ): Promise { + const [rows] = await pool.execute( + ` + 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 { + const [rows] = await pool.execute( + ` + 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 { + await pool.execute( + ` + UPDATE employees + SET last_login_at = CURRENT_TIMESTAMP(3) + WHERE id = ? + `, + [id], + ); + }, + + async findRolesByEmployeeIds( + employeeIds: number[], + ): Promise> { + const result = new Map(); + + if (employeeIds.length === 0) { + return result; + } + + const placeholders = employeeIds.map(() => "?").join(", "); + const [rows] = await pool.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; + }, +}; + +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, + }; +} diff --git a/src/modules/auth/auth.schema.ts b/src/modules/auth/auth.schema.ts new file mode 100644 index 0000000..793887a --- /dev/null +++ b/src/modules/auth/auth.schema.ts @@ -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), +}); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..649762b --- /dev/null +++ b/src/modules/auth/auth.service.ts @@ -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 { + 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("登录已失效,请重新登录"); + }, +}; diff --git a/src/modules/auth/auth.types.ts b/src/modules/auth/auth.types.ts new file mode 100644 index 0000000..f155774 --- /dev/null +++ b/src/modules/auth/auth.types.ts @@ -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; + } +} diff --git a/src/modules/auth/password.ts b/src/modules/auth/password.ts new file mode 100644 index 0000000..a86042e --- /dev/null +++ b/src/modules/auth/password.ts @@ -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 { + 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 { + 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) + ); +} diff --git a/src/modules/catalog/catalog.controller.ts b/src/modules/catalog/catalog.controller.ts index fcfc7d1..059c801 100644 --- a/src/modules/catalog/catalog.controller.ts +++ b/src/modules/catalog/catalog.controller.ts @@ -2,11 +2,9 @@ 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"; @@ -66,26 +64,5 @@ export async function catalogRoutes(app: FastifyInstance): Promise { 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(); - }); + // roles 是服务端固定权限集合,只允许查询,不提供新增、修改、删除接口。 } diff --git a/src/modules/catalog/catalog.repository.ts b/src/modules/catalog/catalog.repository.ts index dddc66f..3bcde1a 100644 --- a/src/modules/catalog/catalog.repository.ts +++ b/src/modules/catalog/catalog.repository.ts @@ -1,7 +1,7 @@ import type { ResultSetHeader, RowDataPacket } from "mysql2/promise"; import { pool } from "../../db/pool"; +import { FIXED_ROLE_CODES } from "./catalog.types"; import type { - CreateRoleInput, CreateStoreInput, ListStoresQuery, Role, @@ -9,11 +9,11 @@ import type { Store, StoreOption, StoreStatus, - UpdateRoleInput, UpdateStoreInput, } from "./catalog.types"; type SqlParam = string | number | boolean | Date | null; +const fixedRoleCodePlaceholders = FIXED_ROLE_CODES.map(() => "?").join(", "); interface StoreRow extends RowDataPacket { id: number; @@ -236,8 +236,10 @@ export const catalogRepository = { ` SELECT id, code, name, description, created_at, updated_at FROM roles + WHERE code IN (${fixedRoleCodePlaceholders}) ORDER BY id ASC `, + [...FIXED_ROLE_CODES], ); return rows.map(toRole); @@ -248,8 +250,10 @@ export const catalogRepository = { ` SELECT id, code, name, description, created_at, updated_at FROM roles + WHERE code IN (${fixedRoleCodePlaceholders}) ORDER BY id ASC `, + [...FIXED_ROLE_CODES], ); return rows.map(toRoleOption); @@ -260,102 +264,13 @@ export const catalogRepository = { ` SELECT id, code, name, description, created_at, updated_at FROM roles - WHERE id = ? + WHERE id = ? AND code IN (${fixedRoleCodePlaceholders}) LIMIT 1 `, - [id], + [id, ...FIXED_ROLE_CODES], ); 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 index d557edd..93d8e92 100644 --- a/src/modules/catalog/catalog.schema.ts +++ b/src/modules/catalog/catalog.schema.ts @@ -58,24 +58,3 @@ export const updateStoreBodySchema = z .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 index ce5f345..e9394bc 100644 --- a/src/modules/catalog/catalog.service.ts +++ b/src/modules/catalog/catalog.service.ts @@ -1,14 +1,12 @@ 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"; @@ -106,46 +104,5 @@ export const catalogService = { 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 index 1ffeee9..6de1022 100644 --- a/src/modules/catalog/catalog.types.ts +++ b/src/modules/catalog/catalog.types.ts @@ -1,7 +1,38 @@ 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 和类型定义里写两遍。 export type StoreStatus = (typeof STORE_STATUS)[number]; +export type FixedRoleCode = (typeof FIXED_ROLE_DEFINITIONS)[number]["code"]; // Option 类型用于下拉框等轻量接口,只返回页面选择所需字段。 export interface StoreOption { @@ -47,15 +78,3 @@ export interface UpdateStoreInput { 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.repository.ts b/src/modules/employees/employee.repository.ts index ca8d372..27a5566 100644 --- a/src/modules/employees/employee.repository.ts +++ b/src/modules/employees/employee.repository.ts @@ -1,5 +1,6 @@ import type { PoolConnection, ResultSetHeader, RowDataPacket } from "mysql2/promise"; import { pool } from "../../db/pool"; +import { FIXED_ROLE_CODES } from "../catalog/catalog.types"; import type { CreateEmployeeInput, Employee, @@ -11,6 +12,9 @@ import type { type DbExecutor = typeof pool | PoolConnection; 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 { id: number; @@ -118,8 +122,13 @@ export const employeeRepository = { // 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 + ` + SELECT id + FROM roles + WHERE id IN (${placeholders}) + AND code IN (${fixedRoleCodePlaceholders}) + `, + [...roleIds, ...FIXED_ROLE_CODES] ); return rows.map((row) => row.id); @@ -217,10 +226,17 @@ export const employeeRepository = { async create(input: CreateEmployeeInput, db: DbExecutor = pool): Promise { const [result] = await db.execute( ` - INSERT INTO employees (store_id, name, phone, status, remark) - VALUES (?, ?, ?, ?, ?) + INSERT INTO employees (store_id, name, phone, password_hash, status, remark) + 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; diff --git a/src/shared/http-error.ts b/src/shared/http-error.ts index 0907876..79ae60d 100644 --- a/src/shared/http-error.ts +++ b/src/shared/http-error.ts @@ -32,3 +32,7 @@ export function internalServerError(message: string): HttpError { export function unauthorized(message: string): HttpError { return new HttpError(401, "UNAUTHORIZED", message); } + +export function forbidden(message: string): HttpError { + return new HttpError(403, "FORBIDDEN", message); +}