Compare commits

..

15 Commits

Author SHA1 Message Date
Kyaru
5e8a464f51 Merge branch 'main' of https://chochox.asia/ChoChoX/backmanager-server 2026-06-30 23:12:21 +08:00
Kyaru
11cb335507 push 2026-06-30 23:11:19 +08:00
d25cf862d8 最后小修 2026-06-29 15:07:53 +08:00
Kyaru
ccb4c32614 离职联动禁用账号 2026-06-29 14:49:24 +08:00
Kyaru
59ea8d1e77 Merge branch 'main' of https://chochox.asia/ChoChoX/backmanager-server 2026-06-29 14:43:24 +08:00
Kyaru
faf94d696d 联动部门更改 2026-06-29 14:43:21 +08:00
067f8e1f21 最后小修 2026-06-29 12:52:01 +08:00
24ccb9b364 最后小修 2026-06-29 11:32:59 +08:00
0cb09616c5 最后小修 2026-06-28 20:43:14 +08:00
9878a07de8 最后小修 2026-06-28 19:50:17 +08:00
Kyaru
84e0862356 暂存 2026-06-28 00:35:31 +08:00
904797e9e9 重构后端,使其更加权责分明 2026-06-26 10:26:03 +08:00
89d8505260 重构后端,使其更加权责分明 2026-06-26 09:44:54 +08:00
Kyaru
764919f69e 更新API说明文档 2026-06-25 21:53:24 +08:00
Kyaru
b4cc90d2d3 更新API说明文档 2026-06-25 21:52:41 +08:00
14 changed files with 1900 additions and 780 deletions

512
API.md
View File

@@ -7,25 +7,27 @@
- [快速开始](#快速开始)
- [技术栈](#技术栈)
- [项目结构](#项目结构)
- [架构分层](#架构分层)
- [认证机制](#认证机制)
- [API 接口总览](#api-接口总览)
- [用户认证](#1-用户认证)
- [用户管理](#2-用户管理)
- [加盟商管理](#3-加盟商管理)
- [员工管理](#4-员工管理)
- [合同管理](#5-合同管理)
- [售后管理](#6-售后管理)
- [产品管理](#7-产品管理)
- [供应商管理](#8-供应商管理)
- [1. 用户认证](#1-用户认证)
- [2. 用户管理](#2-用户管理)
- [3. 加盟商管理](#3-加盟商管理)
- [4. 员工管理](#4-员工管理)
- [5. 合同管理](#5-合同管理)
- [6. 售后管理](#6-售后管理)
- [7. 产品管理](#7-产品管理)
- [8. 供应商管理](#8-供应商管理)
- [统一响应格式](#统一响应格式)
- [数据库设计](#数据库设计)
- [表结构详情](#表结构详情)
- [表关系 ER 图](#表关系-er-图)
- [RBAC 权限系统](#rbac-权限系统)
- [角色定义](#角色定义)
- [部门权限系统](#部门权限系统)
- [部门定义](#部门定义)
- [权限列表](#权限列表)
- [角色-权限映射](#角色-权限映射)
- [部门-权限映射](#部门-权限映射)
- [数据范围隔离](#数据范围隔离)
- [中间件说明](#中间件说明)
- [预置账号](#预置账号)
---
@@ -70,7 +72,7 @@ node test-all.js
| `JWT_SECRET` | JWT 签名密钥 | — |
| `JWT_EXPIRES_IN` | Token 有效期 | `2h` |
> 首次启动时,系统会自动创建数据库、建表、初始化角色权限和预置用户。
> 首次启动时,系统会自动创建数据库、建表、初始化部门权限和预置用户。
---
@@ -93,22 +95,43 @@ backmanager-server/
├── server.js # 入口Express 应用、鉴权中间件、路由注册
├── db.js # 数据库:连接池、建表、种子数据
├── middleware/
── permissions.js # RBAC权限检查 + 数据范围过滤
── permissions.js # 权限检查 + 数据范围 + 合同类型校验
│ ├── users.js # 用户自保护规则(禁止自改部门、自删等)
│ └── employees.js # 员工创建时同步创建用户账号
├── routes/
│ ├── users.js # 用户登录/信息/CRUD
│ ├── customers.js # 加盟商 CRUD
│ ├── employees.js # 员工 CRUD
│ ├── customers.js # 加盟商 CRUD + 简易列表
│ ├── employees.js # 员工 CRUD + 简易列表
│ ├── contracts.js # 合同 CRUD
│ ├── afterSales.js # 售后 CRUD
│ ├── products.js # 产品 CRUD
│ └── suppliers.js # 供应商 CRUD
├── test-all.js # 集成测试(覆盖 6 个角色的权限与数据隔离)
│ └── suppliers.js # 供应商 CRUD + 简易列表
├── test-all.js # 集成测试(覆盖 6 个部门的权限与数据隔离131 条用例
├── API.md # 本文档
├── .env.example # 环境变量模板
└── package.json
```
---
## 架构分层
本项目采用**三层分离架构**,各层职责清晰:
```
请求 → 中间件层(认证/权限/数据范围/业务规则)→ 路由层(纯 CRUD数据库操作→ 响应
```
| 分层 | 文件 | 职责 |
|------|------|------|
| **API 端点层** | `server.js` | 路由注册,串联中间件链 |
| **中间件层** | `middleware/*.js` | JWT 认证、权限校验、数据范围注入、业务规则校验 |
| **数据库查询层** | `routes/*.js` | 纯 SQL CRUD 操作,不做权限判断和业务逻辑 |
**路由层纯粹性要求:** 所有 `routes/*.js` 只包含数据库的增删改查操作,不参与权限判断、数据范围计算等逻辑。权限和数据范围由中间件通过 `req.user.permissions``req.scope` 注入。
---
## 认证机制
所有 API除登录外需要在请求头中携带 JWT Token
@@ -128,19 +151,29 @@ Authorization: Bearer <token>
"id": 1,
"username": "admin",
"name": "系统管理员",
"role_id": 1,
"roleName": "admin",
"department": "信息技术部",
"department_id": 1,
"departmentName": "admin",
"departmentDesc": "信息技术部",
"permissions": ["customer:read", "customer:create", "customer:update", "customer:delete", "contract:read", "..."]
}
```
### 认证流程
### 中间件执行流程
```
客户端请求 → auth 中间件验证 Token → checkPermission 检查权限 → 路由处理 → 数据范围过滤getDataScope→ 返回响应
请求 → auth验证 Token→ checkPermission(权限检查)→ dataScope数据范围注入→ [业务中间件] → 路由处理 → 响应
```
| 中间件 | 文件 | 说明 |
|--------|------|------|
| `auth` | `server.js` | 解析并验证 JWT Token将用户信息挂载到 `req.user` |
| `checkPermission(resource, action)` | `middleware/permissions.js` | 检查 `req.user.permissions` 是否包含 `resource:action` |
| `dataScope(resource)` | `middleware/permissions.js` | 根据用户部门计算数据范围,挂载 `req.scope = { sql, params }` |
| `validateContractType` | `middleware/permissions.js` | 限制招商部只能操作加盟合同,采购部只能操作采购合同 |
| `protectSelfUpdate` | `middleware/users.js` | 禁止用户修改自己的部门或禁用自己 |
| `protectUserDelete` | `middleware/users.js` | 禁止删除自己或最后一个管理员 |
| `allowUserCreation` | `middleware/employees.js` | 允许有 `user:manage` 权限的用户在创建员工时同步创建账号 |
### 错误响应
| HTTP 状态码 | 场景 |
@@ -158,6 +191,38 @@ Authorization: Bearer <token>
- **分页响应**:列表接口返回 `{ list, total, page, pageSize, totalPages }`
- **搜索参数**:通过 URL Query String 传递,如 `GET /api/customers?name=张&phone=138`
- **部分更新**PUT 接口只传需要修改的字段即可,未传字段保持不变
- **权限要求**:每个接口所需的权限标注在接口标题下方
### 接口权限速查表
| 接口 | 方法 | 权限 | 数据范围 |
|------|------|------|---------|
| `/api/user/login` | POST | 无 | — |
| `/api/user/info` | GET | 登录即可 | 仅自己 |
| `/api/user/list` | GET | 登录即可 | 所有启用用户 |
| `/api/user/logout` | POST | 登录即可 | — |
| `/api/user/password` | PUT | 登录即可 | 仅自己 |
| `/api/users` | GET/POST | `user:manage` | 全部 |
| `/api/users/:id` | GET/PUT/DELETE | `user:manage` | 全部 |
| `/api/customers/simple` | GET | `customer:read` | 全部客户 |
| `/api/customers` | GET | `customer:read` | 过渡期全量 |
| `/api/customers` | POST | `customer:create` | — |
| `/api/customers/:id` | GET/PUT/DELETE | 对应权限 | 过渡期全量 |
| `/api/employees/simple` | GET | 登录即可 | 全部在职员工 |
| `/api/employees` | GET | `employee:read` | 按部门隔离 |
| `/api/employees` | POST | `employee:create` | — |
| `/api/employees/:id` | GET/PUT/DELETE | 对应权限 | 按部门隔离 |
| `/api/contracts` | GET | `contract:read` | 按合同类型隔离 |
| `/api/contracts` | POST | `contract:create` | 按部门限制类型 |
| `/api/contracts/:id` | GET/PUT/DELETE | 对应权限 | 按合同类型隔离 |
| `/api/after-sales` | GET | `after_sale:read` | 过渡期全量 |
| `/api/after-sales` | POST | `after_sale:create` | — |
| `/api/after-sales/:id` | GET/PUT/DELETE | 对应权限 | 过渡期全量 |
| `/api/products` | GET | `product:read` | 全部 |
| `/api/products/:id` | GET/PUT/DELETE | 对应权限 | 全部 |
| `/api/suppliers/simple` | GET | `supplier:read` | 全部正常供应商 |
| `/api/suppliers` | GET | `supplier:read` | 全部 |
| `/api/suppliers/:id` | GET/PUT/DELETE | 对应权限 | 全部 |
---
@@ -188,15 +253,17 @@ Authorization: Bearer <token>
"id": 1,
"username": "admin",
"name": "系统管理员",
"role_id": 1,
"roleName": "admin",
"department": "信息技术部",
"department_id": 1,
"departmentName": "admin",
"departmentDesc": "信息技术部",
"permissions": ["customer:read", "customer:create", "..."]
}
}
}
```
> **注意**:登录成功返回 `code: 200`,其他接口统一返回 `code: 0`。前端通过 `res.code === 0 || res.code === 200` 兼容判断。
**错误响应:**
| code | message |
@@ -207,7 +274,38 @@ Authorization: Bearer <token>
#### GET /api/user/info — 获取当前用户信息
需要 Token。返回当前登录用户的完整信息(含角色名、员工名)。
需要 Token。
返回当前登录用户的完整信息(含部门名称、员工姓名)。
**响应数据字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | number | 用户 ID |
| `username` | string | 用户名 |
| `is_active` | number | 状态0-禁用, 1-启用 |
| `department_id` | number | 部门 ID |
| `employee_id` | number | 关联员工 ID |
| `dept_name` | string | 部门标识(如 `admin` |
| `dept_desc` | string | 部门中文名(如 信息技术部) |
| `real_name` | string | 真实姓名(来自员工表) |
| `created_at` | datetime | 创建时间 |
| `updated_at` | datetime | 更新时间 |
#### GET /api/user/list — 简易用户列表
需要 Token仅需登录无需特定权限
返回所有启用用户的基本信息,用于前端下拉选择负责人。
**响应数据字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | number | 用户 ID |
| `username` | string | 用户名 |
| `real_name` | string | 真实姓名(来自员工表,可能为 null |
#### POST /api/user/logout — 登出
@@ -235,7 +333,7 @@ Authorization: Bearer <token>
### 2. 用户管理
> 需要 `user:manage` 权限(仅系统管理员拥有)。
> 需要 `user:manage` 权限(仅系统管理员 / 信息技术部拥有)。
#### GET /api/users — 用户列表
@@ -244,7 +342,9 @@ Authorization: Bearer <token>
| 参数 | 说明 | 示例 |
|------|------|------|
| `username` | 按用户名模糊搜索 | `?username=admin` |
| `role_id` | 按角色 ID 过滤 | `?role_id=2` |
| `real_name` | 按真实姓名模糊搜索 | `?real_name=张` |
| `id` | 按用户 ID 精确搜索 | `?id=1` |
| `department_id` | 按部门 ID 过滤 | `?department_id=2` |
| `is_active` | 按状态过滤0-禁用, 1-启用) | `?is_active=1` |
| `page` | 页码 | `?page=1` |
| `pageSize` | 每页条数 | `?pageSize=10` |
@@ -256,12 +356,11 @@ Authorization: Bearer <token>
| `id` | number | 用户 ID |
| `username` | string | 用户名 |
| `is_active` | number | 状态0-禁用, 1-启用 |
| `role_id` | number | 角色 ID |
| `role_name` | string | 角色标识(如 `admin` |
| `role_description` | string | 角色描述(如 系统管理员) |
| `department_id` | number | 部门 ID |
| `employee_id` | number | 关联员工 ID |
| `dept_name` | string | 部门标识(如 `admin` |
| `dept_desc` | string | 部门中文名(如 信息技术部) |
| `real_name` | string | 真实姓名(来自员工表) |
| `department` | string | 所属部门 |
| `created_at` | datetime | 创建时间 |
| `updated_at` | datetime | 更新时间 |
@@ -277,22 +376,28 @@ Authorization: Bearer <token>
|------|------|------|
| `username` | ✅ | 用户名3-50 字符) |
| `password` | ✅ | 密码(至少 6 位) |
| `role_id` | ❌ | 角色 ID |
| `employee_id` | ❌ | 关联员工 ID |
| `department` | ❌ | 所属部门(若填了 employee_id 会自动同步) |
| `department_id` | ❌ | 部门 ID(需为已存在的部门) |
| `employee_id` | ❌ | 关联员工 ID(需为已存在的员工) |
| `is_active` | ❌ | 状态,默认 1 |
**校验规则:**
- `department_id` 必须存在于 `departments`
- `employee_id` 必须存在于 `employees`
- `username` 不可重复(返回 409
#### PUT /api/users/:id — 更新用户
**可更新字段:** `username``role_id``employee_id``department``is_active``password`
**可更新字段:** `username``department_id``employee_id``is_active``password`
**限制**
- 不能修改自己的角色
- 不能禁用自己
**自保护限制(`protectSelfUpdate` 中间件)**
- 不能修改自己的 `department_id`
- 不能禁用自己`is_active` 不能设为 0
#### DELETE /api/users/:id — 删除用户
**限制:** 不能删除自己。
**自保护限制(`protectUserDelete` 中间件):**
- 不能删除自己
- 不能删除信息技术部的最后一个管理员账号
---
@@ -300,18 +405,30 @@ Authorization: Bearer <token>
> 需要 `customer:read` / `customer:create` / `customer:update` / `customer:delete` 权限。
#### GET /api/customers/simple — 简易加盟商列表
需要 `customer:read` 权限。无数据范围限制,用于前端下拉选择。
**响应数据字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | number | 加盟商 ID |
| `name` | string | 加盟商姓名 |
#### GET /api/customers — 加盟商列表
**查询参数:**
| 参数 | 说明 |
|------|------|
| `id` | 按 ID 精确搜索 |
| `name` | 按加盟商名称模糊搜索 |
| `phone` | 按电话模糊搜索 |
| `province` | 按省份精确匹配 |
| `city` | 按城市精确匹配 |
**响应数据字段:**
**响应数据字段(含 JOIN 字段)**
| 字段 | 类型 | 说明 |
|------|------|------|
@@ -324,7 +441,6 @@ Authorization: Bearer <token>
| `address` | string | 详细地址 |
| `email` | string | 电子邮箱 |
| `remark` | string | 备注信息 |
| `responsible_user_id` | number | 负责人用户 ID |
| `created_at` | datetime | 创建时间 |
| `updated_at` | datetime | 更新时间 |
@@ -342,7 +458,6 @@ Authorization: Bearer <token>
| `address` | ❌ | 详细地址 |
| `email` | ❌ | 电子邮箱 |
| `remark` | ❌ | 备注 |
| `responsible_user_id` | ❌ | 负责人(默认为当前登录用户) |
#### PUT /api/customers/:id — 更新加盟商
@@ -350,18 +465,40 @@ Authorization: Bearer <token>
#### DELETE /api/customers/:id — 删除加盟商
**限制:** 被合同或售后记录引用的客户无法删除(返回 400
---
### 4. 员工管理
> 需要 `employee:read` / `employee:create` / `employee:update` / `employee:delete` 权限。
#### GET /api/employees/simple — 简易员工列表
需要 Token仅需登录。无数据范围限制用于前端下拉选择。支持按部门过滤。
**查询参数:**
| 参数 | 说明 |
|------|------|
| `department` | 按部门精确过滤(如 `?department=运营部` |
**响应数据字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | number | 员工 ID |
| `name` | string | 姓名 |
| `department` | string | 所属部门 |
| `position` | string | 职务/岗位 |
#### GET /api/employees — 员工列表
**查询参数:**
| 参数 | 说明 |
|------|------|
| `id` | 按 ID 精确搜索 |
| `name` | 按姓名模糊搜索 |
| `department` | 按部门模糊搜索 |
| `status` | 按在职状态过滤0-离职, 1-在职) |
@@ -404,6 +541,16 @@ Authorization: Bearer <token>
| `email` | ❌ | 邮箱 |
| `status` | ❌ | 在职状态,默认 1 |
| `remark` | ❌ | 备注 |
| `username` | ❌ | 需同步创建用户时填写(需 `user:manage` 权限) |
| `password` | ❌ | 需同步创建用户时填写(需 `user:manage` 权限) |
| `department_id` | ❌ | 需同步创建用户时填写(指定用户所属部门 ID |
**同步创建用户账号(`allowUserCreation` 中间件):**
当同时传入 `username``password``department_id` 三个字段时:
- 需要拥有 `user:manage` 权限
- 系统会在创建员工后自动创建关联的系统用户账号
- `username` 长度 3-50 字符,`password` 至少 6 位
#### PUT /api/employees/:id — 更新员工
@@ -411,6 +558,11 @@ Authorization: Bearer <token>
#### DELETE /api/employees/:id — 删除员工
**特殊行为:**
- 删除员工时会同步删除关联的用户账号(`users` 表中 `employee_id` 匹配的记录)
- 不能删除当前登录用户自己关联的员工账号
- 被合同或售后记录引用的员工无法删除(返回 400
---
### 5. 合同管理
@@ -423,18 +575,24 @@ Authorization: Bearer <token>
| 参数 | 说明 |
|------|------|
| `id` | 按 ID 精确搜索 |
| `status` | 按合同状态过滤(草稿/生效/完成/作废) |
| `customer_name` | 按客户名称模糊搜索 |
| `contract_no` | 按合同编号模糊搜索 |
| `contract_name` | 按合同名称模糊搜索 |
| `employee_name` | 按业务员姓名模糊搜索 |
| `effective_date_start` | 按生效日期起始过滤格式YYYY-MM-DD |
| `effective_date_end` | 按生效日期截止过滤格式YYYY-MM-DD |
**响应数据字段(含 JOIN 字段):**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | number | 合同 ID |
| `customer_id` | number | 客户 ID |
| `customer_id` | number | 客户 ID(加盟合同必填,采购合同为 null |
| `customer_name` | string | 客户名称JOIN 自 customers |
| `supplier_id` | number | 供应商 ID采购合同必填加盟合同为 null |
| `supplier_name` | string | 供应商名称JOIN 自 suppliers |
| `contract_name` | string | 合同名称 |
| `contract_no` | string | 合同编号 |
| `contract_content` | string | 合同内容/条款 |
@@ -447,6 +605,7 @@ Authorization: Bearer <token>
| `remark` | string | 备注 |
| `type` | string | 合同类型:`franchise`(加盟)/ `supply`(采购) |
| `responsible_user_id` | number | 负责人用户 ID |
| `responsible_user_name` | string | 负责人姓名JOIN 自 users → employees |
| `created_at` | datetime | 创建时间 |
| `updated_at` | datetime | 更新时间 |
@@ -456,7 +615,8 @@ Authorization: Bearer <token>
| 字段 | 必填 | 说明 |
|------|------|------|
| `customer_id` | | 关联客户 ID |
| `customer_id` | 条件必填 | 关联客户 ID(加盟合同必填) |
| `supplier_id` | 条件必填 | 关联供应商 ID采购合同必填 |
| `contract_name` | ✅ | 合同名称 |
| `contract_no` | ❌ | 合同编号 |
| `contract_content` | ❌ | 合同内容 |
@@ -464,21 +624,34 @@ Authorization: Bearer <token>
| `effective_date` | ❌ | 生效日期 |
| `expiry_date` | ❌ | 到期日期 |
| `employee_id` | ❌ | 业务员 ID |
| `status` | ❌ | 状态,默认 `生效` |
| `status` | ❌ | 状态,默认 `生效` |
| `remark` | ❌ | 备注 |
| `type` | ❌ | 合同类型(见下方说明) |
| `responsible_user_id` | ❌ | 负责人(默认为当前用户) |
| `responsible_user_id` | ❌ | 负责人(默认为当前用户,仅管理员可在前端修改 |
**合同类型自动设置规则**
- 采购经理创建的合同自动设为 `supply`
- 其他角色默认 `franchise`,也可手动指定
**合同类型限制(`validateContractType` 中间件)**
| 部门 | 允许的合同类型 | 说明 |
|------|-------------|------|
| 招商部 (`franchise_manager`) | 仅 `franchise` | 只能创建加盟合同,且必须选择 `customer_id` |
| 采购部 (`procurement_manager`) | 仅 `supply` | 只能创建采购合同,且必须选择 `supplier_id` |
| 信息技术部/总经理/财务 | 不限制 | 默认 `franchise`,可手动指定 |
**校验规则:**
- `customer_id` 必须为已存在的客户
- `supplier_id` 必须为已存在的供应商
- `employee_id`(如填写)必须为已存在的员工
#### PUT /api/contracts/:id — 更新合同
所有字段均可选。可更新字段包括 `type``responsible_user_id`
所有字段均可选。可更新字段包括 `type``supplier_id``responsible_user_id`
**校验规则:** 更新 `customer_id``supplier_id``employee_id` 时会校验关联记录是否存在。合同类型限制同创建。
#### DELETE /api/contracts/:id — 删除合同
**限制:** 被售后记录引用的合同无法删除(返回 400
---
### 6. 售后管理
@@ -491,8 +664,11 @@ Authorization: Bearer <token>
| 参数 | 说明 |
|------|------|
| `handle_status` | 按处理状态过滤(待处理/处理中/已完成) |
| `id` | 按 ID 精确搜索 |
| `customer_id` | 按客户 ID 精确搜索 |
| `customer_name` | 按客户名称模糊搜索 |
| `handle_status` | 按处理状态过滤(待处理/处理中/已完成) |
| `feedback` | 按反馈内容模糊搜索 |
**响应数据字段(含 JOIN 字段):**
@@ -509,6 +685,7 @@ Authorization: Bearer <token>
| `service_date` | date | 售后日期 |
| `remark` | string | 备注 |
| `responsible_user_id` | number | 负责人用户 ID |
| `responsible_user_name` | string | 负责人姓名JOIN 自 users → employees |
| `created_at` | datetime | 创建时间 |
| `updated_at` | datetime | 更新时间 |
@@ -520,17 +697,23 @@ Authorization: Bearer <token>
|------|------|------|
| `customer_id` | ✅ | 关联客户 ID |
| `feedback` | ✅ | 售后反馈内容 |
| `employee_id` | ❌ | 处理业务员 ID |
| `employee_id` | ❌ | 处理业务员 ID(前端仅显示运营部员工) |
| `handle_method` | ❌ | 处理方式 |
| `handle_status` | ❌ | 处理状态,默认 `待处理` |
| `service_date` | ❌ | 售后日期 |
| `remark` | ❌ | 备注 |
| `responsible_user_id` | ❌ | 负责人(默认为当前用户) |
| `responsible_user_id` | ❌ | 负责人(默认为当前用户,仅管理员可在前端修改 |
**校验规则:**
- `customer_id` 必须为已存在的客户
- `employee_id`(如填写)必须为已存在的员工
#### PUT /api/after-sales/:id — 更新售后
所有字段均可选。
**校验规则:** 更新 `customer_id``employee_id` 时会校验关联记录是否存在。
#### DELETE /api/after-sales/:id — 删除售后
---
@@ -546,9 +729,10 @@ Authorization: Bearer <token>
| 参数 | 说明 |
|------|------|
| `id` | 按 ID 精确搜索 |
| `name` | 按产品名称模糊搜索 |
| `type` | 按产品类型模糊搜索 |
| `supplier` | 按供应商模糊搜索 |
| `supplier` | 按供应商名称模糊搜索 |
**响应数据字段:**
@@ -561,7 +745,7 @@ Authorization: Bearer <token>
| `price` | number | 单价 |
| `unit` | string | 计量单位(默认"件" |
| `specification` | string | 规格/型号 |
| `supplier` | string | 供应商 |
| `supplier` | string | 供应商名称 |
| `remark` | string | 备注 |
| `created_at` | datetime | 创建时间 |
| `updated_at` | datetime | 更新时间 |
@@ -578,7 +762,7 @@ Authorization: Bearer <token>
| `price` | ❌ | 单价,默认 0.00 |
| `unit` | ❌ | 单位,默认"件" |
| `specification` | ❌ | 规格 |
| `supplier` | ❌ | 供应商 |
| `supplier` | ❌ | 供应商名称 |
| `remark` | ❌ | 备注 |
#### PUT /api/products/:id — 更新产品
@@ -594,12 +778,24 @@ Authorization: Bearer <token>
> 需要 `supplier:read` / `supplier:create` / `supplier:update` / `supplier:delete` 权限。
> 无数据范围隔离,拥有权限即可查看所有供应商。
#### GET /api/suppliers/simple — 简易供应商列表
需要 `supplier:read` 权限。返回所有正常状态供应商的基本信息,用于前端下拉选择。
**响应数据字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | number | 供应商 ID |
| `name` | string | 供应商名称 |
#### GET /api/suppliers — 供应商列表
**查询参数:**
| 参数 | 说明 |
|------|------|
| `id` | 按 ID 精确搜索 |
| `name` | 按供应商名称模糊搜索 |
| `type` | 按类型模糊搜索 |
| `status` | 按状态过滤0-停用, 1-正常) |
@@ -680,9 +876,9 @@ Authorization: Bearer <token>
| HTTP 状态码 | code | 说明 |
|------------|------|------|
| 400 | 400 | 参数错误 / 校验失败 |
| 400 | 400 | 参数错误 / 校验失败 / 关联记录不存在 |
| 401 | 401 | 未登录 / Token 无效 |
| 403 | 403 | 权限不足 / 账号被禁用 |
| 403 | 403 | 权限不足 / 账号被禁用 / 合同类型不匹配 |
| 404 | 404 | 资源不存在 |
| 409 | 409 | 数据冲突(如用户名重复) |
| 500 | 500 | 服务器内部错误 |
@@ -695,7 +891,7 @@ Authorization: Bearer <token>
#### 1. users — 系统用户表
存储系统登录账号,每个用户关联一个角色和一个员工。
存储系统登录账号,每个用户关联一个部门和一个员工。
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
@@ -703,19 +899,18 @@ Authorization: Bearer <token>
| `username` | VARCHAR(50) | NOT NULL, UNIQUE | 用户名(登录账号) |
| `password` | VARCHAR(255) | NOT NULL | 密码bcrypt 哈希) |
| `is_active` | TINYINT(1) | NOT NULL DEFAULT 1 | 账号状态0-禁用, 1-启用 |
| `role_id` | INT | NULLABLE | 角色 ID → roles.id |
| `employee_id` | INT | NULLABLE | 员工 ID → employees.id |
| `department` | VARCHAR(100) | NULLABLE | 所属部门(冗余字段,从 employees 同步) |
| `department_id` | INT | NULLABLE | 部门 ID → departments.id |
| `employee_id` | INT | NULLABLE, UNIQUE | 员工 ID → employees.id |
| `created_at` | DATETIME | DEFAULT CURRENT_TIMESTAMP | 创建时间 |
| `updated_at` | DATETIME | ON UPDATE CURRENT_TIMESTAMP | 更新时间 |
#### 2. roles — 角色
#### 2. departments — 部门
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| `id` | INT | PK, AUTO_INCREMENT | 角色 ID |
| `name` | VARCHAR(50) | NOT NULL, UNIQUE | 角色标识(英文) |
| `description` | VARCHAR(200) | NULLABLE | 角色描述(中文) |
| `id` | INT | PK, AUTO_INCREMENT | 部门 ID |
| `name` | VARCHAR(50) | NOT NULL, UNIQUE | 部门标识(英文) |
| `description` | VARCHAR(200) | NULLABLE | 部门名称(中文) |
| `created_at` | DATETIME | DEFAULT CURRENT_TIMESTAMP | 创建时间 |
#### 3. permissions — 权限表
@@ -728,11 +923,11 @@ Authorization: Bearer <token>
| `resource` | VARCHAR(50) | NOT NULL | 资源名称 |
| `action` | VARCHAR(50) | NOT NULL | 操作类型 |
#### 4. role_permissions — 角色-权限关联表
#### 4. department_permissions — 部门-权限关联表
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| `role_id` | INT | PK (联合) | 角色 ID → roles.id |
| `department_id` | INT | PK (联合) | 部门 ID → departments.id |
| `permission_id` | INT | PK (联合) | 权限 ID → permissions.id |
#### 5. customers — 加盟商信息表
@@ -748,7 +943,6 @@ Authorization: Bearer <token>
| `address` | VARCHAR(200) | — | 详细地址 |
| `email` | VARCHAR(100) | — | 电子邮箱 |
| `remark` | TEXT | — | 备注信息 |
| `responsible_user_id` | INT | — | 负责人用户 ID → users.id |
| `created_at` | DATETIME | DEFAULT CURRENT_TIMESTAMP | 创建时间 |
| `updated_at` | DATETIME | ON UPDATE CURRENT_TIMESTAMP | 更新时间 |
@@ -777,7 +971,8 @@ Authorization: Bearer <token>
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| `id` | INT | PK, AUTO_INCREMENT | 合同 ID |
| `customer_id` | INT | NOT NULL, INDEX | 客户 ID → customers.id |
| `customer_id` | INT | NULLABLE, INDEX | 客户 ID → customers.id(加盟合同必填,采购合同为 null |
| `supplier_id` | INT | NULLABLE, INDEX | 供应商 ID → suppliers.id采购合同必填加盟合同为 null |
| `contract_name` | VARCHAR(200) | NOT NULL | 合同名称 |
| `contract_no` | VARCHAR(100) | — | 合同编号 |
| `contract_content` | TEXT | — | 合同内容/条款 |
@@ -844,13 +1039,13 @@ Authorization: Bearer <token>
### 表关系 ER 图
```
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
roles │ │ permissions │ │ role_permissions
│──────────────│ │──────────────│ │──────────────────│
│ id (PK) │◄─┐ │ id (PK) │◄─┐ │ role_id (PK,FK) │
│ name │ │ │ name │ └──│ permission_id
│ description │ │ │ description │ │ (PK,FK)
└──────────────┘ │ │ resource │ └──────────────────┘
┌──────────────┐ ┌──────────────┐ ┌────────────────────────
departments │ │ permissions │ │ department_permissions │
│──────────────│ │──────────────│ │────────────────────────
│ id (PK) │◄─┐ │ id (PK) │◄─┐ │ department_id (PK,FK) │
│ name │ │ │ name │ │ permission_id (PK,FK)
│ description │ │ │ description │ └──│ → permissions.id
└──────────────┘ │ │ resource │ └────────────────────────
│ │ action │
│ └──────────────┘
@@ -861,12 +1056,12 @@ Authorization: Bearer <token>
│ username │ │ │ name │
│ password │ │ │ gender │
│ is_active │ │ │ age │
role_id (FK)─┼──┘ │ education │
employee_id │─ ─ ─│ department │◄──────── users.department (冗余)
(FK) ──────┼──→ │ entry_date │
department │ │ position │
│ (冗余) │ │ salary │
└──────┬───────┘ │ phone │
department_id│──┘ │ education │
(FK) │ │ department │◄──── 数据范围隔离字段
employee_id │─ ─ ─│ entry_date │
(FK) ──────┼──→ │ position │
└──────┬───────┘ │ salary │
│ phone │
│ │ email │
│ │ status │
│ └──────┬───────┘
@@ -877,7 +1072,6 @@ Authorization: Bearer <token>
│──────────────────────────────────────────────│
│ id (PK) │
│ name, phone, province, city, district, ... │
│ responsible_user_id ──────────→ users.id │
└────────────────────┬─────────────────────────┘
┌──────────┴──────────┐
@@ -888,15 +1082,17 @@ Authorization: Bearer <token>
│ id (PK) │ │ id (PK) │
│ customer_id (FK) │ │ customer_id (FK) │
│ → customers.id │ │ → customers.id │
contract_name │ │ feedback │
contract_no │ │ employee_id (FK) │
amount │ │ → employees.id │
employee_id (FK) │ │ handle_method │
→ employees.id │ │ handle_status │
status │ │ service_date │
type │ │ responsible_user │
franchise/ │ │ _id → users.id │
supply │ └──────────────────┘
supplier_id (FK) │ │ feedback │
→ suppliers.id │ │ employee_id (FK) │
contract_name │ │ → employees.id │
contract_no │ │ handle_method │
amount │ │ handle_status │
employee_id (FK) │ │ service_date │
→ employees.id │ │ responsible_user │
status │ │ _id → users.id │
type │ └──────────────────┘
│ franchise/ │
│ supply │
│ responsible_user │
│ _id → users.id │
└──────────────────┘
@@ -919,11 +1115,11 @@ Authorization: Bearer <token>
| 关系 | 类型 | 说明 |
|------|------|------|
| users → roles | 多对一 | 一个用户属于一个角色,一个角色可以有多个用户 |
| users → employees | 一对一 | 一个用户关联一个员工(通过 employee_id |
| roles ↔ permissions | 多对多 | 通过 role_permissions 中间表关联 |
| customers → users | 多对一 | 通过 responsible_user_id 指定负责人 |
| contracts → customers | 多对一 | 通过 customer_id 关联客户 |
| users → departments | 多对一 | 一个用户属于一个部门,一个部门可以有多个用户 |
| users → employees | 一对一 | 一个用户关联一个员工(通过 employee_id,唯一约束 |
| departments ↔ permissions | 多对多 | 通过 department_permissions 中间表关联 |
| contracts → customers | 多对一 | 通过 customer_id 关联客户(加盟合同必填,采购合同为 null |
| contracts → suppliers | 多对一 | 通过 supplier_id 关联供应商(采购合同必填,加盟合同为 null |
| contracts → employees | 多对一 | 通过 employee_id 关联业务员 |
| contracts → users | 多对一 | 通过 responsible_user_id 指定负责人 |
| after_sales → customers | 多对一 | 通过 customer_id 关联客户 |
@@ -934,20 +1130,20 @@ Authorization: Bearer <token>
---
## RBAC 权限系统
## 部门权限系统
### 角色定义
### 部门定义
系统预置 6 个角色,对应蜜雪冰城总部的组织架构:
系统预置 6 个部门,对应蜜雪冰城总部的组织架构:
| 角色标识 | 中文名 | 所属部门 | 职责概述 |
|---------|--------|---------|---------|
| `admin` | 系统管理员 | 信息技术部 | 系统全权管理,拥有所有权限 |
| `general_manager` | 总经理 | 总经理办公室 | 全局只读,查看所有业务数据 |
| `franchise_manager` | 招商经理 | 招商部 | 管理加盟商和加盟合同 |
| `operations_manager` | 运营经理 | 运营部 | 维护加盟商信息、处理售后 |
| `procurement_manager` | 采购经理 | 采购部 | 管理产品、供应商和采购合同 |
| `finance` | 财务人员 | 财务部 | 只读查看业务数据(加盟商、合同、售后、员工) |
| 部门标识 | 中文名 | 职责概述 |
|---------|--------|---------|
| `admin` | 信息技术部 | 系统全权管理,拥有所有权限 |
| `general_manager` | 总经理办公室 | 全局只读,查看所有业务数据 |
| `franchise_manager` | 招商部 | 管理加盟商和加盟合同 |
| `operations_manager` | 运营部 | 维护加盟商信息、处理售后 |
| `procurement_manager` | 采购部 | 管理产品、供应商和采购合同 |
| `finance` | 财务部 | 只读查看业务数据(加盟商、合同、售后、员工) |
### 权限列表
@@ -988,15 +1184,15 @@ Authorization: Bearer <token>
| **用户 (user)** | |
| `user:manage` | 管理用户账号 |
### 角色-权限映射
### 部门-权限映射
| 权限 | admin | general_manager | franchise_manager | operations_manager | procurement_manager | finance |
|------|:-----:|:---------------:|:-----------------:|:------------------:|:-------------------:|:-------:|
| customer:read | ✅ | ✅ | ✅ | ✅ | | ✅ |
| customer:read | ✅ | ✅ | ✅ | ✅ | | ✅ |
| customer:create | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| customer:update | ✅ | ❌ | ✅ | | ❌ | ❌ |
| customer:update | ✅ | ❌ | ✅ | | ❌ | ❌ |
| customer:delete | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| contract:read | ✅ | ✅ | ✅ | | ✅ | ✅ |
| contract:read | ✅ | ✅ | ✅ | | ✅ | ✅ |
| contract:create | ✅ | ❌ | ✅ | ❌ | ✅ | ❌ |
| contract:update | ✅ | ❌ | ✅ | ❌ | ✅ | ❌ |
| contract:delete | ✅ | ❌ | ✅ | ❌ | ✅ | ❌ |
@@ -1020,25 +1216,64 @@ Authorization: Bearer <token>
### 数据范围隔离
拥有操作权限 ≠ 能看到所有数据。系统通过 `getDataScope()` 函数在 SQL 查询层面做数据隔离:
拥有操作权限 ≠ 能看到所有数据。系统通过 `dataScope()` 中间件在 SQL 查询层面做数据隔离:
| 资源 | admin | general_manager | franchise_manager | operations_manager | procurement_manager | finance |
|------|-------|-----------------|-------------------|--------------------|--------------------|---------|
| customers | 全部 | 全部 | 仅自己负责 | 仅自己负责 | | 全部 |
| contracts | 全部 | 全部 | 仅自己负责的加盟合同 | 仅自己负责的加盟合同 | 仅采购合同 | 全部 |
| after_sales | 全部 | 全部 | — | 仅自己负责 | — | 全部 |
| customers | 全部 | 全部 | 全部(过渡期) | 全部(过渡期) | 全部 | 全部 |
| contracts | 全部 | 全部 | 仅加盟合同 | | 仅采购合同 | 全部 |
| after_sales | 全部 | 全部 | — | 全部(过渡期) | — | 全部 |
| employees | 全部 | 全部 | 仅本部门 | 仅本部门 | 仅本部门 | 全部 |
| products | 全部 | 全部 | — | — | 全部 | — |
| suppliers | 全部 | 全部 | — | — | 全部 | — |
| users | 全部 | — | — | — | — | — |
> **"—"** 表示该角色无此资源的权限,请求会被权限中间件直接拦截(返回 403
> **"—"** 表示该部门无此资源的权限,请求会被 `checkPermission` 中间件直接拦截(返回 403
**隔离机制说明:**
- **负责人类资源**customers、contracts、after_sales通过 `responsible_user_id` 字段过滤,只能看到自己负责的记录
- **合同类型过滤**contracts除了负责人类过滤还按合同 `type` 字段过滤franchise/supply
- **部门类资源**employees通过 `department` 字段过滤,只能看到自己部门的员工
- **全局类资源**products、suppliers无数据范围过滤只要有权限就能看到全部
| 隔离类型 | 资源 | 实现方式 |
|---------|------|---------|
| 合同类型过滤 | contracts | 招商部只能看到 `type = 'franchise'`,采购部只能看到 `type = 'supply'` |
| 部门过滤 | employees | 招商部/运营部/采购部只能看到本部门员工(`department = 本部门名称` |
| 过渡期全量 | customers, after_sales | 当前不注入数据范围,所有部门看到全量数据 |
| 无隔离 | products, suppliers | 无数据范围过滤,有权限即可看到全部 |
**过渡期说明:**
- `customers` 已移除 `responsible_user_id` 字段,不再需要负责人隔离
- `after_sales``dataScope()` 当前为占位状态(不注入任何 scope所有有权限的部门都能看到全量数据
- 后续可基于 `responsible_user_id` 对 contracts/after_sales 激活负责人隔离
**合同类型与部门限制(`validateContractType` 中间件):**
- 招商部(`franchise_manager`)只能创建/修改加盟合同(`type = 'franchise'`
- 采购部(`procurement_manager`)只能创建/修改采购合同(`type = 'supply'`
- 信息技术部/总经理/财务不限制
### 中间件说明
| 中间件 | 文件 | 类型 | 说明 |
|--------|------|------|------|
| `auth` | `server.js` | 认证 | 解析 JWT Token`{ id, username, name, department_id, departmentName, departmentDesc, permissions }` 挂到 `req.user` |
| `checkPermission(resource, action)` | `middleware/permissions.js` | 权限 | 检查 `req.user.permissions` 是否包含 `"resource:action"`,否 → 403 |
| `dataScope(resource)` | `middleware/permissions.js` | 数据范围 | 根据用户部门计算 SQL 过滤条件,挂到 `req.scope = { sql, params }`admin/general_manager 不注入(全量) |
| `validateContractType` | `middleware/permissions.js` | 业务规则 | 限制招商部/采购部只能操作对应类型的合同 |
| `protectSelfUpdate` | `middleware/users.js` | 自保护 | 禁止用户修改自己的部门或禁用自己 |
| `protectUserDelete` | `middleware/users.js` | 自保护 | 禁止用户删除自己或删除信息技术部最后一个管理员 |
| `allowUserCreation` | `middleware/employees.js` | 业务规则 | 允许有 `user:manage` 权限的用户在创建员工时同步创建系统账号 |
**`req.scope` 注入格式:**
```javascript
// 示例:招商部查看合同时注入
req.scope = {
sql: 'AND type = ?',
params: ['franchise']
}
// 路由层机械使用contracts list/detail 需加 c. 别名前缀)
where += ' ' + req.scope.sql.replace(/\btype\b/g, 'c.type')
params.push(...req.scope.params)
```
---
@@ -1046,14 +1281,27 @@ Authorization: Bearer <token>
系统首次启动时自动创建以下账号,密码均为 `123456`
| 用户名 | 姓名 | 角色 | 部门 | 职位 |
|--------|------|------|------|------|
| `admin` | 系统管理员 | admin | 信息技术部 | 系统管理员 |
| `zhangchao` | 张超 | general_manager | 总经理办公室 | 总经理 |
| `liming` | 李明 | franchise_manager | 招商部 | 招商经理 |
| `wangli` | 王丽 | operations_manager | 运营部 | 运营经理 |
| `zhaoqiang` | 赵强 | procurement_manager | 采购部 | 采购经理 |
| `chenfang` | 陈芳 | finance | 财务部 | 财务主管 |
| 用户名 | 姓名 | 部门标识 | 部门 | 职位 |
|--------|------|---------|------|------|
| `admin` | 系统管理员 | `admin` | 信息技术部 | 系统管理员 |
| `zhangchao` | 张超 | `general_manager` | 总经理办公室 | 总经理 |
| `liming` | 李明 | `franchise_manager` | 招商部 | 招商经理 |
| `wangli` | 王丽 | `operations_manager` | 运营部 | 运营经理 |
| `zhaoqiang` | 赵强 | `procurement_manager` | 采购部 | 采购经理 |
| `chenfang` | 陈芳 | `finance` | 财务部 | 财务主管 |
**额外预置账号**(业务员工,同样密码 `123456`
| 用户名 | 姓名 | 部门 | 职位 |
|--------|------|------|------|
| `liuyang` | 刘洋 | 招商部 | 招商专员 |
| `sunting` | 孙婷 | 招商部 | 招商专员 |
| `zhoujie` | 周杰 | 运营部 | 运营专员 |
| `wumin` | 吴敏 | 运营部 | 运营专员 |
| `zhengwei` | 郑伟 | 运营部 | 售后工程师 |
| `huanglei` | 黄磊 | 采购部 | 采购专员 |
| `mali` | 马丽 | 财务部 | 会计 |
| `linfeng` | 林峰 | 总经理办公室 | 副总经理 |
### 快速测试
@@ -1063,7 +1311,7 @@ curl -X POST http://127.0.0.1:3000/api/user/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"123456"}'
# 以招商经理身份登录(只能看到自己负责的加盟商
# 以招商经理身份登录(只能看到加盟合同
curl -X POST http://127.0.0.1:3000/api/user/login \
-H "Content-Type: application/json" \
-d '{"username":"liming","password":"123456"}'

622
db.js
View File

@@ -40,9 +40,9 @@ async function initDB() {
username VARCHAR(50) NOT NULL COMMENT '用户名 (登录账号)',
password VARCHAR(255) NOT NULL COMMENT '登录密码 (应存储加密后的值)',
is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '账号状态: 0-禁用, 1-启用',
role_id INT DEFAULT NULL COMMENT '角色ID (关联roles表)',
department_id INT DEFAULT NULL COMMENT '部门ID (关联departments表)',
employee_id INT DEFAULT NULL COMMENT '员工ID (关联employees表)',
department VARCHAR(100) DEFAULT NULL COMMENT '所属部门 (从employees同步的冗余字段)',
role_level TINYINT NOT NULL DEFAULT 4 COMMENT '角色级别: 1-总经理, 2-部门经理, 3-部门副经理, 4-一般专员',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id),
@@ -61,7 +61,6 @@ async function initDB() {
address VARCHAR(200) DEFAULT NULL COMMENT '详细地址',
email VARCHAR(100) DEFAULT NULL COMMENT '电子邮箱',
remark TEXT DEFAULT NULL COMMENT '备注信息',
responsible_user_id INT DEFAULT NULL COMMENT '负责人用户ID (用于数据范围隔离)',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id),
@@ -170,18 +169,38 @@ async function initDB() {
// 字段已存在则忽略
}
// customers 表删除 responsible_user_id加盟商不需要负责人字段
try {
await pool.query(`ALTER TABLE customers DROP COLUMN responsible_user_id`)
console.log('[init] customers 表已删除 responsible_user_id 字段')
} catch (e) {
// 列已删除则忽略
}
// users 表添加 role_level角色级别
try {
await pool.query(`ALTER TABLE users ADD COLUMN role_level TINYINT NOT NULL DEFAULT 4 COMMENT '角色级别: 1-总经理, 2-部门经理, 3-部门副经理, 4-一般专员' AFTER employee_id`)
console.log('[init] users 表已添加 role_level 字段')
} catch (e) {
// 字段已存在则忽略
}
console.log('[init] 数据表初始化完成')
// ============ 2.3 RBAC 权限相关表 ============
// ============ 2.3 部门与权限相关表 ============
// 迁移旧表
await pool.query('DROP TABLE IF EXISTS role_permissions')
await pool.query('DROP TABLE IF EXISTS roles')
await pool.query(`
CREATE TABLE IF NOT EXISTS roles (
id INT NOT NULL AUTO_INCREMENT COMMENT '角色ID (主键)',
name VARCHAR(50) NOT NULL COMMENT '角色标识 (英文)',
description VARCHAR(200) DEFAULT NULL COMMENT '角色描述 (中文)',
CREATE TABLE IF NOT EXISTS departments (
id INT NOT NULL AUTO_INCREMENT COMMENT '部门ID (主键)',
name VARCHAR(50) NOT NULL COMMENT '部门标识 (英文)',
description VARCHAR(200) DEFAULT NULL COMMENT '部门名称 (中文)',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_roles_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表'
UNIQUE KEY uk_departments_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门表'
`)
await pool.query(`
@@ -197,12 +216,12 @@ async function initDB() {
`)
await pool.query(`
CREATE TABLE IF NOT EXISTS role_permissions (
role_id INT NOT NULL COMMENT '角色ID',
CREATE TABLE IF NOT EXISTS department_permissions (
department_id INT NOT NULL COMMENT '部门ID',
permission_id INT NOT NULL COMMENT '权限ID',
PRIMARY KEY (role_id, permission_id),
KEY idx_rp_permission_id (permission_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色-权限关联表'
PRIMARY KEY (department_id, permission_id),
KEY idx_dp_permission_id (permission_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门-权限关联表'
`)
await pool.query(`
@@ -223,27 +242,47 @@ async function initDB() {
`)
// ============ 2.4 种子数据 ============
await seedRolesAndPermissions()
await seedDepartmentsAndPermissions()
await seedDefaultUsers()
// await seedBusinessData()
// // ============ 2.5 数据库迁移(兼容已有数据的旧库) ============
// // 为财务部补上 product:read 权限
// try {
// const [financeDept] = await pool.query("SELECT id FROM departments WHERE name = 'finance' LIMIT 1")
// if (financeDept.length > 0) {
// const [prodPerm] = await pool.query("SELECT id FROM permissions WHERE name = 'product:read' LIMIT 1")
// if (prodPerm.length > 0) {
// await pool.query(
// 'INSERT IGNORE INTO department_permissions (department_id, permission_id) VALUES (?, ?)',
// [financeDept[0].id, prodPerm[0].id]
// )
// console.log('[migrate] 财务部已补上 product:read 权限')
// }
// }
// } catch (e) {
// console.log('[migrate] 财务部权限迁移跳过:', e.message)
// }
}
// ============ 3. 种子数据:角色与权限 ============
async function seedRolesAndPermissions() {
const [roles] = await pool.query('SELECT id FROM roles LIMIT 1')
if (roles.length > 0) return // 已初始化过,跳过
// ============ 3. 种子数据:部门与权限 ============
async function seedDepartmentsAndPermissions() {
const [depts] = await pool.query('SELECT id FROM departments LIMIT 1')
if (depts.length > 0) return // 已初始化过,跳过
const roleList = [
{ name: 'admin', description: '系统管理员' },
{ name: 'general_manager', description: '总经理' },
{ name: 'franchise_manager', description: '招商经理' },
{ name: 'operations_manager', description: '运营经理' },
{ name: 'procurement_manager', description: '采购经理' },
{ name: 'finance', description: '财务人员' },
const deptList = [
{ name: 'admin', description: '信息技术部' },
{ name: 'general_manager', description: '总经理办公室' },
{ name: 'franchise_manager', description: '招商' },
{ name: 'operations_manager', description: '运营' },
{ name: 'procurement_manager', description: '采购' },
{ name: 'finance', description: '财务' },
{ name: 'hr', description: '人力资源部' },
]
for (const r of roleList) {
await pool.query('INSERT INTO roles (name, description) VALUES (?, ?)', [r.name, r.description])
for (const d of deptList) {
await pool.query('INSERT INTO departments (name, description) VALUES (?, ?)', [d.name, d.description])
}
console.log('[init] 已初始化 6角色')
console.log('[init] 已初始化 7部门')
const permissionList = [
{ name: 'customer:read', description: '查看加盟商', resource: 'customer', action: 'read' },
@@ -280,13 +319,17 @@ async function seedRolesAndPermissions() {
}
console.log('[init] 已初始化 25 个权限')
// 角色-权限映射
const allPerms = permissionList.map(p => p.name)
const rolePermMap = {
admin: allPerms, // 管理员拥有所有权限
// 部门-权限映射
const deptPermMap = {
admin: [
'user:manage',
'employee:read',
],
general_manager: [
'customer:read', 'contract:read', 'after_sale:read',
'product:read', 'supplier:read', 'employee:read',
'product:read', 'supplier:read',
'employee:read', 'employee:create', 'employee:update', 'employee:delete',
'user:manage',
],
franchise_manager: [
'customer:read', 'customer:create', 'customer:update', 'customer:delete',
@@ -294,44 +337,49 @@ async function seedRolesAndPermissions() {
'employee:read',
],
operations_manager: [
'customer:read', 'customer:update',
'contract:read',
'customer:read',
'after_sale:read', 'after_sale:create', 'after_sale:update', 'after_sale:delete',
'employee:read',
],
procurement_manager: [
'customer:read',
'contract:read', 'contract:create', 'contract:update', 'contract:delete',
'product:read', 'product:create', 'product:update', 'product:delete',
'supplier:read', 'supplier:create', 'supplier:update', 'supplier:delete',
'employee:read',
],
finance: [
'customer:read', 'contract:read', 'after_sale:read', 'employee:read',
'customer:read', 'contract:read', 'after_sale:read', 'product:read', 'employee:read',
'supplier:read',
],
hr: [
'employee:read', 'employee:create', 'employee:update', 'employee:delete',
'user:manage',
],
}
const [roleRows] = await pool.query('SELECT id, name FROM roles')
const [deptRows] = await pool.query('SELECT id, name FROM departments')
const [permRows] = await pool.query('SELECT id, name FROM permissions')
const roleIdMap = Object.fromEntries(roleRows.map(r => [r.name, r.id]))
const deptIdMap = Object.fromEntries(deptRows.map(d => [d.name, d.id]))
const permIdMap = Object.fromEntries(permRows.map(p => [p.name, p.id]))
for (const [roleName, permNames] of Object.entries(rolePermMap)) {
for (const [deptName, permNames] of Object.entries(deptPermMap)) {
for (const permName of permNames) {
await pool.query(
'INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)',
[roleIdMap[roleName], permIdMap[permName]]
'INSERT IGNORE INTO department_permissions (department_id, permission_id) VALUES (?, ?)',
[deptIdMap[deptName], permIdMap[permName]]
)
}
}
console.log('[init] 已初始化角色-权限映射')
console.log('[init] 已初始化部门-权限映射')
}
// ============ 4. 种子数据:默认用户 ============
// ============ 4. 种子数据:默认用户(仅保留总经理账号) ============
async function seedDefaultUsers() {
const hash = await bcrypt.hash('123456', 10)
const [roleRows] = await pool.query('SELECT id, name FROM roles')
const roleIdMap = Object.fromEntries(roleRows.map(r => [r.name, r.id]))
const [deptRows] = await pool.query('SELECT id, name FROM departments')
const deptIdMap = Object.fromEntries(deptRows.map(d => [d.name, d.id]))
const seedEmployees = [
{ name: '张超', gender: '男', age: 42, education: '硕士', department: '总经理办公室', entry_date: '2015-03-01', position: '总经理', salary: 50000, phone: '13800001001', email: 'zhangchao@mixue.com' },
@@ -340,15 +388,17 @@ async function seedDefaultUsers() {
{ name: '赵强', gender: '男', age: 36, education: '本科', department: '采购部', entry_date: '2017-09-20', position: '采购经理', salary: 18000, phone: '13800001004', email: 'zhaoqiang@mixue.com' },
{ name: '陈芳', gender: '女', age: 30, education: '本科', department: '财务部', entry_date: '2020-03-05', position: '财务主管', salary: 15000, phone: '13800001005', email: 'chenfang@mixue.com' },
{ name: '系统管理员', gender: '男', age: 28, education: '本科', department: '信息技术部', entry_date: '2022-07-01', position: '系统管理员', salary: 12000, phone: '13800001006', email: 'admin@mixue.com' },
{ name: '杨帆', gender: '女', age: 34, education: '本科', department: '人力资源部', entry_date: '2019-05-01', position: '人力资源经理', salary: 15000, phone: '13800001007', email: 'yangfan@mixue.com' },
]
const seedUsers = [
{ username: 'zhangchao', roleName: 'general_manager' },
{ username: 'liming', roleName: 'franchise_manager' },
{ username: 'wangli', roleName: 'operations_manager' },
{ username: 'zhaoqiang', roleName: 'procurement_manager' },
{ username: 'chenfang', roleName: 'finance' },
{ username: 'admin', roleName: 'admin' },
{ username: 'zhangchao', deptName: 'general_manager', role_level: 1 },
{ username: 'liming', deptName: 'franchise_manager', role_level: 2 },
{ username: 'wangli', deptName: 'operations_manager', role_level: 2 },
{ username: 'zhaoqiang', deptName: 'procurement_manager',role_level: 2 },
{ username: 'chenfang', deptName: 'finance', role_level: 2 },
{ username: 'admin', deptName: 'admin', role_level: 2 },
{ username: 'yangfan', deptName: 'hr', role_level: 2 },
]
for (let i = 0; i < seedUsers.length; i++) {
@@ -365,15 +415,469 @@ async function seedDefaultUsers() {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`,
[emp.name, emp.gender, emp.age, emp.education, emp.department, emp.entry_date, emp.position, emp.salary, emp.phone, emp.email]
)
const empId = empResult.insertId
// 再插入 users关联 employee 和 role
await pool.query(
`INSERT INTO users (username, password, is_active, role_id, employee_id, department)
`INSERT INTO users (username, password, is_active, department_id, employee_id, role_level)
VALUES (?, ?, 1, ?, ?, ?)`,
[usr.username, hash, roleIdMap[usr.roleName], empId, emp.department]
[usr.username, hash, deptIdMap[usr.deptName], empId, usr.role_level]
)
console.log(`[init] 已创建用户 ${usr.username} (${emp.name} - ${emp.department})`)
console.log(`[init] 已创建用户 ${usr.username} (${emp.name})`)
}
}
// ============ 5. 种子数据:业务数据 ============
async function seedBusinessData() {
// ---- 5.1 新增员工 ----
const [empCheck] = await pool.query("SELECT id FROM employees WHERE name = '刘洋' LIMIT 1")
if (empCheck.length === 0) {
const extraEmployees = [
{ name: '刘洋', gender: '男', age: 28, education: '本科', department: '招商部', entry_date: '2021-03-15', position: '招商专员', salary: 8000, phone: '13800002001', email: 'liuyang@mixue.com' },
{ name: '孙婷', gender: '女', age: 26, education: '本科', department: '招商部', entry_date: '2022-02-20', position: '招商专员', salary: 7500, phone: '13800002002', email: 'sunting@mixue.com' },
{ name: '周杰', gender: '男', age: 30, education: '本科', department: '运营部', entry_date: '2021-06-01', position: '运营专员', salary: 8500, phone: '13800002003', email: 'zhoujie@mixue.com' },
{ name: '吴敏', gender: '女', age: 25, education: '本科', department: '运营部', entry_date: '2023-01-10', position: '运营专员', salary: 7000, phone: '13800002004', email: 'wumin@mixue.com' },
{ name: '郑伟', gender: '男', age: 32, education: '大专', department: '运营部', entry_date: '2020-08-15', position: '售后工程师', salary: 9000, phone: '13800002005', email: 'zhengwei@mixue.com' },
{ name: '黄磊', gender: '男', age: 29, education: '本科', department: '采购部', entry_date: '2022-04-01', position: '采购专员', salary: 8000, phone: '13800002006', email: 'huanglei@mixue.com' },
{ name: '马丽', gender: '女', age: 27, education: '本科', department: '财务部', entry_date: '2021-09-01', position: '会计', salary: 7500, phone: '13800002007', email: 'mali@mixue.com' },
{ name: '林峰', gender: '男', age: 45, education: '硕士', department: '总经理办公室', entry_date: '2016-05-01', position: '副总经理', salary: 35000, phone: '13800002008', email: 'linfeng@mixue.com' },
]
for (const e of extraEmployees) {
await pool.query(
`INSERT INTO employees (name, gender, age, education, department, entry_date, position, salary, phone, email, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`,
[e.name, e.gender, e.age, e.education, e.department, e.entry_date, e.position, e.salary, e.phone, e.email]
)
}
console.log('[init] 已初始化 8 条员工数据')
}
// ---- 5.1b 为业务员工创建用户账号 ----
const hash = await bcrypt.hash('123456', 10)
const [deptRows] = await pool.query('SELECT id, name FROM departments')
const deptIdMap2 = Object.fromEntries(deptRows.map(d => [d.name, d.id]))
const extraUsers = [
{ empName: '刘洋', username: 'liuyang', deptName: 'franchise_manager', role_level: 4 },
{ empName: '孙婷', username: 'sunting', deptName: 'franchise_manager', role_level: 4 },
{ empName: '周杰', username: 'zhoujie', deptName: 'operations_manager', role_level: 4 },
{ empName: '吴敏', username: 'wumin', deptName: 'operations_manager', role_level: 4 },
{ empName: '郑伟', username: 'zhengwei', deptName: 'operations_manager', role_level: 4 },
{ empName: '黄磊', username: 'huanglei', deptName: 'procurement_manager',role_level: 4 },
{ empName: '马丽', username: 'mali', deptName: 'finance', role_level: 4 },
{ empName: '林峰', username: 'linfeng', deptName: 'general_manager', role_level: 2 },
]
const [newEmps] = await pool.query("SELECT id, name, department FROM employees WHERE name IN ('刘洋','孙婷','周杰','吴敏','郑伟','黄磊','马丽','林峰')")
const newEmpMap = Object.fromEntries(newEmps.map(e => [e.name, e]))
for (const u of extraUsers) {
const emp = newEmpMap[u.empName]
if (!emp) continue
const [userCheck] = await pool.query('SELECT id FROM users WHERE username = ?', [u.username])
if (userCheck.length > 0) continue
await pool.query(
'INSERT INTO users (username, password, is_active, department_id, employee_id, role_level) VALUES (?, ?, 1, ?, ?, ?)',
[u.username, hash, deptIdMap2[u.deptName], emp.id, u.role_level]
)
console.log(`[init] 已创建用户 ${u.username} (${u.empName} - ${emp.department})`)
}
// 查询员工 ID 映射(用于合同和售后单)
const [allEmps] = await pool.query('SELECT id, name FROM employees')
const empIdMap = Object.fromEntries(allEmps.map(e => [e.name, e.id]))
// ---- 5.2 供应商 ----
const [supCheck] = await pool.query("SELECT id FROM suppliers WHERE name = '郑州瑞丰食品原料有限公司' LIMIT 1")
if (supCheck.length === 0) {
const suppliers = [
{ name: '郑州瑞丰食品原料有限公司', contact: '张建国', phone: '0371-66881234', address: '郑州市经开区食品工业园A区12号', type: '原料', content: '主要供应茶叶、椰浆、果酱等茶饮原料合作5年以上' },
{ name: '河南优品包装科技有限公司', contact: '李文华', phone: '0371-66885678', address: '郑州市高新区包装产业园B栋', type: '包装', content: '提供PET杯、PP杯、杯盖、吸管等包装材料' },
{ name: '广州冰泉制冰设备有限公司', contact: '陈志强', phone: '020-88661234', address: '广州市番禺区工业路88号', type: '设备', content: '制冰机、冷藏柜等冷链设备供应商' },
{ name: '武汉华源茶叶有限公司', contact: '王秀英', phone: '027-85661234', address: '武汉市江汉区茶业批发市场C区', type: '原料', content: '优质茶叶供应商,提供茉莉绿茶、红茶、乌龙茶等' },
{ name: '浙江鑫达纸杯制品有限公司', contact: '赵明', phone: '0571-88771234', address: '杭州市萧山区纸品工业园区6号', type: '包装', content: '定制印刷纸杯和包装袋' },
{ name: '山东齐鲁冷链物流有限公司', contact: '刘大海', phone: '0531-88661234', address: '济南市历城区物流产业园东区', type: '原料', content: '冷链物流服务,负责奶粉、椰浆等冷链原料配送' },
]
for (const s of suppliers) {
await pool.query(
'INSERT INTO suppliers (name, contact, phone, address, type, content, status) VALUES (?, ?, ?, ?, ?, ?, 1)',
[s.name, s.contact, s.phone, s.address, s.type, s.content]
)
}
console.log('[init] 已初始化 6 家供应商')
}
// 查询供应商名称(供货合同引用)
const [supRows] = await pool.query('SELECT id, name FROM suppliers')
const supIdMap = Object.fromEntries(supRows.map(s => [s.name, s.id]))
// ---- 5.3 产品/原料 ----
const [prodCheck] = await pool.query("SELECT id FROM products WHERE name = '茉莉绿茶' LIMIT 1")
if (prodCheck.length === 0) {
const products = [
// 茶饮原料
{ name: '茉莉绿茶', type: '茶饮原料', quantity: 5000, price: 28.00, unit: 'kg', specification: '500g/袋', supplier: '武汉华源茶叶有限公司' },
{ name: '红茶', type: '茶饮原料', quantity: 3000, price: 32.00, unit: 'kg', specification: '500g/袋', supplier: '武汉华源茶叶有限公司' },
{ name: '乌龙茶', type: '茶饮原料', quantity: 2000, price: 45.00, unit: 'kg', specification: '500g/袋', supplier: '武汉华源茶叶有限公司' },
{ name: '椰浆', type: '茶饮原料', quantity: 8000, price: 12.50, unit: 'L', specification: '1L/盒', supplier: '郑州瑞丰食品原料有限公司' },
{ name: '果糖糖浆', type: '茶饮原料', quantity: 6000, price: 8.00, unit: 'L', specification: '2.5L/桶', supplier: '郑州瑞丰食品原料有限公司' },
{ name: '奶粉', type: '茶饮原料', quantity: 3000, price: 35.00, unit: 'kg', specification: '1kg/袋', supplier: '山东齐鲁冷链物流有限公司' },
{ name: '珍珠粉圆', type: '茶饮原料', quantity: 10000, price: 6.50, unit: 'kg', specification: '1kg/袋', supplier: '郑州瑞丰食品原料有限公司' },
{ name: '红豆罐头', type: '茶饮原料', quantity: 2000, price: 9.80, unit: '罐', specification: '400g/罐', supplier: '郑州瑞丰食品原料有限公司' },
{ name: '芒果果酱', type: '茶饮原料', quantity: 3000, price: 18.00, unit: 'kg', specification: '1kg/瓶', supplier: '郑州瑞丰食品原料有限公司' },
{ name: '草莓果酱', type: '茶饮原料', quantity: 2500, price: 20.00, unit: 'kg', specification: '1kg/瓶', supplier: '郑州瑞丰食品原料有限公司' },
{ name: '柠檬浓缩汁', type: '茶饮原料', quantity: 4000, price: 15.00, unit: 'L', specification: '1L/瓶', supplier: '郑州瑞丰食品原料有限公司' },
{ name: '芋泥粉', type: '茶饮原料', quantity: 1500, price: 22.00, unit: 'kg', specification: '500g/袋', supplier: '郑州瑞丰食品原料有限公司' },
// 包装物料
{ name: '500ml PET杯', type: '包装物料', quantity: 200000, price: 0.15, unit: '个', specification: '500ml 透明', supplier: '河南优品包装科技有限公司' },
{ name: '700ml PP杯', type: '包装物料', quantity: 150000, price: 0.20, unit: '个', specification: '700ml 白色', supplier: '浙江鑫达纸杯制品有限公司' },
{ name: '杯盖', type: '包装物料', quantity: 300000, price: 0.08, unit: '个', specification: '平盖+凸盖 各半', supplier: '河南优品包装科技有限公司' },
{ name: '吸管', type: '包装物料', quantity: 500000, price: 0.03, unit: '根', specification: '粗吸管 12mm', supplier: '河南优品包装科技有限公司' },
// 设备器具
{ name: '制冰机', type: '设备器具', quantity: 50, price: 5800.00, unit: '台', specification: '日产冰量100kg', supplier: '广州冰泉制冰设备有限公司' },
{ name: '封口机', type: '设备器具', quantity: 80, price: 2200.00, unit: '台', specification: '全自动杯装封口', supplier: '广州冰泉制冰设备有限公司' },
]
for (const p of products) {
await pool.query(
'INSERT INTO products (name, type, quantity, price, unit, specification, supplier) VALUES (?, ?, ?, ?, ?, ?, ?)',
[p.name, p.type, p.quantity, p.price, p.unit, p.specification, p.supplier]
)
}
console.log('[init] 已初始化 18 个产品')
}
// ---- 5.4 加盟商 (customers) ----
const [custCheck] = await pool.query("SELECT id FROM customers WHERE name = '赵鑫磊' LIMIT 1")
if (custCheck.length === 0) {
const customers = [
{ name: '赵鑫磊', phone: '13912345001', province: '河南省', city: '郑州市', district: '金水区', address: '花园路与农业路交叉口向南200米', email: 'zhaoxinlei@qq.com', remark: '老加盟商经营3年业绩优秀' },
{ name: '钱小燕', phone: '13912345002', province: '河南省', city: '洛阳市', district: '西工区', address: '中州中路王府井百货对面', email: 'qianxiaoyan@qq.com', remark: '2024年新开店经营情况良好' },
{ name: '孙大伟', phone: '13912345003', province: '山东省', city: '济南市', district: '历下区', address: '泉城路188号', email: 'sundawei@qq.com', remark: '大学城附近,客流量大' },
{ name: '李美玲', phone: '13912345004', province: '山东省', city: '青岛市', district: '市南区', address: '香港中路佳世客商场一楼', email: 'limeiling@qq.com', remark: '商场店,租金较高' },
{ name: '周五六', phone: '13912345005', province: '江苏省', city: '南京市', district: '鼓楼区', address: '新街口中山南路128号', email: 'zhouwuliu@qq.com', remark: '核心商圈,品牌效应好' },
{ name: '吴建华', phone: '13912345006', province: '江苏省', city: '苏州市', district: '姑苏区', address: '观前街太监弄18号', email: 'wujianhua@qq.com', remark: '旅游区店铺,夏季旺季营业额高' },
{ name: '郑雪梅', phone: '13912345007', province: '四川省', city: '成都市', district: '锦江区', address: '春熙路步行街IFS旁', email: 'zhengxuemei@qq.com', remark: '新加盟商,正在筹备开业' },
{ name: '王鹏飞', phone: '13912345008', province: '四川省', city: '成都市', district: '武侯区', address: '科华北路65号川大旁', email: 'wangpengfei@qq.com', remark: '学校周边,学生客群为主' },
{ name: '冯国强', phone: '13912345009', province: '广东省', city: '广州市', district: '天河区', address: '天河路228号正佳广场B1层', email: 'fengguoqiang@qq.com', remark: '商场负一楼,人流密集' },
{ name: '陈小凤', phone: '13912345010', province: '广东省', city: '深圳市', district: '南山区', address: '南海大道与东滨路交汇处', email: 'chenxiaofeng@qq.com', remark: '写字楼区,白领消费为主' },
{ name: '褚明亮', phone: '13912345011', province: '浙江省', city: '杭州市', district: '西湖区', address: '文三路与学院路交叉口', email: 'chumingliang@qq.com', remark: '2023年开业已回本' },
{ name: '卫国庆', phone: '13912345012', province: '湖北省', city: '武汉市', district: '武昌区', address: '中南路中商广场一楼', email: 'weiguoqing@qq.com', remark: '老加盟商,有意向开第二家' },
]
for (const c of customers) {
await pool.query(
`INSERT INTO customers (name, phone, province, city, district, address, email, remark)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[c.name, c.phone, c.province, c.city, c.district, c.address, c.email, c.remark]
)
}
console.log('[init] 已初始化 12 个加盟商')
}
// 查询加盟商 ID 映射
const [custRows] = await pool.query('SELECT id, name FROM customers')
const custIdMap = Object.fromEntries(custRows.map(c => [c.name, c.id]))
// 查询 liming 和 wangli 的 user.id
const [userRows] = await pool.query("SELECT id, username FROM users WHERE username IN ('liming', 'wangli', 'admin')")
const userIdMap = Object.fromEntries(userRows.map(u => [u.username, u.id]))
// 查询 employee_id → user_id 映射(用于合同种子数据:业务员即负责人)
const [userEmpRows] = await pool.query('SELECT id, employee_id FROM users WHERE employee_id IS NOT NULL')
const userByEmpIdMap = Object.fromEntries(userEmpRows.map(u => [u.employee_id, u.id]))
// ---- 5.5 合同 (contracts) ----
const [contractCheck] = await pool.query("SELECT id FROM contracts WHERE contract_no = 'MX-JM-2024-001' LIMIT 1")
if (contractCheck.length === 0) {
// 加盟合同
const franchiseContracts = [
{
contract_name: '蜜雪冰城加盟合同(赵鑫磊-郑州花园路店)',
contract_no: 'MX-JM-2024-001', customer_name: '赵鑫磊', employee_name: '刘洋',
amount: 98000.00, effective_date: '2023-06-01', expiry_date: '2028-05-31',
status: '生效', type: 'franchise',
contract_content: '甲方授权乙方在郑州市金水区花园路开设蜜雪冰城加盟门店合同期限5年。加盟费8万元保证金1.8万元。',
remark: '经营3年续约意愿强',
},
{
contract_name: '蜜雪冰城加盟合同(钱小燕-洛阳中州路店)',
contract_no: 'MX-JM-2024-002', customer_name: '钱小燕', employee_name: '刘洋',
amount: 88000.00, effective_date: '2024-03-15', expiry_date: '2029-03-14',
status: '生效', type: 'franchise',
contract_content: '甲方授权乙方在洛阳市西工区中州路开设蜜雪冰城加盟门店合同期限5年。加盟费7万元保证金1.8万元。',
remark: '新加盟商,已完成培训',
},
{
contract_name: '蜜雪冰城加盟合同(孙大伟-济南泉城路店)',
contract_no: 'MX-JM-2024-003', customer_name: '孙大伟', employee_name: '孙婷',
amount: 108000.00, effective_date: '2023-09-01', expiry_date: '2028-08-31',
status: '生效', type: 'franchise',
contract_content: '甲方授权乙方在济南市历下区泉城路开设蜜雪冰城加盟门店合同期限5年。加盟费9万元保证金1.8万元。',
remark: '大学城门店日均出杯量600+',
},
{
contract_name: '蜜雪冰城加盟合同(李美玲-青岛香港中路店)',
contract_no: 'MX-JM-2024-004', customer_name: '李美玲', employee_name: '孙婷',
amount: 128000.00, effective_date: '2024-01-10', expiry_date: '2029-01-09',
status: '生效', type: 'franchise',
contract_content: '甲方授权乙方在青岛市市南区香港中路商场开设蜜雪冰城加盟门店合同期限5年。加盟费11万元保证金1.8万元。',
remark: '商场店,租金较高但人流有保障',
},
{
contract_name: '蜜雪冰城加盟合同(周五六-南京新街口店)',
contract_no: 'MX-JM-2024-005', customer_name: '周五六', employee_name: '刘洋',
amount: 148000.00, effective_date: '2023-04-20', expiry_date: '2028-04-19',
status: '生效', type: 'franchise',
contract_content: '甲方授权乙方在南京市鼓楼区新街口商圈开设蜜雪冰城加盟门店合同期限5年。加盟费12万元保证金1.8万元品牌使用费1万元。',
remark: '核心商圈旗舰店,月营业额稳定',
},
{
contract_name: '蜜雪冰城加盟合同(吴建华-苏州观前街店)',
contract_no: 'MX-JM-2024-006', customer_name: '吴建华', employee_name: '孙婷',
amount: 118000.00, effective_date: '2024-05-01', expiry_date: '2029-04-30',
status: '生效', type: 'franchise',
contract_content: '甲方授权乙方在苏州市姑苏区观前街开设蜜雪冰城加盟门店合同期限5年。加盟费10万元保证金1.8万元。',
remark: '旅游区店铺,夏季月营业额翻倍',
},
{
contract_name: '蜜雪冰城加盟合同(郑雪梅-成都春熙路店)',
contract_no: 'MX-JM-2024-007', customer_name: '郑雪梅', employee_name: '刘洋',
amount: 138000.00, effective_date: '2025-01-15', expiry_date: '2030-01-14',
status: '草稿', type: 'franchise',
contract_content: '甲方授权乙方在成都市锦江区春熙路商圈开设蜜雪冰城加盟门店合同期限5年。加盟费12万元保证金1.8万元。',
remark: '正在审核中,预计下月签约',
},
{
contract_name: '蜜雪冰城加盟合同(王鹏飞-成都科华路店)',
contract_no: 'MX-JM-2024-008', customer_name: '王鹏飞', employee_name: '孙婷',
amount: 88000.00, effective_date: '2023-08-10', expiry_date: '2028-08-09',
status: '已完成', type: 'franchise',
contract_content: '甲方授权乙方在成都市武侯区科华路川大旁开设蜜雪冰城加盟门店合同期限5年。加盟费7万元保证金1.8万元。',
remark: '合同期满未续约',
},
{
contract_name: '蜜雪冰城加盟合同(冯国强-广州正佳广场店)',
contract_no: 'MX-JM-2024-009', customer_name: '冯国强', employee_name: '刘洋',
amount: 158000.00, effective_date: '2024-02-01', expiry_date: '2029-01-31',
status: '生效', type: 'franchise',
contract_content: '甲方授权乙方在广州市天河区正佳广场B1层开设蜜雪冰城加盟门店合同期限5年。加盟费13万元保证金1.8万元品牌使用费1万元。',
remark: '商场负一楼黄金位置',
},
{
contract_name: '蜜雪冰城加盟合同(陈小凤-深圳南山店)',
contract_no: 'MX-JM-2024-010', customer_name: '陈小凤', employee_name: '孙婷',
amount: 108000.00, effective_date: '2023-11-20', expiry_date: '2028-11-19',
status: '已完成', type: 'franchise',
contract_content: '甲方授权乙方在深圳市南山区南海大道开设蜜雪冰城加盟门店合同期限5年。加盟费9万元保证金1.8万元。',
remark: '合同正常到期完成',
},
]
for (const c of franchiseContracts) {
const empId = empIdMap[c.employee_name]
await pool.query(
`INSERT INTO contracts (contract_name, contract_no, customer_id, employee_id, amount, effective_date, expiry_date, status, type, contract_content, remark, responsible_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[c.contract_name, c.contract_no, custIdMap[c.customer_name], empId,
c.amount, c.effective_date, c.expiry_date, c.status, c.type, c.contract_content, c.remark,
userByEmpIdMap[empId]]
)
}
// 供货合同
const supplyContracts = [
{
contract_name: '2024年度茶叶原料供货合同',
contract_no: 'MX-GH-2024-001', supplier_name: '武汉华源茶叶有限公司', employee_name: '黄磊',
amount: 360000.00, effective_date: '2024-01-01', expiry_date: '2024-12-31',
status: '生效', type: 'supply',
contract_content: '甲方向乙方采购茉莉绿茶、红茶、乌龙茶等茶叶原料年度采购量不低于30吨价格按季度协商调整。',
remark: '年度框架协议',
},
{
contract_name: '2024年度包装材料供货合同',
contract_no: 'MX-GH-2024-002', supplier_name: '河南优品包装科技有限公司', employee_name: '黄磊',
amount: 280000.00, effective_date: '2024-01-01', expiry_date: '2024-12-31',
status: '生效', type: 'supply',
contract_content: '甲方向乙方采购PET杯、PP杯、杯盖、吸管等包装材料按月下单每次不低于10万只。',
remark: '长期合作伙伴',
},
{
contract_name: '2024年度设备采购合同',
contract_no: 'MX-GH-2024-003', supplier_name: '广州冰泉制冰设备有限公司', employee_name: '黄磊',
amount: 520000.00, effective_date: '2024-03-01', expiry_date: '2025-02-28',
status: '生效', type: 'supply',
contract_content: '甲方向乙方采购制冰机、封口机、冷藏柜等门店设备,含安装调试和一年质保服务。',
remark: '新店开业批量采购',
},
{
contract_name: '2023年度食品原料供货合同',
contract_no: 'MX-GH-2023-001', supplier_name: '郑州瑞丰食品原料有限公司', employee_name: '黄磊',
amount: 450000.00, effective_date: '2023-01-01', expiry_date: '2023-12-31',
status: '已完成', type: 'supply',
contract_content: '甲方向乙方采购椰浆、果糖糖浆、珍珠粉圆、果酱等茶饮辅料原料,年度框架协议。',
remark: '已正常履约完毕',
},
{
contract_name: '2023年度冷链配送服务合同',
contract_no: 'MX-GH-2023-002', supplier_name: '山东齐鲁冷链物流有限公司', employee_name: '黄磊',
amount: 180000.00, effective_date: '2023-04-01', expiry_date: '2024-03-31',
status: '已完成', type: 'supply',
contract_content: '甲方委托乙方提供奶粉、椰浆等冷链原料的仓储和配送服务,覆盖华中和华东区域。',
remark: '服务评价良好',
},
]
for (const c of supplyContracts) {
const empId = empIdMap[c.employee_name]
await pool.query(
`INSERT INTO contracts (contract_name, contract_no, supplier_id, employee_id, amount, effective_date, expiry_date, status, type, contract_content, remark, responsible_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[c.contract_name, c.contract_no, supIdMap[c.supplier_name], empId,
c.amount, c.effective_date, c.expiry_date, c.status, c.type, c.contract_content, c.remark,
userByEmpIdMap[empId]]
)
}
console.log('[init] 已初始化 15 份合同10 份加盟 + 5 份供货)')
}
// ---- 5.6 售后单 (after_sales) ----
const [asCheck] = await pool.query('SELECT id FROM after_sales LIMIT 1')
if (asCheck.length === 0) {
const afterSales = [
{
customer_name: '赵鑫磊', employee_name: '郑伟',
feedback: '门店制冰机运行噪音突然变大,制冰速度明显下降,疑似压缩机故障。',
handle_method: '已安排售后工程师上门检修,确认为压缩机老化,已更换配件。',
handle_status: '已完成', service_date: '2024-11-15',
remark: '配件已更换,质保期内免费',
responsible_user: 'wangli',
},
{
customer_name: '孙大伟', employee_name: '郑伟',
feedback: '封口机经常卡杯每天至少出现5次严重影响出杯效率。',
handle_method: '远程指导清洁封口模具,如仍无法解决将安排上门维修。',
handle_status: '处理中', service_date: '2024-12-20',
remark: '已远程指导,待反馈效果',
responsible_user: 'wangli',
},
{
customer_name: '钱小燕', employee_name: '周杰',
feedback: '收到的珍珠粉圆有异味,疑似变质,要求退换货。',
handle_method: null,
handle_status: '待处理', service_date: '2025-01-05',
remark: '已联系供应商核实批次',
responsible_user: 'wangli',
},
{
customer_name: '周五六', employee_name: '周杰',
feedback: '新开店需要进行新品制作培训,包括冬季热饮系列和甜品系列。',
handle_method: '已安排运营专员下周到店进行3天驻店培训。',
handle_status: '处理中', service_date: '2025-01-10',
remark: '培训计划已发送',
responsible_user: 'wangli',
},
{
customer_name: '冯国强', employee_name: '吴敏',
feedback: '果糖机出糖量不稳定,有时偏多有时偏少,导致饮品口感不一致。',
handle_method: '已安排校准果糖机,更换流量计。',
handle_status: '已完成', service_date: '2024-10-28',
remark: '校准后恢复正常',
responsible_user: 'wangli',
},
{
customer_name: '李美玲', employee_name: '吴敏',
feedback: '新店开业前需要门店装修指导和设备布局方案。',
handle_method: '已派运营专员到店指导装修,提供标准门店布局图纸。',
handle_status: '已完成', service_date: '2024-01-05',
remark: '装修已完成,符合标准',
responsible_user: 'wangli',
},
{
customer_name: '吴建华', employee_name: '郑伟',
feedback: '冷藏展示柜温度显示异常实际温度与显示温度相差5度。',
handle_method: null,
handle_status: '待处理', service_date: '2025-01-08',
remark: '已安排就近工程师上门',
responsible_user: 'wangli',
},
{
customer_name: '陈小凤', employee_name: '周杰',
feedback: '希望总部提供暑期营销活动策划支持和宣传物料。',
handle_method: '已提供暑期活动方案和配套宣传海报、立牌设计稿。',
handle_status: '已完成', service_date: '2024-06-15',
remark: '活动期间营业额提升30%',
responsible_user: 'wangli',
},
{
customer_name: '王鹏飞', employee_name: '吴敏',
feedback: '草莓果酱批次口感偏酸,与之前供货品质不一致,学生客户投诉较多。',
handle_method: null,
handle_status: '待处理', service_date: '2025-01-12',
remark: '需要取样送检',
responsible_user: 'wangli',
},
{
customer_name: '郑雪梅', employee_name: '周杰',
feedback: '新店筹备中,需要开业前全套培训,包括设备操作、产品制作、门店管理等。',
handle_method: '已制定5天培训计划安排运营专员驻店指导开业。',
handle_status: '处理中', service_date: '2025-01-15',
remark: '预计2月初完成培训并开业',
responsible_user: 'wangli',
},
]
for (const a of afterSales) {
const empId = empIdMap[a.employee_name]
await pool.query(
`INSERT INTO after_sales (customer_id, employee_id, feedback, handle_method, handle_status, service_date, remark, responsible_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[custIdMap[a.customer_name], empId, a.feedback,
a.handle_method, a.handle_status, a.service_date, a.remark, userByEmpIdMap[empId]]
)
}
console.log('[init] 已初始化 10 条售后单')
}
// ---- 5.7 测试/预览用补充数据 ----
// 补充加盟商(分配给运营部用户,测试跨部门可见性)
const [custCheck2] = await pool.query("SELECT id FROM customers WHERE name = '测试加盟商-OPS' LIMIT 1")
if (custCheck2.length === 0) {
const testCustomers = [
{ name: '测试加盟商-OPS', phone: '13999990001', province: '河南省', city: '郑州市', district: '金水区', address: '测试路1号' },
{ name: '测试加盟商-PROC', phone: '13999990002', province: '山东省', city: '济南市', district: '历下区', address: '测试路2号' },
]
for (const c of testCustomers) {
await pool.query(
'INSERT INTO customers (name, phone, province, city, district, address) VALUES (?, ?, ?, ?, ?, ?)',
[c.name, c.phone, c.province, c.city, c.district, c.address]
)
}
console.log('[init] 已初始化 2 条测试加盟商')
// 补充一个采购合同(归属采购部,用于测试数据范围)
const [custOPS] = await pool.query("SELECT id FROM customers WHERE name = '测试加盟商-OPS' LIMIT 1")
const [empZhao] = await pool.query("SELECT id FROM employees WHERE name = '赵强' LIMIT 1")
if (custOPS.length > 0) {
await pool.query(
`INSERT INTO contracts (contract_name, contract_no, customer_id, employee_id, amount, effective_date, expiry_date, status, type, responsible_user_id, remark)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
['测试采购合同-2025', 'MX-GH-TEST-001', custOPS[0].id, empZhao.length > 0 ? empZhao[0].id : null,
150000, '2025-03-01', '2026-02-28', '生效', 'supply',
empZhao.length > 0 ? (userByEmpIdMap[empZhao[0].id] || uidMap['zhaoqiang']) : null, '测试用采购合同']
)
}
// 补充售后单(归属运营部)
const [custA] = await pool.query("SELECT id FROM customers WHERE name = '赵鑫磊' LIMIT 1")
const [empZheng] = await pool.query("SELECT id FROM employees WHERE name = '郑伟' LIMIT 1")
if (custA.length > 0) {
const empZhengId = empZheng.length > 0 ? empZheng[0].id : null
await pool.query(
`INSERT INTO after_sales (customer_id, employee_id, feedback, handle_method, handle_status, service_date, remark, responsible_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[custA[0].id, empZhengId,
'测试售后-夏季冰块供应不足', '协调增加配送频次', '处理中', '2025-06-01', '旺季保障',
empZhengId ? userByEmpIdMap[empZhengId] : uidMap['wangli']]
)
}
console.log('[init] 已初始化测试用合同和售后单')
}
}

64
middleware/employees.js Normal file
View File

@@ -0,0 +1,64 @@
// middleware/employees.js —— 员工管理的业务规则中间件
/**
* 允许在创建员工时一并创建系统用户账号(需 user:manage 权限)
* 将用户创建参数挂到 req._createUser由路由在写入 employee 后处理
*/
async function allowUserCreation(req, res, next) {
const { username, password, department_id } = req.body || {}
// 没传用户相关字段 → 只创建员工,跳过
if (!username && !password) return next()
// 传了用户字段 → 必须有 user:manage 权限
if (!req.user.permissions || !req.user.permissions.includes('user:manage')) {
return res.status(403).json({ code: 403, message: '无权限创建用户账号' })
}
// 校验必填字段
if (!username || !password || !department_id) {
return res.status(400).json({ code: 400, message: '用户名、密码、部门为必填' })
}
if (typeof username !== 'string' || username.length < 3 || username.length > 50) {
return res.status(400).json({ code: 400, message: '用户名长度需在 3-50 之间' })
}
if (typeof password !== 'string' || password.length < 6) {
return res.status(400).json({ code: 400, message: '密码至少 6 位' })
}
// 检查是否是添加总经理办公室的用户
try {
const { pool } = require('../db')
const [dept] = await pool.query('SELECT name FROM departments WHERE id = ?', [department_id])
if (dept.length > 0 && dept[0].name === 'general_manager') {
const operatorLevel = req.user.role_level || 4
const operatorDept = req.user.departmentName
// 只有信息技术部和人力资源部的部门经理才能添加总经理办公室的用户
if (operatorLevel > 2 || !['admin', 'hr'].includes(operatorDept)) {
return res.status(403).json({ code: 403, message: '只有信息技术部和人力资源部的部门经理才能添加总经理办公室的用户' })
}
// 一般科员不能添加总经理办公室的用户
if (operatorLevel === 4) {
return res.status(403).json({ code: 403, message: '一般科员不能添加总经理办公室的用户' })
}
// 只能添加副总经理,总经理是系统预设的
// 这个检查在员工创建时通过 position 字段控制
}
} catch (e) {
console.error('[allowUserCreation] error:', e)
return res.status(500).json({ code: 500, message: e.message })
}
// 获取角色级别
const { role_level } = req.body || {}
// 挂到 req供路由在 INSERT employee 后使用
req._createUser = { username, password, department_id, role_level: role_level || 4 }
next()
}
module.exports = { allowUserCreation }

View File

@@ -1,9 +1,7 @@
// middleware/permissions.js —— RBAC 权限检查 + 数据范围过滤
// middleware/permissions.js —— 接口权限 + 数据范围 + 合同类型校验 + 层级检查
/**
* 检查当前用户是否拥有指定权限
* @param {string} resource - 资源名,如 'customer'
* @param {string} action - 操作名,如 'read'
*/
function checkPermission(resource, action) {
return (req, res, next) => {
@@ -16,58 +14,176 @@ function checkPermission(resource, action) {
}
/**
* 根据当前用户角色,返回该资源的数据范围过滤条件
* @param {object} user - req.user含 roleName, id, department
* @param {string} resource - 资源名customers / contracts / after_sales / products / suppliers / employees / users
* @returns {{ where?: string, values?: any[], deny?: boolean }}
* - where + values追加到 SQL WHERE 子句的条件
* - denytrue 表示该角色无权访问此资源
* 根据用户角色级别和部门计算数据范围,挂到 req.scope
*
* 角色层级:
* - 总经理(level=1): 全量数据
* - 部门经理(level=2): 本部门数据
* - 部门副经理(level=3): 本部门内自己及以下的数据
* - 一般专员(level=4): 仅自己的数据
*/
function getDataScope(user, resource) {
const { roleName, id: userId, department } = user
function dataScope(resource) {
return async (req, res, next) => {
const { pool } = require('../db')
const roleLevel = req.user.role_level || 4
const deptName = req.user.departmentName
const deptDesc = req.user.departmentDesc
// admin 和 general_manager总经理:全量数据,无限制
if (roleName === 'admin' || roleName === 'general_manager') {
return {}
}
// 总经理:全量数据
if (roleLevel === 1) {
return next()
}
switch (resource) {
case 'customers':
if (roleName === 'finance') return {}
return { where: 'responsible_user_id = ?', values: [userId] }
let sql = null
let params = []
case 'contracts':
if (roleName === 'finance') return {}
if (roleName === 'procurement_manager') {
return { where: 'type = ?', values: ['supply'] }
}
if (roleName === 'franchise_manager' || roleName === 'operations_manager') {
return { where: 'responsible_user_id = ? AND type = ?', values: [userId, 'franchise'] }
}
return {}
switch (resource) {
case 'employees':
if (roleLevel === 1) {
// 总经理:全量数据(已在上面处理)
} else if (roleLevel === 2) {
if (deptName === 'hr') {
// 人力资源部经理:查看所有员工
// 无过滤
} else {
// 其他部门经理:本部门所有员工
sql = 'AND department = ?'
params = [deptDesc]
}
} else if (roleLevel === 3) {
// 部门副经理:本部门内非经理级别的员工 + 自己
sql = 'AND (department = ? AND position NOT LIKE ?) OR id = (SELECT employee_id FROM users WHERE id = ?)'
params = [deptDesc, '%经理%', req.user.id]
} else {
// 一般专员:不能看到总经理的信息,但能看到其他员工
sql = 'AND position NOT LIKE ?'
params = ['%总经理%']
}
break
case 'after_sales':
if (roleName === 'finance') return {}
if (roleName === 'operations_manager') {
return { where: 'responsible_user_id = ?', values: [userId], tableAlias: 'a' }
}
// franchise_manager 和 procurement_manager 无权限checkPermission 已拦截)
return {}
case 'contracts':
if (roleLevel === 2) {
// 部门经理:本部门人员负责的合同
// 先查本部门的用户ID列表
const [deptUsers] = await pool.query(
'SELECT u.id FROM users u LEFT JOIN departments d ON u.department_id = d.id WHERE d.name = ?',
[deptName]
)
const deptUserIds = deptUsers.map(u => u.id)
if (deptUserIds.length > 0) {
sql = `AND responsible_user_id IN (${deptUserIds.map(() => '?').join(',')})`
params = deptUserIds
} else {
sql = 'AND 1=0' // 无数据
}
} else if (roleLevel >= 3) {
// 副经理和专员:自己负责的合同
sql = 'AND responsible_user_id = ?'
params = [req.user.id]
}
// 招商部/采购部的类型限制仍然通过 validateContractType 处理
break
case 'employees':
if (roleName === 'finance') return {}
// 所有非管理员/总经理角色:只能看自己部门的员工
return { where: 'department = ?', values: [department] }
case 'after_sales':
if (roleLevel === 2) {
// 部门经理:本部门人员负责的售后
const [deptUsersAS] = await pool.query(
'SELECT u.id FROM users u LEFT JOIN departments d ON u.department_id = d.id WHERE d.name = ?',
[deptName]
)
const deptUserIdsAS = deptUsersAS.map(u => u.id)
if (deptUserIdsAS.length > 0) {
sql = `AND responsible_user_id IN (${deptUserIdsAS.map(() => '?').join(',')})`
params = deptUserIdsAS
} else {
sql = 'AND 1=0'
}
} else if (roleLevel >= 3) {
// 副经理和专员:自己负责的售后
sql = 'AND responsible_user_id = ?'
params = [req.user.id]
}
break
case 'products':
case 'suppliers':
case 'users':
// 无数据范围过滤,仅靠 checkPermission 控制访问
return {}
case 'customers':
// 加盟商是公共资源,所有有权限的人都能看
// 但一般专员只能看自己关联的(通过合同/售后间接关联)
if (roleLevel === 4) {
// 一般专员:只能看到与自己负责的合同/售后关联的客户
sql = 'AND id IN (SELECT customer_id FROM contracts WHERE responsible_user_id = ? UNION SELECT customer_id FROM after_sales WHERE responsible_user_id = ?)'
params = [req.user.id, req.user.id]
}
break
}
default:
return {}
if (sql) {
req.scope = { sql, params }
}
next()
}
}
module.exports = { checkPermission, getDataScope }
/**
* 合同创建/修改时校验类型是否匹配部门
*/
function validateContractType(req, res, next) {
const deptName = req.user.departmentName
const type = req.body.type
if (type === undefined) return next()
// 总经理和财务不限制
if (['general_manager', 'finance'].includes(deptName)) return next()
// 信息技术部也不再是超级管理员,按部门权限走
if (deptName === 'admin') return next()
if (deptName === 'procurement_manager' && type !== 'supply') {
return res.status(403).json({ code: 403, message: '采购部只能操作采购合同' })
}
if (deptName === 'franchise_manager' && type !== 'franchise') {
return res.status(403).json({ code: 403, message: '招商部只能操作加盟合同' })
}
next()
}
/**
* 层级检查:确保操作者不能操作比自己级别高的用户
* 用于用户管理的删除/修改操作
*/
function checkHierarchy(req, res, next) {
return async (req, res, next) => {
try {
const { pool } = require('../db')
const operatorLevel = req.user.role_level || 4
const targetId = Number(req.params.id)
// 查询目标用户的role_level
const [rows] = await pool.query('SELECT role_level FROM users WHERE id = ?', [targetId])
if (rows.length === 0) {
return res.status(404).json({ code: 404, message: '用户不存在' })
}
const targetLevel = rows[0].role_level || 4
// 不能操作比自己级别高的用户
if (targetLevel < operatorLevel) {
return res.status(403).json({ code: 403, message: '不能操作比自己级别高的用户' })
}
// 最高只能操作同级
if (targetLevel < operatorLevel) {
return res.status(403).json({ code: 403, message: '权限不足,无法操作该用户' })
}
next()
} catch (e) {
console.error('[checkHierarchy] error:', e)
return res.status(500).json({ code: 500, message: e.message })
}
}
}
module.exports = { checkPermission, dataScope, validateContractType, checkHierarchy }

71
middleware/users.js Normal file
View File

@@ -0,0 +1,71 @@
// middleware/users.js —— users 路由的纯业务规则中间件
// 与权限/认证无关,只是保护操作者自身不被误操作
const { pool } = require('../db')
/**
* 更新用户时的自保护:
* - 不能修改自己的部门
* - 不能禁用自己
*/
function protectSelfUpdate(req, res, next) {
if (Number(req.params.id) === req.user.id) {
if (req.body.department_id !== undefined && req.body.department_id !== req.user.department_id) {
return res.status(400).json({ code: 400, message: '不能修改自己的部门' })
}
if (req.body.is_active === 0) {
return res.status(400).json({ code: 400, message: '不能禁用自己' })
}
}
next()
}
/**
* 删除用户时的保护:
* - 不能删除自己
* - 不能删除比自己级别高的用户
* - 最高只能删除同级用户
* - 不能删除最后一个总经理
*/
async function protectUserDelete(req, res, next) {
const targetId = Number(req.params.id)
const operatorLevel = req.user.role_level || 4
// 不能删除自己
if (targetId === req.user.id) {
return res.status(400).json({ code: 400, message: '不能删除自己' })
}
try {
const [rows] = await pool.query('SELECT role_level, department_id FROM users WHERE id = ?', [targetId])
if (rows.length === 0) {
return res.status(404).json({ code: 404, message: '用户不存在' })
}
const targetLevel = rows[0].role_level || 4
// 不能删除比自己级别高的用户(数字越小级别越高)
if (targetLevel < operatorLevel) {
return res.status(403).json({ code: 403, message: '不能删除比自己级别高的用户' })
}
// 最高只能删除同级
// 这个逻辑已经在上面的检查中覆盖了targetLevel >= operatorLevel 才能继续)
// 不能删除最后一个总经理
if (targetLevel === 1) {
const [[{ cnt }]] = await pool.query(
'SELECT COUNT(*) AS cnt FROM users WHERE role_level = 1'
)
if (cnt <= 1) {
return res.status(400).json({ code: 400, message: '不能删除最后一个总经理' })
}
}
next()
} catch (e) {
console.error('[protectUserDelete] error:', e)
return res.status(500).json({ code: 500, message: e.message })
}
}
module.exports = { protectSelfUpdate, protectUserDelete }

View File

@@ -1,6 +1,5 @@
// routes/afterSales.js —— 售后管理 CRUD
// routes/afterSales.js —— 售后管理 CRUD(纯数据库操作,权限由中间件层控制)
const { pool } = require('../db')
const { getDataScope } = require('../middleware/permissions')
// 格式化日期为 YYYY-MM-DD
function formatDate(dateStr) {
@@ -46,16 +45,10 @@ async function list(req, res) {
let where = 'WHERE 1=1'
const params = []
// 数据范围过滤
const scope = getDataScope(req.user, 'after_sales')
if (scope.deny) {
return res.status(403).json({ code: 403, message: '无权访问此资源' })
}
if (scope.where) {
// list 查询有 JOIN需要表别名前缀避免列名歧义
const scopeCol = scope.tableAlias ? scope.where.replace(/\bresponsible_user_id\b/g, `${scope.tableAlias}.responsible_user_id`) : scope.where
where += ' AND ' + scopeCol
params.push(...scope.values)
// 数据范围过滤(由中间件注入 req.scope已含表别名前缀
if (req.scope && req.scope.sql) {
where += ' ' + req.scope.sql
params.push(...req.scope.params)
}
if (id) {
@@ -88,7 +81,7 @@ async function list(req, res) {
)
const [rows] = await pool.query(
`${LIST_SELECT} ${where} ORDER BY a.id DESC LIMIT ? OFFSET ?`,
`${LIST_SELECT} ${where} ORDER BY a.id ASC LIMIT ? OFFSET ?`,
[...params, pageSize, offset]
)
@@ -115,14 +108,10 @@ async function detail(req, res) {
let sql = `${LIST_SELECT} WHERE a.id = ?`
const params = [req.params.id]
const scope = getDataScope(req.user, 'after_sales')
if (scope.deny) {
return res.status(403).json({ code: 403, message: '无权访问此资源' })
}
if (scope.where) {
const scopeCol = scope.tableAlias ? scope.where.replace('responsible_user_id', `${scope.tableAlias}.responsible_user_id`) : scope.where
sql += ' AND ' + scopeCol
params.push(...scope.values)
// 数据范围过滤(由中间件注入 req.scope已含表别名前缀
if (req.scope && req.scope.sql) {
sql += ' ' + req.scope.sql
params.push(...req.scope.params)
}
const [rows] = await pool.query(sql, params)
@@ -140,7 +129,7 @@ async function detail(req, res) {
async function create(req, res) {
const {
customer_id, feedback, employee_id, handle_method,
handle_status, service_date, remark, responsible_user_id,
handle_status, service_date, remark,
} = req.body || {}
if (!customer_id) {
@@ -156,17 +145,20 @@ async function create(req, res) {
if (cust.length === 0) {
return res.status(400).json({ code: 400, message: '关联客户不存在' })
}
// 校验业务员
// 校验业务员,并通过 employee_id 自动推导负责人
let ownerId = req.user.id
if (employee_id) {
const [emp] = await pool.query('SELECT id FROM employees WHERE id = ?', [employee_id])
if (emp.length === 0) {
return res.status(400).json({ code: 400, message: '关联业务员不存在' })
}
// 业务员即负责人:查找该员工对应的系统用户
const [userRows] = await pool.query('SELECT id FROM users WHERE employee_id = ? LIMIT 1', [employee_id])
if (userRows.length > 0) {
ownerId = userRows[0].id
}
}
// 如果没指定负责人,默认设为当前用户
const ownerId = responsible_user_id || req.user.id
const [result] = await pool.query(
`INSERT INTO after_sales
(customer_id, feedback, employee_id, handle_method, handle_status, service_date, remark, responsible_user_id)
@@ -198,15 +190,11 @@ async function update(req, res) {
try {
// 确认记录存在且在数据范围内
let checkSql = 'SELECT id FROM after_sales WHERE id = ?'
const checkParams = [id]
const scope = getDataScope(req.user, 'after_sales')
if (scope.deny) {
return res.status(403).json({ code: 403, message: '无权访问此资源' })
}
if (scope.where) {
checkSql += ' AND ' + scope.where
checkParams.push(...scope.values)
let checkSql = 'SELECT id, employee_id FROM after_sales WHERE id = ?'
let checkParams = [id]
if (req.scope && req.scope.sql) {
checkSql += ' ' + req.scope.sql
checkParams.push(...req.scope.params)
}
const [existing] = await pool.query(checkSql, checkParams)
if (existing.length === 0) {
@@ -226,6 +214,22 @@ async function update(req, res) {
}
}
// 业务员即负责人:当 employee_id 变更时自动推导 responsible_user_id
if (req.body.employee_id !== undefined) {
if (req.body.employee_id) {
const [userRows] = await pool.query('SELECT id FROM users WHERE employee_id = ? LIMIT 1', [req.body.employee_id])
if (userRows.length > 0) {
req.body.responsible_user_id = userRows[0].id
} else {
// 该员工无对应系统用户时,设为当前登录用户
req.body.responsible_user_id = req.user.id
}
} else {
// 业务员被清空时,清空负责人
req.body.responsible_user_id = null
}
}
const sets = []
const params = []
for (const f of fields) {
@@ -259,14 +263,10 @@ async function remove(req, res) {
const { id } = req.params
try {
let checkSql = 'SELECT id FROM after_sales WHERE id = ?'
const checkParams = [id]
const scope = getDataScope(req.user, 'after_sales')
if (scope.deny) {
return res.status(403).json({ code: 403, message: '无权访问此资源' })
}
if (scope.where) {
checkSql += ' AND ' + scope.where
checkParams.push(...scope.values)
let checkParams = [id]
if (req.scope && req.scope.sql) {
checkSql += ' ' + req.scope.sql
checkParams.push(...req.scope.params)
}
const [existing] = await pool.query(checkSql, checkParams)
if (existing.length === 0) {

View File

@@ -1,6 +1,5 @@
// routes/contracts.js —— 合同管理 CRUD
// routes/contracts.js —— 合同管理 CRUD(纯数据库操作,权限由中间件层控制)
const { pool } = require('../db')
const { getDataScope } = require('../middleware/permissions')
// 格式化日期为 YYYY-MM-DD
function formatDate(dateStr) {
@@ -27,17 +26,20 @@ function pagination(query) {
return { page, pageSize, offset }
}
// 列表查询时 JOIN 客户名、供应商名员工名
// 列表查询时 JOIN 客户名、供应商名员工名和负责人姓名
const LIST_SELECT = `
SELECT
c.*,
cu.name AS customer_name,
s.name AS supplier_name,
e.name AS employee_name
e.name AS employee_name,
ru.name AS responsible_user_name
FROM contracts c
LEFT JOIN customers cu ON c.customer_id = cu.id
LEFT JOIN suppliers s ON c.supplier_id = s.id
LEFT JOIN employees e ON c.employee_id = e.id
LEFT JOIN users u ON c.responsible_user_id = u.id
LEFT JOIN employees ru ON u.employee_id = ru.id
`
// 单条查询时用同样的 JOIN
@@ -47,21 +49,15 @@ const DETAIL_SELECT = LIST_SELECT
async function list(req, res) {
try {
const { page, pageSize, offset } = pagination(req.query)
const { status, customer_name, contract_no, contract_name, employee_name, id, effective_date_start, effective_date_end } = req.query
const { status, type, customer_name, contract_no, contract_name, employee_name, id, effective_date_start, effective_date_end } = req.query
let where = 'WHERE 1=1'
const params = []
// 数据范围过滤(列表查询使用表别名 c需要加前缀)
const scope = getDataScope(req.user, 'contracts')
if (scope.deny) {
return res.status(403).json({ code: 403, message: '无权访问此资源' })
}
if (scope.where) {
// 给 scope.where 中的字段加上表别名 c.
const scopedWhere = scope.where.replace(/\b(responsible_user_id|type)\b/g, 'c.$1')
where += ' AND ' + scopedWhere
params.push(...scope.values)
// 数据范围过滤(由中间件注入 req.scope无别名list/detail 需机械加 c. 前缀)
if (req.scope && req.scope.sql) {
where += ' ' + req.scope.sql.replace(/\btype\b/g, 'c.type')
params.push(...req.scope.params)
}
if (id) {
@@ -72,6 +68,10 @@ async function list(req, res) {
where += ' AND c.status = ?'
params.push(status)
}
if (type) {
where += ' AND c.type = ?'
params.push(type)
}
if (contract_name) {
where += ' AND c.contract_name LIKE ?'
params.push(`%${contract_name}%`)
@@ -102,12 +102,14 @@ async function list(req, res) {
LEFT JOIN customers cu ON c.customer_id = cu.id
LEFT JOIN suppliers s ON c.supplier_id = s.id
LEFT JOIN employees e ON c.employee_id = e.id
LEFT JOIN users u ON c.responsible_user_id = u.id
LEFT JOIN employees ru ON u.employee_id = ru.id
${where}`,
params
)
const [rows] = await pool.query(
`${LIST_SELECT} ${where} ORDER BY c.id DESC LIMIT ? OFFSET ?`,
`${LIST_SELECT} ${where} ORDER BY c.id ASC LIMIT ? OFFSET ?`,
[...params, pageSize, offset]
)
@@ -134,15 +136,10 @@ async function detail(req, res) {
let sql = `${DETAIL_SELECT} WHERE c.id = ?`
const params = [req.params.id]
const scope = getDataScope(req.user, 'contracts')
if (scope.deny) {
return res.status(403).json({ code: 403, message: '无权访问此资源' })
}
if (scope.where) {
// detail 查询使用表别名 c需要给 scope.where 加上前缀
const scopedWhere = scope.where.replace(/\b(responsible_user_id|type)\b/g, 'c.$1')
sql += ' AND ' + scopedWhere
params.push(...scope.values)
// 数据范围过滤(由中间件注入 req.scope无别名detail 需机械加 c. 前缀)
if (req.scope && req.scope.sql) {
sql += ' ' + req.scope.sql.replace(/\btype\b/g, 'c.type')
params.push(...req.scope.params)
}
const [rows] = await pool.query(sql, params)
@@ -163,14 +160,8 @@ async function create(req, res) {
amount, effective_date, expiry_date, employee_id, status, remark,
} = req.body || {}
// 根据角色限制合同类型
const contractType = req.body.type || (req.user.roleName === 'procurement_manager' ? 'supply' : 'franchise')
if (req.user.roleName === 'procurement_manager' && contractType !== 'supply') {
return res.status(403).json({ code: 403, message: '采购经理只能创建采购合同' })
}
if (req.user.roleName === 'franchise_manager' && contractType !== 'franchise') {
return res.status(403).json({ code: 403, message: '招商经理只能创建加盟合同' })
}
// 合同类型默认值(类型校验由中间件层控制)
const contractType = req.body.type || 'franchise'
if (contractType === 'supply' && !supplier_id) {
return res.status(400).json({ code: 400, message: '采购合同必须选择供应商' })
@@ -197,12 +188,18 @@ async function create(req, res) {
return res.status(400).json({ code: 400, message: '关联供应商不存在' })
}
}
// 校验业务员(如果填了)
// 校验业务员(如果填了),并通过 employee_id 自动推导负责人
let responsibleUserId = req.user.id
if (employee_id) {
const [emp] = await pool.query('SELECT id FROM employees WHERE id = ?', [employee_id])
if (emp.length === 0) {
return res.status(400).json({ code: 400, message: '关联业务员不存在' })
}
// 业务员即负责人:查找该员工对应的系统用户
const [userRows] = await pool.query('SELECT id FROM users WHERE employee_id = ? LIMIT 1', [employee_id])
if (userRows.length > 0) {
responsibleUserId = userRows[0].id
}
}
const [result] = await pool.query(
@@ -219,7 +216,7 @@ async function create(req, res) {
status || '生效中',
remark || null,
contractType,
req.body.responsible_user_id || req.user.id]
responsibleUserId]
)
const [rows] = await pool.query(`${DETAIL_SELECT} WHERE c.id = ?`, [result.insertId])
@@ -235,20 +232,17 @@ async function update(req, res) {
const { id } = req.params
const fields = [
'customer_id', 'supplier_id', 'contract_name', 'contract_no', 'contract_content',
'amount', 'effective_date', 'expiry_date', 'employee_id', 'status', 'remark', 'type', 'responsible_user_id',
'amount', 'effective_date', 'expiry_date', 'employee_id', 'status', 'remark', 'type',
'responsible_user_id',
]
try {
// 确认记录存在且在数据范围内detail/update/delete 不用表别名)
let checkSql = 'SELECT id FROM contracts WHERE id = ?'
const checkParams = [id]
const scope = getDataScope(req.user, 'contracts')
if (scope.deny) {
return res.status(403).json({ code: 403, message: '无权访问此资源' })
}
if (scope.where) {
checkSql += ' AND ' + scope.where
checkParams.push(...scope.values)
let checkSql = 'SELECT id, employee_id FROM contracts WHERE id = ?'
let checkParams = [id]
if (req.scope && req.scope.sql) {
checkSql += ' ' + req.scope.sql.replace(/\btype\b/g, 'c.type')
checkParams.push(...req.scope.params)
}
const [existing] = await pool.query(checkSql, checkParams)
if (existing.length === 0) {
@@ -268,6 +262,22 @@ async function update(req, res) {
return res.status(400).json({ code: 400, message: '关联业务员不存在' })
}
}
// 业务员即负责人:当 employee_id 变更时自动推导 responsible_user_id
if (req.body.employee_id !== undefined) {
if (req.body.employee_id) {
const [userRows] = await pool.query('SELECT id FROM users WHERE employee_id = ? LIMIT 1', [req.body.employee_id])
if (userRows.length > 0) {
req.body.responsible_user_id = userRows[0].id
} else {
// 该员工无对应系统用户时,设为当前登录用户
req.body.responsible_user_id = req.user.id
}
} else {
// 业务员被清空时,清空负责人
req.body.responsible_user_id = null
}
}
if (req.body.supplier_id) {
const [sup] = await pool.query('SELECT id FROM suppliers WHERE id = ?', [req.body.supplier_id])
if (sup.length === 0) {
@@ -308,14 +318,10 @@ async function remove(req, res) {
const { id } = req.params
try {
let checkSql = 'SELECT id FROM contracts WHERE id = ?'
const checkParams = [id]
const scope = getDataScope(req.user, 'contracts')
if (scope.deny) {
return res.status(403).json({ code: 403, message: '无权访问此资源' })
}
if (scope.where) {
checkSql += ' AND ' + scope.where
checkParams.push(...scope.values)
let checkParams = [id]
if (req.scope && req.scope.sql) {
checkSql += ' ' + req.scope.sql.replace(/\btype\b/g, 'c.type')
checkParams.push(...req.scope.params)
}
const [existing] = await pool.query(checkSql, checkParams)
if (existing.length === 0) {

View File

@@ -1,6 +1,5 @@
// routes/customers.js —— 客户管理 CRUD
// routes/customers.js —— 客户管理 CRUD(纯数据库操作,权限由中间件层控制)
const { pool } = require('../db')
const { getDataScope } = require('../middleware/permissions')
// 提取分页参数
function pagination(query) {
@@ -19,14 +18,10 @@ async function list(req, res) {
let where = 'WHERE 1=1'
const params = []
// 数据范围过滤
const scope = getDataScope(req.user, 'customers')
if (scope.deny) {
return res.status(403).json({ code: 403, message: '无权访问此资源' })
}
if (scope.where) {
where += ' AND ' + scope.where
params.push(...scope.values)
// 数据范围过滤(由中间件注入 req.scope
if (req.scope && req.scope.sql) {
where += ' ' + req.scope.sql
params.push(...req.scope.params)
}
if (id) {
@@ -56,13 +51,11 @@ async function list(req, res) {
params
)
// 查分页数据JOIN users+employees 获取负责人姓名)
// 查分页数据
const [rows] = await pool.query(
`SELECT c.*, e.name AS responsible_user_name
`SELECT c.*
FROM customers c
LEFT JOIN users u ON c.responsible_user_id = u.id
LEFT JOIN employees e ON u.employee_id = e.id
${where} ORDER BY c.id DESC LIMIT ? OFFSET ?`,
${where} ORDER BY c.id ASC LIMIT ? OFFSET ?`,
[...params, pageSize, offset]
)
@@ -89,13 +82,9 @@ async function detail(req, res) {
let sql = 'SELECT * FROM customers WHERE id = ?'
const params = [req.params.id]
const scope = getDataScope(req.user, 'customers')
if (scope.deny) {
return res.status(403).json({ code: 403, message: '无权访问此资源' })
}
if (scope.where) {
sql += ' AND ' + scope.where
params.push(...scope.values)
if (req.scope && req.scope.sql) {
sql += ' ' + req.scope.sql
params.push(...req.scope.params)
}
const [rows] = await pool.query(sql, params)
@@ -113,7 +102,7 @@ async function detail(req, res) {
async function create(req, res) {
const {
name, phone, province, city, district,
address, email, remark, responsible_user_id,
address, email, remark,
} = req.body || {}
if (!name) {
@@ -121,14 +110,11 @@ async function create(req, res) {
}
try {
// 如果没指定负责人,默认设为当前用户
const ownerId = responsible_user_id || req.user.id
const [result] = await pool.query(
`INSERT INTO customers (name, phone, province, city, district, address, email, remark, responsible_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO customers (name, phone, province, city, district, address, email, remark)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[name, phone || null, province || null, city || null, district || null,
address || null, email || null, remark || null, ownerId]
address || null, email || null, remark || null]
)
const [rows] = await pool.query('SELECT * FROM customers WHERE id = ?', [result.insertId])
res.json({ code: 0, message: 'ok', data: rows[0] })
@@ -143,20 +129,16 @@ async function update(req, res) {
const { id } = req.params
const fields = [
'name', 'phone', 'province', 'city', 'district',
'address', 'email', 'remark', 'responsible_user_id',
'address', 'email', 'remark',
]
try {
// 确认记录存在且在数据范围内
let checkSql = 'SELECT id FROM customers WHERE id = ?'
const checkParams = [id]
const scope = getDataScope(req.user, 'customers')
if (scope.deny) {
return res.status(403).json({ code: 403, message: '无权访问此资源' })
}
if (scope.where) {
checkSql += ' AND ' + scope.where
checkParams.push(...scope.values)
let checkParams = [id] // let, 后续可能 push scope.params
if (req.scope && req.scope.sql) {
checkSql += ' ' + req.scope.sql
checkParams.push(...req.scope.params)
}
const [existing] = await pool.query(checkSql, checkParams)
if (existing.length === 0) {
@@ -192,14 +174,10 @@ async function remove(req, res) {
const { id } = req.params
try {
let checkSql = 'SELECT id FROM customers WHERE id = ?'
const checkParams = [id]
const scope = getDataScope(req.user, 'customers')
if (scope.deny) {
return res.status(403).json({ code: 403, message: '无权访问此资源' })
}
if (scope.where) {
checkSql += ' AND ' + scope.where
checkParams.push(...scope.values)
let checkParams = [id]
if (req.scope && req.scope.sql) {
checkSql += ' ' + req.scope.sql
checkParams.push(...req.scope.params)
}
const [existing] = await pool.query(checkSql, checkParams)
if (existing.length === 0) {
@@ -216,4 +194,17 @@ async function remove(req, res) {
}
}
module.exports = { list, detail, create, update, remove }
// GET /api/customers/simple —— 简易加盟商列表(无数据范围限制,用于下拉选择)
async function simpleList(req, res) {
try {
const [rows] = await pool.query(
'SELECT id, name FROM customers ORDER BY id'
)
res.json({ code: 0, message: 'ok', data: rows })
} catch (e) {
console.error('[customers simpleList] error:', e)
res.status(500).json({ code: 500, message: e.message })
}
}
module.exports = { list, detail, create, update, remove, simpleList }

View File

@@ -1,6 +1,6 @@
// routes/employees.js —— 员工管理 CRUD
// routes/employees.js —— 员工管理 CRUD(纯数据库操作,权限由中间件层控制)
const { pool } = require('../db')
const { getDataScope } = require('../middleware/permissions')
const bcrypt = require('bcryptjs')
// 格式化日期为 YYYY-MM-DD
function formatDate(dateStr) {
@@ -36,14 +36,10 @@ async function list(req, res) {
let where = 'WHERE 1=1'
const params = []
// 数据范围过滤(部门隔离
const scope = getDataScope(req.user, 'employees')
if (scope.deny) {
return res.status(403).json({ code: 403, message: '无权访问此资源' })
}
if (scope.where) {
where += ' AND ' + scope.where
params.push(...scope.values)
// 数据范围过滤(由中间件注入 req.scope
if (req.scope && req.scope.sql) {
where += ' ' + req.scope.sql
params.push(...req.scope.params)
}
if (id) {
@@ -69,7 +65,7 @@ async function list(req, res) {
)
const [rows] = await pool.query(
`SELECT * FROM employees ${where} ORDER BY id DESC LIMIT ? OFFSET ?`,
`SELECT * FROM employees ${where} ORDER BY id ASC LIMIT ? OFFSET ?`,
[...params, pageSize, offset]
)
@@ -96,13 +92,10 @@ async function detail(req, res) {
let sql = 'SELECT * FROM employees WHERE id = ?'
const params = [req.params.id]
const scope = getDataScope(req.user, 'employees')
if (scope.deny) {
return res.status(403).json({ code: 403, message: '无权访问此资源' })
}
if (scope.where) {
sql += ' AND ' + scope.where
params.push(...scope.values)
// 数据范围过滤(由中间件注入 req.scope
if (req.scope && req.scope.sql) {
sql += ' ' + req.scope.sql
params.push(...req.scope.params)
}
const [rows] = await pool.query(sql, params)
@@ -127,6 +120,27 @@ async function create(req, res) {
return res.status(400).json({ code: 400, message: '员工姓名必填' })
}
// 检查是否是添加总经理办公室的员工
if (department === '总经理办公室') {
const roleLevel = req.user.role_level || 4
const deptName = req.user.departmentName
// 只有信息技术部和人力资源部的部门经理才能添加总经理办公室的员工
if (roleLevel > 2 || !['admin', 'hr'].includes(deptName)) {
return res.status(403).json({ code: 403, message: '只有信息技术部和人力资源部的部门经理才能添加总经理办公室的员工' })
}
// 一般科员不能添加员工到总经理办公室
if (roleLevel === 4) {
return res.status(403).json({ code: 403, message: '一般科员不能添加总经理办公室的员工' })
}
// 只能添加副总经理,总经理是系统预设的
if (position && position !== '副总经理') {
return res.status(400).json({ code: 400, message: '总经理办公室只能添加副总经理,总经理是系统预设的' })
}
}
try {
const [result] = await pool.query(
`INSERT INTO employees (name, gender, age, education, department, entry_date, position, salary, phone, email, status, remark)
@@ -145,6 +159,19 @@ async function create(req, res) {
remark || null]
)
const [rows] = await pool.query('SELECT * FROM employees WHERE id = ?', [result.insertId])
// 如果中间件 allowUserCreation 要求同步创建用户账号
if (req._createUser) {
const { username, password, department_id, role_level } = req._createUser
const hash = await bcrypt.hash(password, 10)
const validRoleLevel = [1, 2, 3, 4].includes(Number(role_level)) ? Number(role_level) : 4
await pool.query(
'INSERT INTO users (username, password, is_active, department_id, employee_id, role_level) VALUES (?, ?, 1, ?, ?, ?)',
[username, hash, department_id, result.insertId, validRoleLevel]
)
delete req._createUser // 清理,避免影响后续中间件
}
res.json({ code: 0, message: 'ok', data: rows[0] })
} catch (e) {
console.error('[employees create] error:', e)
@@ -163,14 +190,10 @@ async function update(req, res) {
try {
// 确认记录存在且在数据范围内
let checkSql = 'SELECT id FROM employees WHERE id = ?'
const checkParams = [id]
const scope = getDataScope(req.user, 'employees')
if (scope.deny) {
return res.status(403).json({ code: 403, message: '无权访问此资源' })
}
if (scope.where) {
checkSql += ' AND ' + scope.where
checkParams.push(...scope.values)
let checkParams = [id]
if (req.scope && req.scope.sql) {
checkSql += ' ' + req.scope.sql
checkParams.push(...req.scope.params)
}
const [existing] = await pool.query(checkSql, checkParams)
if (existing.length === 0) {
@@ -197,9 +220,18 @@ async function update(req, res) {
params.push(id)
await pool.query(`UPDATE employees SET ${sets.join(', ')} WHERE id = ?`, params)
// 同步 department 到关联的 users 表
// 部门联动:员工部门变更时同步更新关联用户的部门
if (req.body.department !== undefined) {
await pool.query('UPDATE users SET department = ? WHERE employee_id = ?', [req.body.department, id])
const [[dept]] = await pool.query('SELECT id FROM departments WHERE description = ?', [req.body.department])
if (dept) {
await pool.query('UPDATE users SET department_id = ? WHERE employee_id = ?', [dept.id, id])
}
}
// 状态联动:员工离职时同步禁用关联用户,复职时同步启用
if (req.body.status !== undefined) {
const isActive = req.body.status === 1 ? 1 : 0
await pool.query('UPDATE users SET is_active = ? WHERE employee_id = ?', [isActive, id])
}
const [rows] = await pool.query('SELECT * FROM employees WHERE id = ?', [id])
@@ -215,14 +247,10 @@ async function remove(req, res) {
const { id } = req.params
try {
let checkSql = 'SELECT id FROM employees WHERE id = ?'
const checkParams = [id]
const scope = getDataScope(req.user, 'employees')
if (scope.deny) {
return res.status(403).json({ code: 403, message: '无权访问此资源' })
}
if (scope.where) {
checkSql += ' AND ' + scope.where
checkParams.push(...scope.values)
let checkParams = [id]
if (req.scope && req.scope.sql) {
checkSql += ' ' + req.scope.sql
checkParams.push(...req.scope.params)
}
const [existing] = await pool.query(checkSql, checkParams)
if (existing.length === 0) {
@@ -246,4 +274,32 @@ async function remove(req, res) {
}
}
module.exports = { list, detail, create, update, remove }
// GET /api/employees/simple —— 简易员工列表(无数据范围限制,用于下拉选择)
async function simpleList(req, res) {
try {
const { department, max_role_level } = req.query
// 关联users表获取role_level信息
let sql = `SELECT e.id, e.name, e.department, e.position, COALESCE(u.role_level, 4) as role_level
FROM employees e
LEFT JOIN users u ON e.id = u.employee_id
WHERE e.status = 1`
const params = []
if (department) {
sql += ' AND e.department = ?'
params.push(department)
}
// 过滤角色级别(不能选择比自己职位高的员工)
if (max_role_level) {
sql += ' AND COALESCE(u.role_level, 4) >= ?'
params.push(Number(max_role_level))
}
sql += ' ORDER BY e.id'
const [rows] = await pool.query(sql, params)
res.json({ code: 0, message: 'ok', data: rows })
} catch (e) {
console.error('[employees simpleList] error:', e)
res.status(500).json({ code: 500, message: e.message })
}
}
module.exports = { list, detail, create, update, remove, simpleList }

View File

@@ -40,7 +40,7 @@ async function list(req, res) {
)
const [rows] = await pool.query(
`SELECT * FROM products ${where} ORDER BY id DESC LIMIT ? OFFSET ?`,
`SELECT * FROM products ${where} ORDER BY id ASC LIMIT ? OFFSET ?`,
[...params, pageSize, offset]
)

View File

@@ -40,7 +40,7 @@ async function list(req, res) {
)
const [rows] = await pool.query(
`SELECT * FROM suppliers ${where} ORDER BY id DESC LIMIT ? OFFSET ?`,
`SELECT * FROM suppliers ${where} ORDER BY id ASC LIMIT ? OFFSET ?`,
[...params, pageSize, offset]
)
@@ -151,4 +151,17 @@ async function remove(req, res) {
}
}
module.exports = { list, detail, create, update, remove }
// GET /api/suppliers/simple —— 简易供应商列表(无数据范围限制,用于下拉选择)
async function simpleList(req, res) {
try {
const [rows] = await pool.query(
'SELECT id, name FROM suppliers WHERE status = 1 ORDER BY id'
)
res.json({ code: 0, message: 'ok', data: rows })
} catch (e) {
console.error('[suppliers simpleList] error:', e)
res.status(500).json({ code: 500, message: e.message })
}
}
module.exports = { list, detail, create, update, remove, simpleList }

View File

@@ -3,14 +3,14 @@ const jwt = require('jsonwebtoken')
const bcrypt = require('bcryptjs')
const { pool } = require('../db')
// 安全字段:关联查询 roles 和 employees
// 安全字段:关联查询 departments 和 employees
const USER_LIST_SQL = `
SELECT u.id, u.username, u.is_active, u.role_id, u.employee_id, u.department,
SELECT u.id, u.username, u.is_active, u.department_id, u.employee_id, u.role_level,
u.created_at, u.updated_at,
r.name AS role_name, r.description AS role_description,
d.name AS dept_name, d.description AS dept_desc,
e.name AS real_name
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
LEFT JOIN departments d ON u.department_id = d.id
LEFT JOIN employees e ON u.employee_id = e.id
`
@@ -32,10 +32,10 @@ async function login(req, res) {
try {
const [rows] = await pool.query(
`SELECT u.id, u.username, u.password, u.is_active, u.role_id, u.department, u.employee_id,
r.name AS role_name, e.name AS real_name
`SELECT u.id, u.username, u.password, u.is_active, u.department_id, u.employee_id, u.role_level,
d.name AS dept_name, d.description AS dept_desc, e.name AS real_name
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
LEFT JOIN departments d ON u.department_id = d.id
LEFT JOIN employees e ON u.employee_id = e.id
WHERE u.username = ?`,
[username]
@@ -56,12 +56,12 @@ async function login(req, res) {
return res.status(400).json({ code: 400, message: '账号或密码错误' })
}
// 查询该角色的所有权限标识
// 查询该部门的所有权限标识
const [perms] = await pool.query(
`SELECT p.name FROM permissions p
JOIN role_permissions rp ON p.id = rp.permission_id
WHERE rp.role_id = ?`,
[user.role_id]
JOIN department_permissions dp ON p.id = dp.permission_id
WHERE dp.department_id = ?`,
[user.department_id]
)
const permissions = perms.map(p => p.name)
@@ -70,9 +70,11 @@ async function login(req, res) {
id: user.id,
username: user.username,
name: user.real_name || user.username,
role_id: user.role_id,
roleName: user.role_name,
department: user.department,
employee_id: user.employee_id,
department_id: user.department_id,
departmentName: user.dept_name,
departmentDesc: user.dept_desc,
role_level: user.role_level || 4,
permissions,
},
process.env.JWT_SECRET,
@@ -88,9 +90,11 @@ async function login(req, res) {
id: user.id,
username: user.username,
name: user.real_name || user.username,
role_id: user.role_id,
roleName: user.role_name,
department: user.department,
employee_id: user.employee_id,
department_id: user.department_id,
departmentName: user.dept_name,
departmentDesc: user.dept_desc,
role_level: user.role_level || 4,
permissions,
},
},
@@ -177,7 +181,7 @@ async function changePassword(req, res) {
async function list(req, res) {
try {
const { page, pageSize, offset } = pagination(req.query)
const { username, real_name, id, role_id, is_active } = req.query
const { username, real_name, id, department_id, is_active } = req.query
let where = 'WHERE 1=1'
const params = []
@@ -194,9 +198,9 @@ async function list(req, res) {
where += ' AND u.id = ?'
params.push(Number(id))
}
if (role_id) {
where += ' AND u.role_id = ?'
params.push(Number(role_id))
if (department_id) {
where += ' AND u.department_id = ?'
params.push(Number(department_id))
}
if (is_active !== undefined && is_active !== '') {
where += ' AND u.is_active = ?'
@@ -211,7 +215,7 @@ async function list(req, res) {
)
const [rows] = await pool.query(
`${USER_LIST_SQL} ${where} ORDER BY u.id DESC LIMIT ? OFFSET ?`,
`${USER_LIST_SQL} ${where} ORDER BY u.id ASC LIMIT ? OFFSET ?`,
[...params, pageSize, offset]
)
@@ -251,7 +255,7 @@ async function detail(req, res) {
// POST /api/users —— 创建用户
async function create(req, res) {
const { username, password, role_id, employee_id, department, is_active = 1 } = req.body || {}
const { username, password, department_id, employee_id, is_active = 1, role_level = 4 } = req.body || {}
if (!username || !password) {
return res.status(400).json({ code: 400, message: '用户名和密码必填' })
@@ -263,31 +267,50 @@ async function create(req, res) {
return res.status(400).json({ code: 400, message: '密码至少 6 位' })
}
// 校验 role_id 是否存在
if (role_id) {
const [role] = await pool.query('SELECT id FROM roles WHERE id = ?', [role_id])
if (role.length === 0) {
return res.status(400).json({ code: 400, message: '指定的角色不存在' })
// 校验 department_id 是否存在
if (department_id) {
const [dept] = await pool.query('SELECT id, name FROM departments WHERE id = ?', [department_id])
if (dept.length === 0) {
return res.status(400).json({ code: 400, message: '指定的部门不存在' })
}
// 检查是否是添加总经理办公室的用户
if (dept[0].name === 'general_manager') {
const operatorLevel = req.user.role_level || 4
const operatorDept = req.user.departmentName
// 只有信息技术部和人力资源部的部门经理才能添加总经理办公室的用户
if (operatorLevel > 2 || !['admin', 'hr'].includes(operatorDept)) {
return res.status(403).json({ code: 403, message: '只有信息技术部和人力资源部的部门经理才能添加总经理办公室的用户' })
}
// 一般科员不能添加总经理办公室的用户
if (operatorLevel === 4) {
return res.status(403).json({ code: 403, message: '一般科员不能添加总经理办公室的用户' })
}
// 只能添加副总经理role_level=2总经理是系统预设的
if (role_level && Number(role_level) !== 2) {
return res.status(400).json({ code: 400, message: '总经理办公室只能添加副总经理(角色级别2),总经理是系统预设的' })
}
}
}
// 校验 employee_id 是否存在
if (employee_id) {
const [emp] = await pool.query('SELECT id, department FROM employees WHERE id = ?', [employee_id])
const [emp] = await pool.query('SELECT id FROM employees WHERE id = ?', [employee_id])
if (emp.length === 0) {
return res.status(400).json({ code: 400, message: '指定的员工不存在' })
}
// 如果没传 department自动从员工表同步
if (!department) {
req.body.department = emp[0].department
}
}
try {
const hash = await bcrypt.hash(password, 10)
// 验证role_level范围
const validRoleLevel = [1, 2, 3, 4].includes(Number(role_level)) ? Number(role_level) : 4
const [result] = await pool.query(
'INSERT INTO users (username, password, is_active, role_id, employee_id, department) VALUES (?, ?, ?, ?, ?, ?)',
[username, hash, is_active, role_id || null, employee_id || null, req.body.department || department || null]
'INSERT INTO users (username, password, is_active, department_id, employee_id, role_level) VALUES (?, ?, ?, ?, ?, ?)',
[username, hash, is_active, department_id || null, employee_id || null, validRoleLevel]
)
const [rows] = await pool.query(
@@ -307,42 +330,30 @@ async function create(req, res) {
// PUT /api/users/:id —— 更新用户
async function update(req, res) {
const { id } = req.params
const fields = ['username', 'role_id', 'employee_id', 'department', 'is_active']
const fields = ['username', 'department_id', 'employee_id', 'is_active', 'role_level']
try {
const [existing] = await pool.query('SELECT id, role_id, is_active FROM users WHERE id = ?', [id])
const [existing] = await pool.query('SELECT id, department_id, is_active, employee_id FROM users WHERE id = ?', [id])
if (existing.length === 0) {
return res.status(404).json({ code: 404, message: '用户不存在' })
}
// 不允许修改自己的角色或禁用自己
if (Number(id) === req.user.id) {
if (req.body.role_id !== undefined && req.body.role_id !== req.user.role_id) {
return res.status(400).json({ code: 400, message: '不能修改自己的角色' })
}
if (req.body.is_active === 0) {
return res.status(400).json({ code: 400, message: '不能禁用自己' })
}
}
// 不允许修改自己的部门或禁用自己(此检查已移至中间件 protectSelfUpdate
// 校验 role_id
if (req.body.role_id) {
const [role] = await pool.query('SELECT id FROM roles WHERE id = ?', [req.body.role_id])
if (role.length === 0) {
return res.status(400).json({ code: 400, message: '指定的角色不存在' })
// 校验 department_id
if (req.body.department_id) {
const [dept] = await pool.query('SELECT id FROM departments WHERE id = ?', [req.body.department_id])
if (dept.length === 0) {
return res.status(400).json({ code: 400, message: '指定的部门不存在' })
}
}
// 校验 employee_id
if (req.body.employee_id) {
const [emp] = await pool.query('SELECT id, department FROM employees WHERE id = ?', [req.body.employee_id])
const [emp] = await pool.query('SELECT id FROM employees WHERE id = ?', [req.body.employee_id])
if (emp.length === 0) {
return res.status(400).json({ code: 400, message: '指定的员工不存在' })
}
// 自动同步 department
if (req.body.department === undefined) {
req.body.department = emp[0].department
}
}
const sets = []
@@ -372,6 +383,14 @@ async function update(req, res) {
params.push(id)
await pool.query(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`, params)
// 部门联动:用户部门变更时同步更新关联员工的部门
if (req.body.department_id !== undefined && existing[0].employee_id) {
const [[dept]] = await pool.query('SELECT description FROM departments WHERE id = ?', [req.body.department_id])
if (dept) {
await pool.query('UPDATE employees SET department = ? WHERE id = ?', [dept.description, existing[0].employee_id])
}
}
const [rows] = await pool.query(
`${USER_LIST_SQL} WHERE u.id = ?`,
[id]
@@ -390,24 +409,12 @@ async function update(req, res) {
async function remove(req, res) {
const { id } = req.params
try {
if (Number(id) === req.user.id) {
return res.status(400).json({ code: 400, message: '不能删除自己' })
}
const [existing] = await pool.query('SELECT id, role_id FROM users WHERE id = ?', [id])
// 不能删除自己/最后一个管理员(此检查已移至中间件 protectUserDelete
const [existing] = await pool.query('SELECT id FROM users WHERE id = ?', [id])
if (existing.length === 0) {
return res.status(404).json({ code: 404, message: '用户不存在' })
}
// 禁止删除最后一个管理员
const [adminRole] = await pool.query('SELECT id FROM roles WHERE name = ?', ['admin'])
if (adminRole.length > 0 && existing[0].role_id === adminRole[0].id) {
const [[{ cnt }]] = await pool.query('SELECT COUNT(*) AS cnt FROM users WHERE role_id = ?', [adminRole[0].id])
if (cnt <= 1) {
return res.status(400).json({ code: 400, message: '不能删除最后一个管理员' })
}
}
await pool.query('DELETE FROM users WHERE id = ?', [id])
res.json({ code: 0, message: 'ok' })
} catch (e) {

View File

@@ -11,7 +11,11 @@ const contracts = require('./routes/contracts')
const afterSales = require('./routes/afterSales')
const products = require('./routes/products')
const suppliers = require('./routes/suppliers')
const { checkPermission } = require('./middleware/permissions')
// 导入中间件
const { checkPermission, dataScope, validateContractType, checkHierarchy } = require('./middleware/permissions')
const { protectSelfUpdate, protectUserDelete } = require('./middleware/users')
const { allowUserCreation } = require('./middleware/employees')
const app = express()
app.use(express.json()) // 解析 application/json 请求体
@@ -35,7 +39,7 @@ function auth(req, res, next) {
// ============ 登录/登出/个人信息 ============
app.post('/api/user/login', users.login)
app.get('/api/user/info', auth, users.info)
app.get('/api/user/list', auth, checkPermission('user', 'manage'), users.simpleList)
app.get('/api/user/list', auth, users.simpleList)
app.post('/api/user/logout', auth, users.logout)
app.put('/api/user/password', auth, users.changePassword)
@@ -43,36 +47,38 @@ app.put('/api/user/password', auth, users.changePassword)
app.get('/api/users', auth, checkPermission('user', 'manage'), users.list)
app.get('/api/users/:id', auth, checkPermission('user', 'manage'), users.detail)
app.post('/api/users', auth, checkPermission('user', 'manage'), users.create)
app.put('/api/users/:id', auth, checkPermission('user', 'manage'), users.update)
app.delete('/api/users/:id', auth, checkPermission('user', 'manage'), users.remove)
app.put('/api/users/:id', auth, checkPermission('user', 'manage'), checkHierarchy, protectSelfUpdate, users.update)
app.delete('/api/users/:id', auth, checkPermission('user', 'manage'), checkHierarchy, protectUserDelete, users.remove)
// ============ 加盟商管理 /api/customers ============
app.get('/api/customers', auth, checkPermission('customer', 'read'), customers.list)
app.get('/api/customers/:id', auth, checkPermission('customer', 'read'), customers.detail)
app.post('/api/customers', auth, checkPermission('customer', 'create'), customers.create)
app.put('/api/customers/:id', auth, checkPermission('customer', 'update'), customers.update)
app.delete('/api/customers/:id', auth, checkPermission('customer', 'delete'), customers.remove)
app.get('/api/customers/simple', auth, checkPermission('customer', 'read'), customers.simpleList)
app.get('/api/customers', auth, checkPermission('customer', 'read'), dataScope('customers'), customers.list)
app.get('/api/customers/:id', auth, checkPermission('customer', 'read'), dataScope('customers'), customers.detail)
app.post('/api/customers', auth, checkPermission('customer', 'create'), customers.create)
app.put('/api/customers/:id', auth, checkPermission('customer', 'update'),dataScope('customers'), customers.update)
app.delete('/api/customers/:id', auth, checkPermission('customer', 'delete'),dataScope('customers'), customers.remove)
// ============ 员工管理 /api/employees ============
app.get('/api/employees', auth, checkPermission('employee', 'read'), employees.list)
app.get('/api/employees/:id', auth, checkPermission('employee', 'read'), employees.detail)
app.post('/api/employees', auth, checkPermission('employee', 'create'), employees.create)
app.put('/api/employees/:id', auth, checkPermission('employee', 'update'), employees.update)
app.delete('/api/employees/:id', auth, checkPermission('employee', 'delete'), employees.remove)
app.get('/api/employees/simple', auth, employees.simpleList)
app.get('/api/employees', auth, checkPermission('employee', 'read'), dataScope('employees'), employees.list)
app.get('/api/employees/:id', auth, checkPermission('employee', 'read'), dataScope('employees'), employees.detail)
app.post('/api/employees', auth, checkPermission('employee', 'create'),allowUserCreation, employees.create)
app.put('/api/employees/:id', auth, checkPermission('employee', 'update'),dataScope('employees'), employees.update)
app.delete('/api/employees/:id', auth, checkPermission('employee', 'delete'),dataScope('employees'), employees.remove)
// ============ 合同管理 /api/contracts ============
app.get('/api/contracts', auth, checkPermission('contract', 'read'), contracts.list)
app.get('/api/contracts/:id', auth, checkPermission('contract', 'read'), contracts.detail)
app.post('/api/contracts', auth, checkPermission('contract', 'create'), contracts.create)
app.put('/api/contracts/:id', auth, checkPermission('contract', 'update'), contracts.update)
app.delete('/api/contracts/:id', auth, checkPermission('contract', 'delete'), contracts.remove)
app.get('/api/contracts', auth, checkPermission('contract', 'read'), dataScope('contracts'), contracts.list)
app.get('/api/contracts/:id', auth, checkPermission('contract', 'read'), dataScope('contracts'), contracts.detail)
app.post('/api/contracts', auth, checkPermission('contract', 'create'),validateContractType, contracts.create)
app.put('/api/contracts/:id', auth, checkPermission('contract', 'update'),validateContractType,dataScope('contracts'),contracts.update)
app.delete('/api/contracts/:id', auth, checkPermission('contract', 'delete'),dataScope('contracts'), contracts.remove)
// ============ 售后管理 /api/after-sales ============
app.get('/api/after-sales', auth, checkPermission('after_sale', 'read'), afterSales.list)
app.get('/api/after-sales/:id', auth, checkPermission('after_sale', 'read'), afterSales.detail)
app.post('/api/after-sales', auth, checkPermission('after_sale', 'create'), afterSales.create)
app.put('/api/after-sales/:id', auth, checkPermission('after_sale', 'update'), afterSales.update)
app.delete('/api/after-sales/:id', auth, checkPermission('after_sale', 'delete'), afterSales.remove)
app.get('/api/after-sales', auth, checkPermission('after_sale', 'read'), dataScope('after_sales'), afterSales.list)
app.get('/api/after-sales/:id', auth, checkPermission('after_sale', 'read'), dataScope('after_sales'), afterSales.detail)
app.post('/api/after-sales', auth, checkPermission('after_sale', 'create'), afterSales.create)
app.put('/api/after-sales/:id', auth, checkPermission('after_sale', 'update'),dataScope('after_sales'),afterSales.update)
app.delete('/api/after-sales/:id', auth, checkPermission('after_sale', 'delete'),dataScope('after_sales'),afterSales.remove)
// ============ 产品管理 /api/products ============
app.get('/api/products', auth, checkPermission('product', 'read'), products.list)
@@ -82,6 +88,7 @@ app.put('/api/products/:id', auth, checkPermission('product', 'update'), p
app.delete('/api/products/:id', auth, checkPermission('product', 'delete'), products.remove)
// ============ 供应商管理 /api/suppliers ============
app.get('/api/suppliers/simple', auth, checkPermission('supplier', 'read'), suppliers.simpleList)
app.get('/api/suppliers', auth, checkPermission('supplier', 'read'), suppliers.list)
app.get('/api/suppliers/:id', auth, checkPermission('supplier', 'read'), suppliers.detail)
app.post('/api/suppliers', auth, checkPermission('supplier', 'create'), suppliers.create)

View File

@@ -52,7 +52,8 @@ function check(label, res, expectStatus) {
} else {
failCount++
console.log(`${label} 预期 HTTP ${expectStatus}, 实际 HTTP ${res.status}`)
console.log(` 响应: ${JSON.stringify(res.body).substring(0, 300)}`)
const preview = JSON.stringify(res.body).substring(0, 300)
if (preview) console.log(` 响应: ${preview}`)
}
return res
}
@@ -70,7 +71,7 @@ function checkValue(label, actual, expected) {
// ========== 主流程 ==========
async function main() {
console.log('╔══════════════════════════════════════════════════╗')
console.log('║ 蜜雪冰城企业管理系统 — 全量接口测试 ║')
console.log('║ 蜜雪冰城企业管理系统 — 全量接口测试 V2 ║')
console.log('╚══════════════════════════════════════════════════╝')
console.log(`服务地址: ${BASE}\n`)
@@ -82,12 +83,12 @@ async function main() {
console.log('━'.repeat(55))
const accounts = [
{ username: 'admin', password: '123456', label: '系统管理员' },
{ username: 'liming', password: '123456', label: '招商经理' },
{ username: 'wangli', password: '123456', label: '运营经理' },
{ username: 'zhaoqiang', password: '123456', label: '采购经理' },
{ username: 'chenfang', password: '123456', label: '财务人员' },
{ username: 'zhangchao', password: '123456', label: '总经理' },
{ username: 'admin', password: '123456', label: '信息技术部' },
{ username: 'zhangchao', password: '123456', label: '总经理办公室' },
{ username: 'liming', password: '123456', label: '招商部' },
{ username: 'wangli', password: '123456', label: '运营部' },
{ username: 'zhaoqiang', password: '123456', label: '采购部' },
{ username: 'chenfang', password: '123456', label: '财务部' },
]
for (const acct of accounts) {
@@ -98,49 +99,56 @@ async function main() {
}
}
// 错误密码
let r = await req('POST', '/api/user/login', { username: 'admin', password: 'wrong' })
check('POST /api/user/login (错误密码)', r, 400)
check('POST /api/user/login (错误密码 → 400)', r, 400)
r = await req('POST', '/api/user/login', { username: '', password: '' })
check('POST /api/user/login (空参数)', r, 400)
check('POST /api/user/login (空参数 → 400)', r, 400)
// ══════════════════════════════════════════
// 【2】 JWT payload 验证
// 【2】 JWT payload 验证(新字段名)
// ══════════════════════════════════════════
console.log('\n' + '━'.repeat(55))
console.log('【2】 JWT payload 验证(新字段')
console.log('【2】 JWT payload 验证(departmentName / departmentDesc / permissions')
console.log('━'.repeat(55))
// 验证 admin 登录返回的 userInfo 结构
const adminLogin = await req('POST', '/api/user/login', { username: 'admin', password: '123456' })
const adminInfo = adminLogin.body?.data?.userInfo
if (adminInfo) {
checkValue('admin userInfo.name', adminInfo.name, '系统管理员')
checkValue('admin userInfo.roleName', adminInfo.roleName, 'admin')
checkValue('admin userInfo.department', adminInfo.department, '信息技术部')
check('admin userInfo.permissions 是数组', { status: Array.isArray(adminInfo.permissions) ? 200 : 500 }, 200)
checkValue('admin name', adminInfo.name, '系统管理员')
checkValue('admin departmentName', adminInfo.departmentName, 'admin')
checkValue('admin departmentDesc', adminInfo.departmentDesc, '信息技术部')
check('admin permissions 是数组', { status: Array.isArray(adminInfo.permissions) ? 200 : 500 }, 200)
checkValue('admin permissions 数量', adminInfo.permissions?.length, 25)
}
// 验证 liming招商经理的权限
const limingLogin = await req('POST', '/api/user/login', { username: 'liming', password: '123456' })
const limingInfo = limingLogin.body?.data?.userInfo
if (limingInfo) {
checkValue('liming userInfo.name', limingInfo.name, '李明')
checkValue('liming userInfo.roleName', limingInfo.roleName, 'franchise_manager')
checkValue('liming userInfo.department', limingInfo.department, '招商部')
checkValue('liming name', limingInfo.name, '李明')
checkValue('liming departmentName', limingInfo.departmentName, 'franchise_manager')
checkValue('liming departmentDesc', limingInfo.departmentDesc, '招商部')
check('liming 有 customer:read', { status: limingInfo.permissions?.includes('customer:read') ? 200 : 500 }, 200)
check('liming 有 customer:create', { status: limingInfo.permissions?.includes('customer:create') ? 200 : 500 }, 200)
check('liming 无 product:read', { status: !limingInfo.permissions?.includes('product:read') ? 200 : 500 }, 200)
check('liming 无 supplier:read', { status: !limingInfo.permissions?.includes('supplier:read') ? 200 : 500 }, 200)
check('liming 无 after_sale:read', { status: !limingInfo.permissions?.includes('after_sale:read') ? 200 : 500 }, 200)
}
// 验证 /api/user/info 返回新字段
const zhaoqiangLogin = await req('POST', '/api/user/login', { username: 'zhaoqiang', password: '123456' })
const zhaoqiangInfo = zhaoqiangLogin.body?.data?.userInfo
if (zhaoqiangInfo) {
checkValue('zhaoqiang name', zhaoqiangInfo.name, '赵强')
check('zhaoqiang 有 customer:read新增权限', { status: zhaoqiangInfo.permissions?.includes('customer:read') ? 200 : 500 }, 200)
check('zhaoqiang 有 supplier:create', { status: zhaoqiangInfo.permissions?.includes('supplier:create') ? 200 : 500 }, 200)
}
// GET /api/user/info 返回新字段
r = await req('GET', '/api/user/info', null, tokens.admin)
check('GET /api/user/info (admin)', r, 200)
if (r.body?.data) {
check('info 包含 role_name 字段', { status: r.body.data.role_name ? 200 : 500 }, 200)
check('info 包含 department 字段', { status: r.body.data.department ? 200 : 500 }, 200)
check('info 有 dept_name', { status: r.body.data.dept_name ? 200 : 500 }, 200)
check('info dept_desc', { status: r.body.data.dept_desc ? 200 : 500 }, 200)
}
// ══════════════════════════════════════════
@@ -150,222 +158,214 @@ async function main() {
console.log('【3】 权限隔离测试(无权限应返回 403')
console.log('━'.repeat(55))
// 招商经理 → products无权限
// 招商 → products无权限
r = await req('GET', '/api/products', null, tokens.liming)
check('liming → GET /api/products → 403', r, 403)
r = await req('POST', '/api/products', { name: 'x' }, tokens.liming)
check('liming → POST /api/products → 403', r, 403)
// 招商经理 → suppliers无权限
// 招商 → suppliers无权限
r = await req('GET', '/api/suppliers', null, tokens.liming)
check('liming → GET /api/suppliers → 403', r, 403)
// 招商经理 → after-sales无权限
// 招商 → after-sales无权限
r = await req('GET', '/api/after-sales', null, tokens.liming)
check('liming → GET /api/after-sales → 403', r, 403)
// 采购经理 → customers无权限
r = await req('GET', '/api/customers', null, tokens.zhaoqiang)
check('zhaoqiang → GET /api/customers → 403', r, 403)
// 运营部 → products无权限
r = await req('GET', '/api/products', null, tokens.wangli)
check('wangli → GET /api/products → 403', r, 403)
// 采购经理 → after-sales无权限
// 运营部 → suppliers无权限
r = await req('GET', '/api/suppliers', null, tokens.wangli)
check('wangli → GET /api/suppliers → 403', r, 403)
// 运营部 → contracts无权限
r = await req('GET', '/api/contracts', null, tokens.wangli)
check('wangli → GET /api/contracts → 403 (运营部无合同权限)', r, 403)
// 采购部 → after-sales无权限
r = await req('GET', '/api/after-sales', null, tokens.zhaoqiang)
check('zhaoqiang → GET /api/after-sales → 403', r, 403)
// 财务人员products无权限
r = await req('GET', '/api/products', null, tokens.chenfang)
check('chenfang → GET /api/products → 403', r, 403)
// 财务人员 → suppliers无权限
r = await req('GET', '/api/suppliers', null, tokens.chenfang)
check('chenfang → GET /api/suppliers → 403', r, 403)
// 财务人员 → 创建 customers只有 read 权限,无 create
// 财务POST customers只有 read
r = await req('POST', '/api/customers', { name: '财务创建' }, tokens.chenfang)
check('chenfang → POST /api/customers → 403', r, 403)
check('chenfang → POST /api/customers → 403 (仅可读)', r, 403)
// 财务人员创建 employees只有 read,无 create
// 财务POST employees只有 read
r = await req('POST', '/api/employees', { name: '财务创建' }, tokens.chenfang)
check('chenfang → POST /api/employees → 403', r, 403)
check('chenfang → POST /api/employees → 403 (仅可读)', r, 403)
// 总经理 → 创建 customers只有 read,无 create
// 总经理 → POST customers只有 read
r = await req('POST', '/api/customers', { name: '总经理创建' }, tokens.zhangchao)
check('zhangchao → POST /api/customers → 403', r, 403)
check('zhangchao → POST /api/customers → 403 (仅可读)', r, 403)
// 总经理 → 创建 contracts只有 read,无 create
// 总经理 → POST contracts只有 read
r = await req('POST', '/api/contracts', { contract_name: 'x', customer_id: 1 }, tokens.zhangchao)
check('zhangchao → POST /api/contracts → 403', r, 403)
check('zhangchao → POST /api/contracts → 403 (仅可读)', r, 403)
// 非管理员 → 用户管理
r = await req('GET', '/api/users', null, tokens.liming)
check('liming → GET /api/users → 403', r, 403)
check('liming → GET /api/users → 403 (非管理员)', r, 403)
// 无 token
r = await req('GET', '/api/customers', null, null)
check('无 token → GET /api/customers → 401', r, 401)
// ══════════════════════════════════════════
// 【4】 数据范围隔离测试
// 【4】 简易列表接口
// ══════════════════════════════════════════
console.log('\n' + '━'.repeat(55))
console.log('【4】 数据范围隔离测试')
console.log('【4】 简易列表接口(下拉选择用)')
console.log('━'.repeat(55))
// 4.1 加盟商范围隔离admin 创建 2 个加盟商,分别分配给 liming 和 wangli
// 获取 liming 和 wangli 的 user ID
const allUsers = await req('GET', '/api/users', null, tokens.admin)
const limingUserId = allUsers.body?.data?.list?.find(u => u.username === 'liming')?.id
const wangliUserId = allUsers.body?.data?.list?.find(u => u.username === 'wangli')?.id
r = await req('POST', '/api/customers', { name: '加盟商A-归属liming', phone: '13811111111', responsible_user_id: limingUserId }, tokens.admin)
check('admin 创建加盟商A (归属liming)', r, 200)
const custAId = r.body?.data?.id
if (custAId) created.customers.push(custAId)
r = await req('POST', '/api/customers', { name: '加盟商B-归属wangli', phone: '13822222222', responsible_user_id: wangliUserId }, tokens.admin)
check('admin 创建加盟商B (归属wangli)', r, 200)
const custBId = r.body?.data?.id
if (custBId) created.customers.push(custBId)
// liming 只能看到自己负责的
r = await req('GET', '/api/customers', null, tokens.liming)
check('liming GET /api/customers (应只看到自己的)', r, 200)
r = await req('GET', '/api/user/list', null, tokens.admin)
check('GET /api/user/list (admin)', r, 200)
if (r.body?.data) {
const limingCusts = r.body.data.list || []
const allBelongToLiming = limingCusts.every(c => c.responsible_user_id === limingUserId)
checkValue('liming 只看到自己的加盟商', allBelongToLiming ? 'YES' : 'NO', 'YES')
checkValue('liming 加盟商数量 >= 1', limingCusts.length >= 1 ? 'YES' : 'NO', 'YES')
checkValue('user/list 数量 >= 6', r.body.data.length >= 6 ? 'YES' : 'NO', 'YES')
}
// wangli 只能看到自己负责的
r = await req('GET', '/api/customers', null, tokens.wangli)
check('wangli GET /api/customers (应只看到自己的)', r, 200)
// 放宽为 auth 后,非管理员也能调
r = await req('GET', '/api/user/list', null, tokens.liming)
check('GET /api/user/list (liming auth即可)', r, 200)
r = await req('GET', '/api/employees/simple', null, tokens.liming)
check('GET /api/employees/simple (liming)', r, 200)
r = await req('GET', '/api/customers/simple', null, tokens.liming)
check('GET /api/customers/simple (liming)', r, 200)
if (r.body?.data) {
const wangliCusts = r.body.data.list || []
const allBelongToWangli = wangliCusts.every(c => c.responsible_user_id === wangliUserId)
checkValue('wangli 只看到自己的加盟商', allBelongToWangli ? 'YES' : 'NO', 'YES')
check('customers/simple 有 id+name', { status: r.body.data[0]?.id && r.body.data[0]?.name ? 200 : 500 }, 200)
}
// admin 能看到全部
r = await req('GET', '/api/suppliers/simple', null, tokens.zhaoqiang)
check('GET /api/suppliers/simple (zhaoqiang)', r, 200)
// 无 supplier:read 的看不到供应商简易列表
r = await req('GET', '/api/suppliers/simple', null, tokens.liming)
check('GET /api/suppliers/simple (liming 无权限 → 403)', r, 403)
// ══════════════════════════════════════════
// 【5】 数据范围隔离测试(过渡期规则)
// ══════════════════════════════════════════
console.log('\n' + '━'.repeat(55))
console.log('【5】 数据范围隔离测试')
console.log('━'.repeat(55))
// 5.1 加盟商:招商部看全量(过渡期)
r = await req('GET', '/api/customers', null, tokens.admin)
check('admin GET /api/customers (应看到全部)', r, 200)
check('admin GET /api/customers (全量)', r, 200)
const adminCustCount = r.body?.data?.total || 0
r = await req('GET', '/api/customers', null, tokens.liming)
check('liming GET /api/customers (全量-过渡期)', r, 200)
if (r.body?.data) {
checkValue('admin 加盟商>= 2', r.body.data.total >= 2 ? 'YES' : 'NO', 'YES')
checkValue('liming 看到加盟商数 = admin', r.body.data.total >= adminCustCount ? 'YES' : 'NO', 'YES')
}
// liming 不能看 wangli 的加盟商详情
if (custBId) {
r = await req('GET', '/api/customers/' + custBId, null, tokens.liming)
check('liming GET wangli的加盟商详情 → 404', r, 404)
r = await req('GET', '/api/customers', null, tokens.wangli)
check('wangli GET /api/customers (全量-过渡期)', r, 200)
if (r.body?.data) {
checkValue('wangli 看到加盟商数 = admin', r.body.data.total >= adminCustCount ? 'YES' : 'NO', 'YES')
}
// liming 不能修改 wangli 的加盟商
if (custBId) {
r = await req('PUT', '/api/customers/' + custBId, { name: '越权修改' }, tokens.liming)
check('liming PUT wangli的加盟商 → 404', r, 404)
// 采购部现在也能看加盟商
r = await req('GET', '/api/customers', null, tokens.zhaoqiang)
check('zhaoqiang GET /api/customers (采购部可看)', r, 200)
// 5.2 合同类型隔离
// 全量看
r = await req('GET', '/api/contracts', null, tokens.admin)
check('admin GET /api/contracts (全量)', r, 200)
// 招商部只看 franchise
r = await req('GET', '/api/contracts', null, tokens.liming)
check('liming GET /api/contracts (只看加盟合同)', r, 200)
if (r.body?.data) {
const allFranchise = (r.body.data.list || []).every(c => c.type === 'franchise')
checkValue('liming 合同全是 franchise', allFranchise ? 'YES' : 'NO', 'YES')
check('liming 有加盟合同', { status: r.body.data.total > 0 ? 200 : 500 }, 200)
}
// 4.2 员工部门隔离admin 创建 2 个不同部门的员工
r = await req('POST', '/api/employees', { name: '招商部测试员工', department: '招商部', position: '专员', status: 1 }, tokens.admin)
check('admin 创建招商部员工', r, 200)
const empLmId = r.body?.data?.id
if (empLmId) created.employees.push(empLmId)
// 采购部只看 supply
r = await req('GET', '/api/contracts', null, tokens.zhaoqiang)
check('zhaoqiang GET /api/contracts (只看采购合同)', r, 200)
if (r.body?.data) {
const allSupply = (r.body.data.list || []).every(c => c.type === 'supply')
checkValue('zhaoqiang 合同全是 supply', allSupply ? 'YES' : 'NO', 'YES')
}
r = await req('POST', '/api/employees', { name: '运营部测试员工', department: '运营部', position: '专员', status: 1 }, tokens.admin)
check('admin 创建运营部员工', r, 200)
const empWlId = r.body?.data?.id
if (empWlId) created.employees.push(empWlId)
// 运营部无合同权限(走不到 dataScope
r = await req('GET', '/api/contracts', null, tokens.wangli)
check('wangli → GET /api/contracts → 403', r, 403)
// liming 只能看到招商部员工
// 5.3 售后:运营部看全量(过渡期)
r = await req('GET', '/api/after-sales', null, tokens.wangli)
check('wangli GET /api/after-sales (全量-过渡期)', r, 200)
if (r.body?.data) {
check('wangli 看到售后数据', { status: r.body.data.total > 0 ? 200 : 500 }, 200)
}
r = await req('GET', '/api/after-sales', null, tokens.chenfang)
check('chenfang GET /api/after-sales (财务全量)', r, 200)
// 5.4 员工部门隔离
r = await req('GET', '/api/employees', null, tokens.liming)
check('liming GET /api/employees (部门隔离)', r, 200)
if (r.body?.data) {
const limingEmps = r.body.data.list || []
const allInDept = limingEmps.every(e => e.department === '招商部')
checkValue('liming 只看到招商部员工', allInDept ? 'YES' : 'NO', 'YES')
const allInDept = (r.body.data.list || []).every(e => e.department === '招商部')
checkValue('liming 只看招商部员工', allInDept ? 'YES' : 'NO', 'YES')
}
// wangli 只能看到运营部员工
r = await req('GET', '/api/employees', null, tokens.wangli)
check('wangli GET /api/employees (部门隔离)', r, 200)
if (r.body?.data) {
const wangliEmps = r.body.data.list || []
const allInDept = wangliEmps.every(e => e.department === '运营部')
checkValue('wangli 只看到运营部员工', allInDept ? 'YES' : 'NO', 'YES')
const allInDept = (r.body.data.list || []).every(e => e.department === '运营部')
checkValue('wangli 只看运营部员工', allInDept ? 'YES' : 'NO', 'YES')
}
// admin 看到全部
r = await req('GET', '/api/employees', null, tokens.admin)
check('admin GET /api/employees (应看到全部)', r, 200)
if (r.body?.data) {
checkValue('admin 员工总数 >= 2', r.body.data.total >= 2 ? 'YES' : 'NO', 'YES')
}
// 4.3 合同范围隔离
// admin 创建加盟合同(归属 liming和采购合同
r = await req('POST', '/api/contracts', {
customer_id: custAId, contract_name: '加盟合同A', amount: 100000,
effective_date: '2026-01-01', expiry_date: '2027-01-01', status: '生效',
type: 'franchise',
}, tokens.admin)
check('admin 创建加盟合同 (归属liming的客户)', r, 200)
const franchiseConId = r.body?.data?.id
if (franchiseConId) created.contracts.push(franchiseConId)
r = await req('POST', '/api/contracts', {
customer_id: custAId, contract_name: '采购合同X', amount: 200000,
effective_date: '2026-01-01', expiry_date: '2027-01-01', status: '生效',
type: 'supply',
}, tokens.admin)
check('admin 创建采购合同', r, 200)
const supplyConId = r.body?.data?.id
if (supplyConId) created.contracts.push(supplyConId)
// 采购经理只看 supply 合同
r = await req('GET', '/api/contracts', null, tokens.zhaoqiang)
check('zhaoqiang GET /api/contracts (只看supply)', r, 200)
if (r.body?.data) {
const zhaoContracts = r.body.data.list || []
const allSupply = zhaoContracts.every(c => c.type === 'supply')
checkValue('zhaoqiang 只看到supply合同', allSupply ? 'YES' : 'NO', 'YES')
}
// 4.4 after_sales 范围隔离admin 创建售后记录
r = await req('POST', '/api/after-sales', {
customer_id: custAId, feedback: '设备故障归属wangli处理',
responsible_user_id: wangliUserId, handle_status: '待处理',
}, tokens.admin)
check('admin 创建售后记录 (归属wangli)', r, 200)
const asScopeId = r.body?.data?.id
if (asScopeId) created.afterSales.push(asScopeId)
r = await req('POST', '/api/after-sales', {
customer_id: custBId, feedback: '原料质量问题,归属当前用户处理',
handle_status: '待处理',
}, tokens.wangli)
check('wangli 创建售后记录 (默认归属自己)', r, 200)
const asScopeId2 = r.body?.data?.id
if (asScopeId2) created.afterSales.push(asScopeId2)
// wangli 应该能看到 2 条售后(都是自己负责的)
r = await req('GET', '/api/after-sales', null, tokens.wangli)
check('wangli GET /api/after-sales (应看到自己负责的)', r, 200)
if (r.body?.data) {
const wangliAS = r.body.data.list || []
const allBelong = wangliAS.every(a => a.responsible_user_id === wangliUserId)
checkValue('wangli 只看到自己的售后', allBelong ? 'YES' : 'NO', 'YES')
}
// liming 没有 after_sale:read 权限,直接 403
r = await req('GET', '/api/after-sales', null, tokens.liming)
check('liming GET /api/after-sales → 403 (无权限)', r, 403)
check('admin GET /api/employees (全量)', r, 200)
// ══════════════════════════════════════════
// 【5suppliers CRUD 测试
// 【6合同类型校验中间件
// ══════════════════════════════════════════
console.log('\n' + '━'.repeat(55))
console.log('【5suppliers CRUD采购经理操作')
console.log('【6validateContractType 中间件测试')
console.log('━'.repeat(55))
// 获取一个加盟商 ID 用于测试
const custList = await req('GET', '/api/customers?pageSize=1', null, tokens.admin)
const testCustId = custList.body?.data?.list?.[0]?.id
// 招商部不能创建采购合同
r = await req('POST', '/api/contracts', {
customer_id: testCustId, contract_name: '越权采购合同',
type: 'supply', effective_date: '2026-01-01', expiry_date: '2027-01-01',
}, tokens.liming)
check('liming → POST supply合同 → 403', r, 403)
// 采购部不能创建加盟合同
r = await req('POST', '/api/contracts', {
customer_id: testCustId, contract_name: '越权加盟合同',
type: 'franchise', effective_date: '2026-01-01', expiry_date: '2027-01-01',
}, tokens.zhaoqiang)
check('zhaoqiang → POST franchise合同 → 403', r, 403)
// admin 可以创建任意类型
r = await req('POST', '/api/contracts', {
customer_id: testCustId, contract_name: 'admin测试合同',
amount: 100000, effective_date: '2026-01-01', expiry_date: '2027-01-01',
type: 'franchise', status: '生效',
}, tokens.admin)
check('admin → POST franchise合同 → 200', r, 200)
if (r.body?.data?.id) created.contracts.push(r.body.data.id)
// ══════════════════════════════════════════
// 【7】 suppliers CRUD
// ══════════════════════════════════════════
console.log('\n' + '━'.repeat(55))
console.log('【7】 suppliers CRUD采购部操作')
console.log('━'.repeat(55))
r = await req('POST', '/api/suppliers', {
@@ -377,53 +377,56 @@ async function main() {
if (supId) created.suppliers.push(supId)
r = await req('POST', '/api/suppliers', { name: '' }, tokens.zhaoqiang)
check('zhaoqiang POST /api/suppliers (缺名)', r, 400)
check('zhaoqiang POST /api/suppliers (缺名 → 400)', r, 400)
if (supId) {
r = await req('GET', '/api/suppliers/' + supId, null, tokens.zhaoqiang)
check('zhaoqiang GET /api/suppliers/:id (详情)', r, 200)
if (r.body?.data) {
checkValue('供应商名称', r.body.data.name, '柠檬供应商-测试')
}
check('GET /api/suppliers/:id (详情)', r, 200)
if (r.body?.data) checkValue('供应商名称', r.body.data.name, '柠檬供应商-测试')
r = await req('PUT', '/api/suppliers/' + supId, { phone: '13800008888', remark: '更新联系方式' }, tokens.zhaoqiang)
check('zhaoqiang PUT /api/suppliers/:id (更新)', r, 200)
r = await req('GET', '/api/suppliers', null, tokens.zhaoqiang)
check('zhaoqiang GET /api/suppliers (列表)', r, 200)
if (r.body?.data) console.log(` ↳ 供应商总数: ${r.body.data.total}`)
r = await req('PUT', '/api/suppliers/' + supId, { phone: '13800008888' }, tokens.zhaoqiang)
check('PUT /api/suppliers/:id (更新)', r, 200)
r = await req('GET', '/api/suppliers?name=柠檬', null, tokens.zhaoqiang)
check('zhaoqiang GET /api/suppliers?name=柠檬 (搜索)', r, 200)
check('GET /api/suppliers?name=柠檬 (搜索)', r, 200)
}
// ══════════════════════════════════════════
// 【6】 users CRUD 测试(适配新字段
// 【8】 users CRUD(新字段 department_id
// ══════════════════════════════════════════
console.log('\n' + '━'.repeat(55))
console.log('【6】 users CRUD管理员操作')
console.log('【8】 users CRUD管理员操作,新字段 department_id')
console.log('━'.repeat(55))
r = await req('GET', '/api/users', null, tokens.admin)
check('admin GET /api/users (列表)', r, 200)
if (r.body?.data) console.log(` ↳ 用户总数: ${r.body.data.total}`)
if (r.body?.data) {
console.log(` ↳ 用户总数: ${r.body.data.total}`)
// 验证新字段
const sample = r.body.data.list[0]
check('用户有 dept_name', { status: sample?.dept_name ? 200 : 500 }, 200)
check('用户有 dept_desc', { status: sample?.dept_desc ? 200 : 500 }, 200)
check('用户有 real_name (employees联查)', { status: sample?.real_name ? 200 : 500 }, 200)
}
// 创建新用户,关联到已有员工
// 管理员查自己 ID
const allUsers = await req('GET', '/api/users', null, tokens.admin)
const adminUser = allUsers.body?.data?.list?.find(u => u.username === 'admin')
const adminUserId = adminUser?.id
// 创建新用户(用新字段 department_id
r = await req('POST', '/api/users', {
username: 'testuser_' + Date.now(),
password: 'pass123456',
role_id: 3, // franchise_manager
employee_id: empLmId,
department: '招商部',
department_id: 3, // franchise_manager 的 ID
}, tokens.admin)
check('admin POST /api/users (创建带role_id+employee_id)', r, 200)
check('admin POST /api/users (创建,新字段)', r, 200)
const newUserId = r.body?.data?.id
if (newUserId) {
created.users.push(newUserId)
if (r.body?.data) {
check('新用户有 role_name', { status: r.body.data.role_name ? 200 : 500 }, 200)
check('新用户有 real_name (从employees)', { status: r.body.data.real_name ? 200 : 500 }, 200)
console.log(` ↳ 新用户: ${r.body.data.username}, 角色: ${r.body.data.role_name}, 姓名: ${r.body.data.real_name}`)
check('新用户有 dept_name', { status: r.body.data.dept_name ? 200 : 500 }, 200)
check('新用户有 dept_desc', { status: r.body.data.dept_desc ? 200 : 500 }, 200)
}
r = await req('GET', '/api/users/' + newUserId, null, tokens.admin)
@@ -431,28 +434,71 @@ async function main() {
r = await req('PUT', '/api/users/' + newUserId, { is_active: 0 }, tokens.admin)
check('admin PUT /api/users/:id (禁用)', r, 200)
if (r.body?.data) checkValue('is_active 已禁用', r.body.data.is_active, 0)
r = await req('PUT', '/api/users/' + newUserId, { is_active: 1, password: 'newpass123' }, tokens.admin)
check('admin PUT /api/users/:id (启用+重置密码)', r, 200)
check('admin PUT /api/users/:id (启用+改密)', r, 200)
}
// admin 不能删除自己(先获取 admin 的真实 ID
const adminUser = allUsers.body?.data?.list?.find(u => u.username === 'admin')
const adminUserId = adminUser?.id
// admin 不能删除自己
r = await req('DELETE', '/api/users/' + adminUserId, null, tokens.admin)
check('admin DELETE /api/users/' + adminUserId + ' (删自己) → 400', r, 400)
check('admin DELETE 自己 → 400', r, 400)
// admin 不能禁用自己
r = await req('PUT', '/api/users/' + adminUserId, { is_active: 0 }, tokens.admin)
check('admin PUT /api/users/' + adminUserId + ' (禁用自己) → 400', r, 400)
check('admin PUT 禁用自己 → 400', r, 400)
// admin 不能改自己部门
r = await req('PUT', '/api/users/' + adminUserId, { department_id: 3 }, tokens.admin)
check('admin PUT 改自己部门 → 400', r, 400)
// 按部门筛选用户
r = await req('GET', '/api/users?department_id=1', null, tokens.admin)
check('GET /api/users?department_id=1 (筛选admin部门)', r, 200)
// ══════════════════════════════════════════
// 【7customers CRUD 测试
// 【9allowUserCreation创建员工同时建用户
// ══════════════════════════════════════════
console.log('\n' + '━'.repeat(55))
console.log('【7customers CRUD')
console.log('【9allowUserCreation 中间件测试')
console.log('━'.repeat(55))
// 管理员创建员工同时建账号
r = await req('POST', '/api/employees', {
name: '测试员工-带账号', gender: '男', department: '招商部',
position: '专员', status: 1,
username: 'testemp_' + Date.now(),
password: 'pass123456',
department_id: 3, // franchise_manager
}, tokens.admin)
check('admin POST /api/employees (同步建用户)', r, 200)
if (r.body?.data?.id) created.employees.push(r.body.data.id)
// 非管理员创建员工不能带用户字段
r = await req('POST', '/api/employees', {
name: '测试员工-越权', department: '招商部',
username: 'hackuser', password: 'pass123', department_id: 3,
}, tokens.liming)
check('liming POST /api/employees 带 user 字段 → 403', r, 403)
// 管理员不带用户字段 → 只创建员工
r = await req('POST', '/api/employees', {
name: '测试员工-纯员工', department: '招商部', position: '专员', status: 1,
}, tokens.admin)
check('admin POST /api/employees (不建用户)', r, 200)
if (r.body?.data?.id) created.employees.push(r.body.data.id)
// 管理员建用户但缺字段
r = await req('POST', '/api/employees', {
name: '测试员工-缺字段', department: '招商部',
username: 'testuser2', // 缺 password 和 department_id
}, tokens.admin)
check('admin POST /api/employees (建用户缺字段 → 400)', r, 400)
// ══════════════════════════════════════════
// 【10】 customers CRUD
// ══════════════════════════════════════════
console.log('\n' + '━'.repeat(55))
console.log('【10】 customers CRUD')
console.log('━'.repeat(55))
r = await req('POST', '/api/customers', {
@@ -463,17 +509,18 @@ async function main() {
const custCrudId = r.body?.data?.id
if (custCrudId) {
created.customers.push(custCrudId)
checkValue('默认 responsible_user_id = admin', r.body.data.responsible_user_id, adminUserId)
check('客户创建成功', { status: r.body.data.id ? 200 : 500 }, 200)
}
r = await req('POST', '/api/customers', { name: '' }, tokens.admin)
check('POST /api/customers (缺名)', r, 400)
check('POST /api/customers (缺名 → 400)', r, 400)
if (custCrudId) {
r = await req('GET', '/api/customers/' + custCrudId, null, tokens.admin)
check('GET /api/customers/:id (详情)', r, 200)
if (r.body?.data) checkValue('加盟商名', r.body.data.name, 'CRUD测试加盟商')
r = await req('PUT', '/api/customers/' + custCrudId, { phone: '13900006666', remark: '更新备注' }, tokens.admin)
r = await req('PUT', '/api/customers/' + custCrudId, { phone: '13900006666', remark: '更新' }, tokens.admin)
check('PUT /api/customers/:id (更新)', r, 200)
r = await req('GET', '/api/customers?name=CRUD', null, tokens.admin)
@@ -481,13 +528,13 @@ async function main() {
}
r = await req('GET', '/api/customers/99999', null, tokens.admin)
check('GET /api/customers/99999 (不存在)', r, 404)
check('GET /api/customers/99999 (不存在 → 404)', r, 404)
// ══════════════════════════════════════════
// 【8】 employees CRUD 测试
// 【11】 employees CRUD
// ══════════════════════════════════════════
console.log('\n' + '━'.repeat(55))
console.log('【8】 employees CRUD')
console.log('【11】 employees CRUD')
console.log('━'.repeat(55))
r = await req('POST', '/api/employees', {
@@ -503,21 +550,18 @@ async function main() {
r = await req('GET', '/api/employees/' + empCrudId, null, tokens.admin)
check('GET /api/employees/:id (详情)', r, 200)
r = await req('PUT', '/api/employees/' + empCrudId, { salary: 15000, position: '高级品控专员' }, tokens.admin)
r = await req('PUT', '/api/employees/' + empCrudId, { salary: 15000, position: '高级专员' }, tokens.admin)
check('PUT /api/employees/:id (更新)', r, 200)
r = await req('GET', '/api/employees?status=1', null, tokens.admin)
check('GET /api/employees?status=1 (在职筛选)', r, 200)
r = await req('GET', '/api/employees?department=品控部', null, tokens.admin)
check('GET /api/employees?department=品控部 (部门筛选)', r, 200)
check('GET /api/employees?department=品控部 (筛选)', r, 200)
}
// ══════════════════════════════════════════
// 【9】 products CRUD 测试
// 【12】 products CRUD
// ══════════════════════════════════════════
console.log('\n' + '━'.repeat(55))
console.log('【9】 products CRUD采购经理操作)')
console.log('【12】 products CRUD采购操作)')
console.log('━'.repeat(55))
r = await req('POST', '/api/products', {
@@ -529,7 +573,7 @@ async function main() {
if (prodId) created.products.push(prodId)
r = await req('POST', '/api/products', { name: '' }, tokens.zhaoqiang)
check('POST /api/products (缺名)', r, 400)
check('POST /api/products (缺名 → 400)', r, 400)
if (prodId) {
r = await req('GET', '/api/products/' + prodId, null, tokens.zhaoqiang)
@@ -540,72 +584,66 @@ async function main() {
r = await req('GET', '/api/products?name=柠檬', null, tokens.zhaoqiang)
check('GET /api/products?name=柠檬 (搜索)', r, 200)
r = await req('GET', '/api/products?type=原材料', null, tokens.zhaoqiang)
check('GET /api/products?type=原材料 (筛选)', r, 200)
}
// ══════════════════════════════════════════
// 【10】 contracts CRUD 测试
// 【13】 contracts CRUD
// ══════════════════════════════════════════
console.log('\n' + '━'.repeat(55))
console.log('【10】 contracts CRUD(含 type 字段)')
console.log('【13】 contracts CRUD')
console.log('━'.repeat(55))
// 采购经理创建合同 → 自动 type='supply'
// 招商部正确创建加盟合同
r = await req('POST', '/api/contracts', {
customer_id: custAId, contract_name: '原材料采购合同', amount: 500000,
customer_id: custCrudId, contract_name: '加盟协议-测试', amount: 300000,
effective_date: '2026-06-01', expiry_date: '2027-05-31', status: '生效',
}, tokens.zhaoqiang)
check('zhaoqiang POST /api/contracts (自动type=supply)', r, 200)
}, tokens.liming)
check('liming POST /api/contracts (加盟合同)', r, 200)
if (r.body?.data) {
checkValue('合同type自动设为supply', r.body.data.type, 'supply')
created.contracts.push(r.body.data.id)
}
// 管理员创建合同 → 默认 type='franchise'
r = await req('POST', '/api/contracts', {
customer_id: custAId, contract_name: '加盟协议-测试', amount: 300000,
effective_date: '2026-06-01', expiry_date: '2027-05-31', status: '生效',
employee_id: empLmId,
}, tokens.admin)
check('admin POST /api/contracts (默认type=franchise)', r, 200)
if (r.body?.data) {
checkValue('合同type默认franchise', r.body.data.type, 'franchise')
checkValue('合同type自动=franchise', r.body.data.type, 'franchise')
check('合同有 customer_name 联查', { status: r.body.data.customer_name ? 200 : 500 }, 200)
created.contracts.push(r.body.data.id)
}
r = await req('POST', '/api/contracts', { customer_id: 99999, contract_name: '无效' }, tokens.admin)
check('POST /api/contracts (客户不存在)', r, 400)
// 采购部正确创建采购合同(需要 supplier_id
r = await req('POST', '/api/contracts', {
customer_id: custCrudId, contract_name: '采购合同-测试', amount: 200000,
effective_date: '2026-06-01', expiry_date: '2027-05-31',
type: 'supply', status: '生效', supplier_id: 1,
}, tokens.zhaoqiang)
check('zhaoqiang POST /api/contracts (采购合同)', r, 200)
if (r.body?.data) {
checkValue('合同type=supply', r.body.data.type, 'supply')
created.contracts.push(r.body.data.id)
}
r = await req('GET', '/api/contracts', null, tokens.admin)
check('GET /api/contracts (列表)', r, 200)
r = await req('POST', '/api/contracts', { customer_id: 99999, contract_name: '无效' }, tokens.admin)
check('POST /api/contracts (客户不存在 → 400)', r, 400)
// ══════════════════════════════════════════
// 【11】 after-sales CRUD 测试
// 【14】 after-sales CRUD
// ══════════════════════════════════════════
console.log('\n' + '━'.repeat(55))
console.log('【11】 after-sales CRUD')
console.log('【14】 after-sales CRUD')
console.log('━'.repeat(55))
r = await req('POST', '/api/after-sales', {
customer_id: custAId, feedback: '冰淇淋机不出料',
employee_id: empWlId, handle_method: '派工程师上门检修',
handle_status: '处理中', service_date: '2026-06-20',
customer_id: custCrudId, feedback: '冰淇淋机不出料',
handle_method: '派工程师上门', handle_status: '处理中',
service_date: '2026-06-20',
}, tokens.wangli)
check('wangli POST /api/after-sales (创建)', r, 200)
const asId = r.body?.data?.id
if (asId) {
created.afterSales.push(asId)
check('售后有 customer_name 联查', { status: r.body.data.customer_name ? 200 : 500 }, 200)
check('售后有 customer_name', { status: r.body.data.customer_name ? 200 : 500 }, 200)
}
r = await req('POST', '/api/after-sales', { customer_id: 99999, feedback: '测试' }, tokens.wangli)
check('POST /api/after-sales (客户不存在)', r, 400)
check('POST /api/after-sales (客户不存在 → 400)', r, 400)
r = await req('POST', '/api/after-sales', { customer_id: custAId }, tokens.wangli)
check('POST /api/after-sales (缺feedback)', r, 400)
r = await req('POST', '/api/after-sales', { customer_id: custCrudId }, tokens.wangli)
check('POST /api/after-sales (缺feedback → 400)', r, 400)
if (asId) {
r = await req('GET', '/api/after-sales/' + asId, null, tokens.wangli)
@@ -619,32 +657,31 @@ async function main() {
}
// ══════════════════════════════════════════
// 【12】 个人信息 & 改密
// 【15】 个人信息 & 改密
// ══════════════════════════════════════════
console.log('\n' + '━'.repeat(55))
console.log('【12】 个人信息 & 改密')
console.log('【15】 个人信息 & 改密')
console.log('━'.repeat(55))
r = await req('POST', '/api/user/logout', null, tokens.admin)
check('POST /api/user/logout', r, 200)
r = await req('PUT', '/api/user/password', { oldPassword: 'wrong', newPassword: 'newpwd999' }, tokens.admin)
check('PUT /api/user/password (旧密码错)', r, 400)
check('PUT /api/user/password (旧密码错 → 400)', r, 400)
r = await req('PUT', '/api/user/password', { oldPassword: '123456', newPassword: '123456' }, tokens.admin)
check('PUT /api/user/password (新旧相同)', r, 400)
check('PUT /api/user/password (新旧相同 → 400)', r, 400)
r = await req('PUT', '/api/user/password', { oldPassword: '123456', newPassword: '123' }, tokens.admin)
check('PUT /api/user/password (新密码太短)', r, 400)
check('PUT /api/user/password (新密码太短 → 400)', r, 400)
// ══════════════════════════════════════════
// 【13】 数据清理
// 【16】 数据清理
// ══════════════════════════════════════════
console.log('\n' + '━'.repeat(55))
console.log('【13】 测试数据清理')
console.log('【16】 测试数据清理')
console.log('━'.repeat(55))
// 按依赖顺序删除
for (const id of created.afterSales) {
await req('DELETE', '/api/after-sales/' + id, null, tokens.admin)
}