access-manage

access-manage 是一个用于学习 MySQL CRUD 的门店员工权限管理后端项目。

项目刻意使用 mysql2 直接写 SQL,不引入 ORM。这样可以更直接地学习表设计、索引、外键、事务、软删除、参数化查询和分层代码组织。

技术栈

  • Node.js + TypeScript
  • Fastify
  • MySQL 8.4
  • mysql2
  • zod
  • @fastify/jwt
  • Docker Compose

项目能力

  • 门店管理:查询、新增、修改、停用、软删除门店,门店详情可查看员工。
  • 角色管理:拥有 role:manage 的账号可新增、修改、软删除自定义角色,服务端内置角色不可变更。
  • 员工管理:分页查询、新增、修改、启用/停用、修改密码、重置初始密码、移除和软删除员工。
  • 员工角色:一个员工可以绑定多个角色。
  • 登录账号:超级管理员和员工都可以登录。
  • 后台权限:超级管理员拥有所有权限;角色权限由 role_permissions 动态分配。
  • 动态权限:菜单和按钮动作由 /api/permissions/me 返回,前端可通过权限管理页分配角色权限。
  • JWT 鉴权:登录后签发 token,除健康检查和登录外,接口都需要 Bearer token。
  • 数据校验:使用 zod 校验路径参数、查询参数和请求体。
  • 数据库迁移:使用 migrations/*.sql 管理建表和初始化数据。
  • 事务处理:创建/更新员工和角色绑定时使用事务,避免部分成功。

目录结构

.
├── .agents/
│   └── skills/
│       └── readme-structure-sync/  # README 和目录结构同步维护规则
├── .env.development                # 默认本地开发环境变量文件,不提交到仓库
├── .env.local / .env.production    # 可选环境变量文件,不提交到仓库
├── .gitignore                      # Git 忽略规则,排除本地配置、依赖和编译产物
├── AGENTS.md                       # Codex/Agent 入口指令,当前指向 RTK.md
├── RTK.md                          # 项目协作规则和开发约定
├── docs/
│   └── API.md                      # 前端对接接口文档
├── deploy/
│   └── server/
│       ├── create-env.sh              # 在服务器生成真实 .env 和 .env.production
│       └── docker-compose.mysql.yml   # 服务器 MySQL Compose 模板
├── migrations/                 # 数据库迁移 SQL
│   ├── 001_initial_schema.sql   # 创建基础表结构
│   ├── 002_seed_demo_data.sql   # 初始化演示门店和角色
│   ├── 003_create_super_admins.sql # 创建超级管理员表和默认账号
│   ├── 004_add_employee_login_fields.sql # 给员工补充登录字段
│   ├── 005_refine_employee_login_and_role_policy.sql # 调整员工默认密码、手机号唯一和系统角色
│   ├── 006_create_role_permissions.sql # 创建角色权限关系表并初始化默认权限
│   └── 007_add_soft_delete_to_roles_and_relations.sql # 给角色和关系表补充逻辑删除字段并移除级联删除
├── src/
│   ├── app.ts                   # 创建 Fastify 应用、注册路由、统一错误处理
│   ├── server.ts                # 启动 HTTP 服务和优雅停机
│   ├── config/
│   │   └── env.ts               # 环境变量校验
│   ├── db/
│   │   ├── migrate.ts           # 执行 migrations 目录下的 SQL
│   │   └── pool.ts              # MySQL 连接池
│   ├── modules/
│   │   ├── auth/                # 登录、当前用户和 JWT 鉴权模块
│   │   ├── catalog/             # 门店和角色模块
│   │   ├── employees/           # 员工 CRUD 模块
│   │   └── permissions/         # 权限点定义、角色权限分配和菜单动作策略
│   └── shared/                  # 通用响应结构和业务错误
├── docker-compose.yml           # 本地 MySQL
├── package.json
├── pnpm-lock.yaml
├── README.md
└── tsconfig.json

目录和关键文件说明

路径 作用
.agents/skills/readme-structure-sync/ 项目内 skill。约定当目录、重要文件或 package.json 脚本变化时,同步更新 README。
.env.development 当前 package.json 脚本默认读取的本地开发环境变量文件;该文件只保留在本机,不提交到仓库。
.env.local / .env.production 本机已有的其他环境变量文件;代码已允许 NODE_ENV=localNODE_ENV=production,切换脚本时可以复用。
.gitignore 忽略本地环境变量、依赖目录、编译产物和系统文件,避免把无关文件推到仓库。
AGENTS.md Agent 工具读取的入口文件,当前通过 @RTK.md 引入项目规则。
RTK.md 本项目的协作规则,例如使用中文说明、保持分层、改目录时同步 README。
docs/API.md 面向前端对接的完整接口文档,包含认证、权限、字段约束、示例请求响应和错误码。
deploy/server/ 服务器部署辅助文件。create-env.sh 在服务器本地生成真实环境变量,docker-compose.mysql.yml 用于启动服务器 MySQL。
migrations/ 数据库迁移目录。所有建表、改表、初始化基础数据的 SQL 都放在这里。
src/app.ts 创建 Fastify 应用,注册路由,处理健康检查和全局错误。
src/server.ts 真正启动 HTTP 服务,监听端口,并处理优雅停机。
src/config/env.ts 使用 zod 校验当前运行环境变量,避免配置错误拖到请求阶段才暴露。
src/db/migrate.ts 执行 migrations/*.sql,并用 schema_migrations 记录已执行迁移。
src/db/pool.ts 创建 MySQL 连接池,提供数据库健康检查和关闭连接的方法。
src/modules/auth/ 登录鉴权模块,负责后台登录、员工端登录、密码校验、JWT 签发、当前用户查询和权限 guard。
src/modules/catalog/ 门店和角色模块,负责基础资料接口、门店详情员工列表和门店移除员工入口。
src/modules/employees/ 员工模块,负责员工分页、详情、新增、修改、状态变更、密码维护和软删除。
src/modules/permissions/ 权限模块,维护权限点定义、角色权限分配、当前用户菜单动作权限和权限策略说明。
src/shared/ 跨模块复用的响应结构和业务错误类型。
docker-compose.yml 本地开发用 MySQL 容器配置。
package.json 项目信息、依赖和常用脚本;脚本会读取现有 .env.development
pnpm-lock.yaml pnpm 锁文件,保证依赖版本一致。
tsconfig.json TypeScript 编译配置。

分层约定

项目按 controller -> service -> repository 分层:

  • controller:处理 HTTP 请求,校验入参,调用 service,返回响应。
  • service:处理业务规则,例如门店是否存在、手机号是否重复、角色能否删除。
  • repository:只负责 SQL 查询和数据库字段转换。
  • schema:使用 zod 定义接口入参规则。
  • types:定义接口输入输出类型。

这个分层是学习重点之一。新增接口时,优先保持同样结构。

环境准备

需要先安装:

  • Node.js
  • pnpm
  • Docker Desktop

安装依赖:

pnpm install

本地开发默认使用现有的 .env.development,当前需要这些变量:

NODE_ENV=development
PORT=3500

DB_HOST=127.0.0.1
DB_PORT=3307
DB_USER=access_user
DB_PASSWORD=access_pass
DB_NAME=access_manage
DB_CONNECTION_LIMIT=10

JWT_SECRET=请使用至少 32 位的随机字符串
JWT_EXPIRES_IN=2h

代码允许的 NODE_ENV 值是:localdevelopmenttestproduction。如果改用 .env.local.env.production 启动,也需要包含同样的 JWT_SECRETJWT_EXPIRES_IN

启动步骤

  1. 启动 MySQL
pnpm mysql:up

本机连接端口是 3307,容器内 MySQL 端口仍然是 3306。这样可以减少和本机已有 MySQL 的冲突。

  1. 执行数据库迁移:
pnpm db:migrate

迁移会创建这些表:

  • stores:门店表
  • roles:角色表
  • employees:员工表
  • employee_roles:员工角色关系表
  • role_permissions:角色权限关系表
  • super_admins:超级管理员表
  • schema_migrations:迁移记录表
  1. 启动后端:
pnpm dev

服务默认运行在:

http://localhost:3500
  1. 健康检查:
curl http://localhost:3500/health

正常响应示例:

{
  "success": true,
  "data": {
    "status": "ok",
    "database": "up",
    "now": "2026-05-26T00:00:00.000Z"
  }
}

服务器部署准备

服务器部署时不要直接使用本地 .env.development,也不要把本机 .env.* 打进部署包。真实密码只应该在服务器本地生成和保存。

  1. 生成服务器真实环境变量:
cd /srv/www/access-manage
bash deploy/server/create-env.sh /srv/www/access-manage

这个脚本会生成两个不提交到仓库的文件:

/srv/www/access-manage/.env
/srv/www/access-manage/.env.production

其中 .env 给 MySQL 容器使用,.env.production 给 Node 后端使用。如果 .env 已经存在,脚本会读取现有 MYSQL_PASSWORD,只补 .env.production;如果 .env.production 已经存在,脚本会拒绝覆盖。

  1. 启动服务器 MySQL
docker-compose -f deploy/server/docker-compose.mysql.yml up -d mysql

服务器模板会把 MySQL 数据持久化到:

/srv/data/access-manage/mysql

当前模板默认后端通过服务器本机端口连接 MySQL:

DB_HOST=127.0.0.1
DB_PORT=3307
  1. 安装生产依赖、迁移、启动:
pnpm install --prod --frozen-lockfile
pnpm db:migrate:prod
pnpm start:prod

pnpm build:devpnpm build:pro 生成的是已经包含 dist/ 的部署包,所以服务器上不需要再执行 pnpm build

  1. 健康检查:
curl http://127.0.0.1:3500/health

只有返回里出现 database: "up",才代表后端服务和 MySQL 都连通。

如果用压缩包部署,至少需要包含 dist/migrations/deploy/package.jsonpnpm-lock.yaml。不要把本机 .env.*node_modules/src/tsconfig.json.git/ 打进部署包。

可以在本机执行:

pnpm build:dev

生成的 access-manage-dev.tar.gz 会包含编译后的 dist/,并排除本地环境变量、node_modules/src/tsconfig.json 和本地开发用 docker-compose.yml,避免覆盖服务器已经准备好的 MySQL 配置。正式生产发布时可以执行 pnpm build:pro,生成 access-manage-pro.tar.gz

package.json 脚本说明

这些脚本定义在 package.jsonscripts 字段里。

命令 作用 什么时候用
pnpm dev 使用现有 .env.development 启动开发服务,并通过 tsx watch 监听代码变化。 日常开发接口时使用。
pnpm build 使用 tsc 编译 TypeScript,输出到 dist/ 准备运行编译产物或发布前验证时使用。
pnpm build:dev 先执行 pnpm build,再把 dist/ 和部署必需文件压缩成 access-manage-dev.tar.gz 准备上传开发/测试服务器的部署包时使用。
pnpm build:pro 先执行 pnpm build,再把 dist/ 和部署必需文件压缩成 access-manage-pro.tar.gz 准备正式生产发布包时使用。
pnpm start 使用现有 .env.development 运行 dist/server.js 已经执行过 pnpm build 后,用编译产物启动服务。
pnpm start:prod 使用 .env.production 运行 dist/server.js 服务器生产环境启动编译产物时使用。
pnpm typecheck 执行 tsc --noEmit,只检查类型,不生成文件。 改 TypeScript 代码后快速确认类型是否正确。
pnpm db:migrate 使用现有 .env.development 运行 src/db/migrate.ts,按顺序执行 migrations/*.sql 第一次启动项目、拉到新迁移、改数据库结构后使用。
pnpm db:migrate:prod 使用 .env.production 运行 dist/db/migrate.js,按顺序执行 migrations/*.sql 服务器生产环境建表、升级表结构或初始化基础数据时使用。
pnpm db:shell 进入 Docker 容器里的 MySQL 命令行。 需要手动查看表结构或查询数据时使用。
pnpm mysql:up 启动本地 MySQL 容器。 开发前先启动数据库。
pnpm mysql:down 停止并移除本地 MySQL 容器。 不再需要本地数据库容器时使用。

重要顺序通常是:

pnpm mysql:up
pnpm db:migrate
pnpm dev

接口文档

完整前端对接文档见 docs/API.md,包含认证、权限、字段约束、全部接口、示例请求响应和常见错误码。

接口响应格式

成功响应:

{
  "success": true,
  "data": {}
}

分页响应:

{
  "success": true,
  "data": {
    "items": [],
    "pagination": {
      "page": 1,
      "pageSize": 20,
      "total": 0,
      "totalPages": 0
    }
  }
}

错误响应:

{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "请求参数不合法",
    "details": []
  }
}

登录和鉴权

本项目有两类可登录账号:

  • 超级管理员:拥有所有后台管理权限。
  • 员工:都可以通过员工端接口登录;后台登录只开放给有后台菜单权限的员工,例如 adminstore_manager

默认本地超级管理员账号由 003_create_super_admins.sql 初始化:

账号:admin
密码:Admin@123456

员工登录字段由 004_add_employee_login_fields.sql005_refine_employee_login_and_role_policy.sql 初始化。已有员工和新建员工默认密码是:

账号:员工手机号
密码:pw111111

后台登录获取 token。超级管理员、管理员和店长使用这个接口:

curl -X POST http://localhost:3500/api/auth/admin/login \
  -H 'Content-Type: application/json' \
  -d '{
    "username": "admin",
    "password": "Admin@123456"
}'

POST /api/auth/login 也保留为后台登录的兼容入口。

员工端登录使用独立接口,给后续 toc 项目使用:

curl -X POST http://localhost:3500/api/auth/employee/login \
  -H 'Content-Type: application/json' \
  -d '{
    "username": "13812345678",
    "password": "pw111111"
  }'

响应里的 data.token 就是后续接口要使用的 JWT。 响应里的 data.user.permissions 是服务端按角色动态计算出的权限点;菜单和按钮动作以 /api/permissions/me 返回结果为准。 登录时会先校验账号和密码;如果密码正确但账号已停用,会返回“账号已被禁用”;如果所属门店已停用,会返回“所属门店已被禁用”。

为了方便测试,可以先把 token 保存成 shell 变量:

TOKEN="把登录响应里的 data.token 粘贴到这里"

获取当前登录用户:

curl http://localhost:3500/api/auth/me \
  -H "Authorization: Bearer $TOKEN"

/health/api/auth/login 外,当前接口都需要带上:

-H "Authorization: Bearer $TOKEN"

获取当前账号菜单和动作权限:

curl http://localhost:3500/api/permissions/me \
  -H "Authorization: Bearer $TOKEN"

查看角色权限策略:

curl http://localhost:3500/api/permissions/policies \
  -H "Authorization: Bearer $TOKEN"

查看可分配权限点定义:

curl http://localhost:3500/api/permissions/definitions \
  -H "Authorization: Bearer $TOKEN"

给角色分配权限:

curl -X PUT http://localhost:3500/api/permissions/roles/5 \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"permissions":["store:view","store:manage","permission:view","permission:manage"]}'

如果员工账号没有后台菜单权限,可以通过员工端登录并访问 /api/auth/me,但访问门店、角色、员工等后台管理接口会返回 403 FORBIDDEN

后台菜单权限

菜单 超级管理员 默认管理员 admin 默认店长 store_manager 其他角色
门店管理 查看、新增、修改、删除 查看、新增、修改、删除 不可见 不可见
角色管理 查看、新增、修改、软删除自定义角色 查看、新增、修改、软删除自定义角色 不可见 按角色权限决定
员工管理 查看全部、新增、修改、删除 查看全部、新增、修改、删除 仅查看当前门店员工 不可见
权限管理 查看、分配 查看、分配 不可见 按角色权限决定

门店接口示例

查询门店选项:

curl http://localhost:3500/api/stores \
  -H "Authorization: Bearer $TOKEN"

查询包含停用门店的列表:

curl 'http://localhost:3500/api/stores?includeInactive=true' \
  -H "Authorization: Bearer $TOKEN"

查询门店详情和门店员工:

curl http://localhost:3500/api/stores/1 \
  -H "Authorization: Bearer $TOKEN"

新增门店:

curl -X POST http://localhost:3500/api/stores \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "人民广场店",
    "address": "上海市黄浦区人民广场",
    "phone": "021-12345678",
    "status": "ACTIVE"
  }'

修改门店:

curl -X PATCH http://localhost:3500/api/stores/1 \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "phone": "021-87654321"
  }'

删除门店:

curl -X DELETE http://localhost:3500/api/stores/1 \
  -H "Authorization: Bearer $TOKEN"

从门店详情中移除员工:

curl -X DELETE http://localhost:3500/api/stores/1/employees/2 \
  -H "Authorization: Bearer $TOKEN"

门店下还有员工时,可以停用门店;该门店员工会返回 storeStatus: "INACTIVE" 和“门店被禁用”状态标签。门店下还有员工时,仍不能删除门店。

角色接口示例

角色管理页面由 role:view 控制可见性,由 role:manage 控制新增、修改、删除。服务端内置角色不可修改或删除。 自定义角色默认不绑定后台菜单权限;可以在权限管理页面给角色分配权限后,再把角色绑定给员工。

查询角色:

curl http://localhost:3500/api/roles \
  -H "Authorization: Bearer $TOKEN"

新增自定义角色:

curl -X POST http://localhost:3500/api/roles \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "code": "regional_manager",
    "name": "区域经理",
    "description": "自定义角色示例"
  }'

员工接口示例

新增员工:

curl -X POST http://localhost:3500/api/employees \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "storeId": 1,
    "name": "张三",
    "phone": "13812345678",
    "roleIds": [1, 2],
    "remark": "早班员工"
  }'

查询员工列表:

curl 'http://localhost:3500/api/employees?page=1&pageSize=20' \
  -H "Authorization: Bearer $TOKEN"

按门店和状态筛选:

curl 'http://localhost:3500/api/employees?storeId=1&status=ACTIVE&page=1&pageSize=20' \
  -H "Authorization: Bearer $TOKEN"

按姓名或手机号搜索:

curl 'http://localhost:3500/api/employees?keyword=张三' \
  -H "Authorization: Bearer $TOKEN"

查询员工详情:

curl http://localhost:3500/api/employees/1 \
  -H "Authorization: Bearer $TOKEN"

修改员工:

curl -X PATCH http://localhost:3500/api/employees/1 \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "张三丰",
    "roleIds": [1]
  }'

停用员工:

curl -X PATCH http://localhost:3500/api/employees/1/status \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"status": "INACTIVE"}'

修改员工密码:

curl -X PATCH http://localhost:3500/api/employees/1/password \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"oldPassword": "pw111111", "newPassword": "NewPw111111"}'

重置员工为初始密码:

curl -X PATCH http://localhost:3500/api/employees/1/password/reset \
  -H "Authorization: Bearer $TOKEN"

软删除员工:

curl -X DELETE http://localhost:3500/api/employees/1 \
  -H "Authorization: Bearer $TOKEN"

员工手机号就是登录账号,未删除员工的手机号不能重复。员工软删除后,可以重新录入相同手机号。员工返回结构包含 statusTags;所属门店停用时会额外包含“门店被禁用”标签。

数据库迁移说明

迁移文件放在 migrations 目录下。

当前迁移:

执行 pnpm db:migratepnpm db:migrate:prod 时,脚本会:

  1. 创建 schema_migrations 迁移记录表。
  2. 读取 migrations 目录里的 .sql 文件。
  3. 按文件名排序执行未执行过的迁移。
  4. 把已执行文件名写入 schema_migrations

后续改表时,建议新增迁移文件,例如:

migrations/003_add_employee_email.sql

不要随意修改已经在数据库执行过的旧迁移,否则不同环境的数据库结构可能不一致。

学习重点

  • stores.deleted_atemployees.deleted_atroles.deleted_at 用于软删除。
  • employees.active_phone 是生成列,用来实现“未删除员工手机号全局唯一”。
  • roles.active_code 是生成列,用来实现“未删除角色编码唯一”。
  • employees.password_hash 让员工也能登录,默认本地密码是 pw111111
  • employee_roles 是员工和角色的多对多关系表,解绑时写入 deleted_at
  • role_permissions 保存角色和权限点的多对多关系,权限解绑时写入 deleted_at,权限分配保存后会在接口鉴权时实时生效。
  • super_admins 保存超级管理员账号,密码使用 PBKDF2 哈希,禁止存明文。
  • 权限点定义由 src/modules/permissions/ 固定,角色拥有的权限点由 role_permissions 动态决定。
  • 前端根据 /api/permissions/me 渲染菜单和按钮,根据 /api/permissions/definitions 渲染可分配权限点。
  • admin 角色默认可管理门店、角色、员工和权限;store_manager 默认只能查看当前门店员工。
  • JWT 鉴权在 src/modules/auth/ 中实现,permissionGuard 按当前角色权限点保护接口。
  • repository 使用参数化查询,避免 SQL 注入。
  • service 使用事务保证员工信息和角色绑定同时成功或同时失败。
  • app.ts 统一注册 JWT、业务路由和错误处理,并处理 zod 校验错误、业务错误和数据库唯一索引冲突。

README 维护规则

本项目有一个项目内 skillreadme-structure-sync

只要发生以下任一变化,就必须在同一次修改里更新 README:

  • 新增、删除、重命名或移动目录。
  • 新增、删除、重命名或移动重要源码文件。
  • 修改 package.jsonscripts
  • 修改启动流程、数据库迁移流程或本地环境变量。

README 至少要同步这些部分:

  • 目录结构
  • 目录和关键文件说明
  • package.json 脚本说明
  • 启动步骤
  • 数据库迁移说明

常见问题

连接不上数据库

先确认 MySQL 容器是否启动:

docker compose ps

再确认 .env.development 里的 DB_PORT3307

迁移执行过了,为什么再次运行会跳过

src/db/migrate.ts 会把执行过的文件名写入 schema_migrations。再次运行时,如果文件名已存在,就会跳过,避免重复建表或重复插入数据。

删除员工或角色后,为什么数据库里还有记录

这是软删除。员工和门店删除接口会把 deleted_at 设置为当前时间,并把状态改成 INACTIVE;角色删除接口会写入 roles.deleted_at。员工角色关系、角色权限关系的解绑也会写入各自的 deleted_at,迁移也会移除旧关系表上的级联物理删除约束。这样可以保留历史数据,同时普通查询会过滤掉已删除记录。

为什么不使用 ORM

这个项目的目标是学习 SQL 和 MySQL 基础能力。直接使用 mysql2 可以更清楚地看到 SQL、索引、事务和参数绑定是如何工作的。

S
Description
No description provided
Readme 150 KiB
Languages
TypeScript 99.2%
Shell 0.8%