重构后端,使其更加权责分明

This commit is contained in:
2026-06-26 09:44:54 +08:00
parent 764919f69e
commit 89d8505260
11 changed files with 1426 additions and 761 deletions

422
API.md
View File

@@ -7,25 +7,27 @@
- [快速开始](#快速开始) - [快速开始](#快速开始)
- [技术栈](#技术栈) - [技术栈](#技术栈)
- [项目结构](#项目结构) - [项目结构](#项目结构)
- [架构分层](#架构分层)
- [认证机制](#认证机制) - [认证机制](#认证机制)
- [API 接口总览](#api-接口总览) - [API 接口总览](#api-接口总览)
- [用户认证](#1-用户认证) - [1. 用户认证](#1-用户认证)
- [用户管理](#2-用户管理) - [2. 用户管理](#2-用户管理)
- [加盟商管理](#3-加盟商管理) - [3. 加盟商管理](#3-加盟商管理)
- [员工管理](#4-员工管理) - [4. 员工管理](#4-员工管理)
- [合同管理](#5-合同管理) - [5. 合同管理](#5-合同管理)
- [售后管理](#6-售后管理) - [6. 售后管理](#6-售后管理)
- [产品管理](#7-产品管理) - [7. 产品管理](#7-产品管理)
- [供应商管理](#8-供应商管理) - [8. 供应商管理](#8-供应商管理)
- [统一响应格式](#统一响应格式) - [统一响应格式](#统一响应格式)
- [数据库设计](#数据库设计) - [数据库设计](#数据库设计)
- [表结构详情](#表结构详情) - [表结构详情](#表结构详情)
- [表关系 ER 图](#表关系-er-图) - [表关系 ER 图](#表关系-er-图)
- [RBAC 权限系统](#rbac-权限系统) - [部门权限系统](#部门权限系统)
- [角色定义](#角色定义) - [部门定义](#部门定义)
- [权限列表](#权限列表) - [权限列表](#权限列表)
- [角色-权限映射](#角色-权限映射) - [部门-权限映射](#部门-权限映射)
- [数据范围隔离](#数据范围隔离) - [数据范围隔离](#数据范围隔离)
- [中间件说明](#中间件说明)
- [预置账号](#预置账号) - [预置账号](#预置账号)
--- ---
@@ -70,7 +72,7 @@ node test-all.js
| `JWT_SECRET` | JWT 签名密钥 | — | | `JWT_SECRET` | JWT 签名密钥 | — |
| `JWT_EXPIRES_IN` | Token 有效期 | `2h` | | `JWT_EXPIRES_IN` | Token 有效期 | `2h` |
> 首次启动时,系统会自动创建数据库、建表、初始化角色权限和预置用户。 > 首次启动时,系统会自动创建数据库、建表、初始化部门权限和预置用户。
--- ---
@@ -93,22 +95,43 @@ backmanager-server/
├── server.js # 入口Express 应用、鉴权中间件、路由注册 ├── server.js # 入口Express 应用、鉴权中间件、路由注册
├── db.js # 数据库:连接池、建表、种子数据 ├── db.js # 数据库:连接池、建表、种子数据
├── middleware/ ├── middleware/
── permissions.js # RBAC权限检查 + 数据范围过滤 ── permissions.js # 权限检查 + 数据范围 + 合同类型校验
│ ├── users.js # 用户自保护规则(禁止自改部门、自删等)
│ └── employees.js # 员工创建时同步创建用户账号
├── routes/ ├── routes/
│ ├── users.js # 用户登录/信息/CRUD │ ├── users.js # 用户登录/信息/CRUD
│ ├── customers.js # 加盟商 CRUD │ ├── customers.js # 加盟商 CRUD + 简易列表
│ ├── employees.js # 员工 CRUD │ ├── employees.js # 员工 CRUD + 简易列表
│ ├── contracts.js # 合同 CRUD │ ├── contracts.js # 合同 CRUD
│ ├── afterSales.js # 售后 CRUD │ ├── afterSales.js # 售后 CRUD
│ ├── products.js # 产品 CRUD │ ├── products.js # 产品 CRUD
│ └── suppliers.js # 供应商 CRUD │ └── suppliers.js # 供应商 CRUD + 简易列表
├── test-all.js # 集成测试(覆盖 6 个角色的权限与数据隔离) ├── test-all.js # 集成测试(覆盖 6 个部门的权限与数据隔离131 条用例
├── API.md # 本文档
├── .env.example # 环境变量模板 ├── .env.example # 环境变量模板
└── package.json └── package.json
``` ```
--- ---
## 架构分层
本项目采用**三层分离架构**,各层职责清晰:
```
请求 → 中间件层(认证/权限/数据范围/业务规则)→ 路由层(纯 CRUD数据库操作→ 响应
```
| 分层 | 文件 | 职责 |
|------|------|------|
| **API 端点层** | `server.js` | 路由注册,串联中间件链 |
| **中间件层** | `middleware/*.js` | JWT 认证、权限校验、数据范围注入、业务规则校验 |
| **数据库查询层** | `routes/*.js` | 纯 SQL CRUD 操作,不做权限判断和业务逻辑 |
**路由层纯粹性要求:** 所有 `routes/*.js` 只包含数据库的增删改查操作,不参与权限判断、数据范围计算等逻辑。权限和数据范围由中间件通过 `req.user.permissions``req.scope` 注入。
---
## 认证机制 ## 认证机制
所有 API除登录外需要在请求头中携带 JWT Token 所有 API除登录外需要在请求头中携带 JWT Token
@@ -128,19 +151,29 @@ Authorization: Bearer <token>
"id": 1, "id": 1,
"username": "admin", "username": "admin",
"name": "系统管理员", "name": "系统管理员",
"role_id": 1, "department_id": 1,
"roleName": "admin", "departmentName": "admin",
"department": "信息技术部", "departmentDesc": "信息技术部",
"permissions": ["customer:read", "customer:create", "customer:update", "customer:delete", "contract:read", "..."] "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 状态码 | 场景 | | HTTP 状态码 | 场景 |
@@ -158,6 +191,38 @@ Authorization: Bearer <token>
- **分页响应**:列表接口返回 `{ list, total, page, pageSize, totalPages }` - **分页响应**:列表接口返回 `{ list, total, page, pageSize, totalPages }`
- **搜索参数**:通过 URL Query String 传递,如 `GET /api/customers?name=张&phone=138` - **搜索参数**:通过 URL Query String 传递,如 `GET /api/customers?name=张&phone=138`
- **部分更新**PUT 接口只传需要修改的字段即可,未传字段保持不变 - **部分更新**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,9 +253,9 @@ Authorization: Bearer <token>
"id": 1, "id": 1,
"username": "admin", "username": "admin",
"name": "系统管理员", "name": "系统管理员",
"role_id": 1, "department_id": 1,
"roleName": "admin", "departmentName": "admin",
"department": "信息技术部", "departmentDesc": "信息技术部",
"permissions": ["customer:read", "customer:create", "..."] "permissions": ["customer:read", "customer:create", "..."]
} }
} }
@@ -209,11 +274,30 @@ Authorization: Bearer <token>
#### GET /api/user/info — 获取当前用户信息 #### 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 — 简易用户列表 #### GET /api/user/list — 简易用户列表
需要 Token。返回所有启用用户的基本信息id、用户名、真实姓名用于前端下拉选择负责人 需要 Token(仅需登录,无需特定权限)
返回所有启用用户的基本信息,用于前端下拉选择负责人。
**响应数据字段:** **响应数据字段:**
@@ -249,7 +333,7 @@ Authorization: Bearer <token>
### 2. 用户管理 ### 2. 用户管理
> 需要 `user:manage` 权限(仅系统管理员拥有)。 > 需要 `user:manage` 权限(仅系统管理员 / 信息技术部拥有)。
#### GET /api/users — 用户列表 #### GET /api/users — 用户列表
@@ -260,7 +344,7 @@ Authorization: Bearer <token>
| `username` | 按用户名模糊搜索 | `?username=admin` | | `username` | 按用户名模糊搜索 | `?username=admin` |
| `real_name` | 按真实姓名模糊搜索 | `?real_name=张` | | `real_name` | 按真实姓名模糊搜索 | `?real_name=张` |
| `id` | 按用户 ID 精确搜索 | `?id=1` | | `id` | 按用户 ID 精确搜索 | `?id=1` |
| `role_id` | 按角色 ID 过滤 | `?role_id=2` | | `department_id` | 按部门 ID 过滤 | `?department_id=2` |
| `is_active` | 按状态过滤0-禁用, 1-启用) | `?is_active=1` | | `is_active` | 按状态过滤0-禁用, 1-启用) | `?is_active=1` |
| `page` | 页码 | `?page=1` | | `page` | 页码 | `?page=1` |
| `pageSize` | 每页条数 | `?pageSize=10` | | `pageSize` | 每页条数 | `?pageSize=10` |
@@ -272,12 +356,11 @@ Authorization: Bearer <token>
| `id` | number | 用户 ID | | `id` | number | 用户 ID |
| `username` | string | 用户名 | | `username` | string | 用户名 |
| `is_active` | number | 状态0-禁用, 1-启用 | | `is_active` | number | 状态0-禁用, 1-启用 |
| `role_id` | number | 角色 ID | | `department_id` | number | 部门 ID |
| `role_name` | string | 角色标识(如 `admin` |
| `role_description` | string | 角色描述(如 系统管理员) |
| `employee_id` | number | 关联员工 ID | | `employee_id` | number | 关联员工 ID |
| `dept_name` | string | 部门标识(如 `admin` |
| `dept_desc` | string | 部门中文名(如 信息技术部) |
| `real_name` | string | 真实姓名(来自员工表) | | `real_name` | string | 真实姓名(来自员工表) |
| `department` | string | 所属部门 |
| `created_at` | datetime | 创建时间 | | `created_at` | datetime | 创建时间 |
| `updated_at` | datetime | 更新时间 | | `updated_at` | datetime | 更新时间 |
@@ -293,24 +376,28 @@ Authorization: Bearer <token>
|------|------|------| |------|------|------|
| `username` | ✅ | 用户名3-50 字符) | | `username` | ✅ | 用户名3-50 字符) |
| `password` | ✅ | 密码(至少 6 位) | | `password` | ✅ | 密码(至少 6 位) |
| `role_id` | ❌ | 角色 ID需为已存在的角色 | | `department_id` | ❌ | 部门 ID需为已存在的部门 |
| `employee_id` | ❌ | 关联员工 ID需为已存在的员工,会自动同步部门信息 | | `employee_id` | ❌ | 关联员工 ID需为已存在的员工 |
| `department` | ❌ | 所属部门(若填了 employee_id 且未传 department会自动从员工表同步 |
| `is_active` | ❌ | 状态,默认 1 | | `is_active` | ❌ | 状态,默认 1 |
**校验规则:**
- `department_id` 必须存在于 `departments`
- `employee_id` 必须存在于 `employees`
- `username` 不可重复(返回 409
#### PUT /api/users/:id — 更新用户 #### 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 — 删除用户 #### DELETE /api/users/:id — 删除用户
**限制** **自保护限制(`protectUserDelete` 中间件)**
- 不能删除自己 - 不能删除自己
- 不能删除最后一个管理员账号(当系统中仅剩 1 个 admin 角色用户时,拒绝删除) - 不能删除信息技术部的最后一个管理员账号
--- ---
@@ -318,6 +405,17 @@ Authorization: Bearer <token>
> 需要 `customer:read` / `customer:create` / `customer:update` / `customer:delete` 权限。 > 需要 `customer:read` / `customer:create` / `customer:update` / `customer:delete` 权限。
#### GET /api/customers/simple — 简易加盟商列表
需要 `customer:read` 权限。无数据范围限制,用于前端下拉选择。
**响应数据字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | number | 加盟商 ID |
| `name` | string | 加盟商姓名 |
#### GET /api/customers — 加盟商列表 #### GET /api/customers — 加盟商列表
**查询参数:** **查询参数:**
@@ -343,8 +441,6 @@ Authorization: Bearer <token>
| `address` | string | 详细地址 | | `address` | string | 详细地址 |
| `email` | string | 电子邮箱 | | `email` | string | 电子邮箱 |
| `remark` | string | 备注信息 | | `remark` | string | 备注信息 |
| `responsible_user_id` | number | 负责人用户 ID |
| `responsible_user_name` | string | 负责人姓名JOIN 自 users → employees |
| `created_at` | datetime | 创建时间 | | `created_at` | datetime | 创建时间 |
| `updated_at` | datetime | 更新时间 | | `updated_at` | datetime | 更新时间 |
@@ -362,7 +458,6 @@ Authorization: Bearer <token>
| `address` | ❌ | 详细地址 | | `address` | ❌ | 详细地址 |
| `email` | ❌ | 电子邮箱 | | `email` | ❌ | 电子邮箱 |
| `remark` | ❌ | 备注 | | `remark` | ❌ | 备注 |
| `responsible_user_id` | ❌ | 负责人(默认为当前登录用户,仅管理员可在前端修改) |
#### PUT /api/customers/:id — 更新加盟商 #### PUT /api/customers/:id — 更新加盟商
@@ -370,6 +465,8 @@ Authorization: Bearer <token>
#### DELETE /api/customers/:id — 删除加盟商 #### DELETE /api/customers/:id — 删除加盟商
**限制:** 被合同或售后记录引用的客户无法删除(返回 400
--- ---
### 4. 员工管理 ### 4. 员工管理
@@ -378,7 +475,7 @@ Authorization: Bearer <token>
#### GET /api/employees/simple — 简易员工列表 #### GET /api/employees/simple — 简易员工列表
需要 Token。无数据范围限制用于前端下拉选择(如售后分配业务员)。支持按部门过滤。 需要 Token(仅需登录)。无数据范围限制,用于前端下拉选择。支持按部门过滤。
**查询参数:** **查询参数:**
@@ -444,18 +541,27 @@ Authorization: Bearer <token>
| `email` | ❌ | 邮箱 | | `email` | ❌ | 邮箱 |
| `status` | ❌ | 在职状态,默认 1 | | `status` | ❌ | 在职状态,默认 1 |
| `remark` | ❌ | 备注 | | `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 — 更新员工 #### PUT /api/employees/:id — 更新员工
所有字段均可选,只传需要修改的字段。 所有字段均可选,只传需要修改的字段。
**特殊行为:** 更新 `department` 字段时,会自动同步到关联的 users 表的 `department` 字段。
#### DELETE /api/employees/:id — 删除员工 #### DELETE /api/employees/:id — 删除员工
**特殊行为:** **特殊行为:**
- 删除员工时会同步删除关联的用户账号users 表中 `employee_id` 匹配的记录) - 删除员工时会同步删除关联的用户账号(`users` 表中 `employee_id` 匹配的记录)
- 不能删除当前登录用户自己关联的员工账号 - 不能删除当前登录用户自己关联的员工账号
- 被合同或售后记录引用的员工无法删除(返回 400
--- ---
@@ -470,7 +576,7 @@ Authorization: Bearer <token>
| 参数 | 说明 | | 参数 | 说明 |
|------|------| |------|------|
| `id` | 按 ID 精确搜索 | | `id` | 按 ID 精确搜索 |
| `status` | 按合同状态过滤(草稿/生效中/已完成/作废) | | `status` | 按合同状态过滤(草稿/生效/完成/作废) |
| `customer_name` | 按客户名称模糊搜索 | | `customer_name` | 按客户名称模糊搜索 |
| `contract_no` | 按合同编号模糊搜索 | | `contract_no` | 按合同编号模糊搜索 |
| `contract_name` | 按合同名称模糊搜索 | | `contract_name` | 按合同名称模糊搜索 |
@@ -483,9 +589,9 @@ Authorization: Bearer <token>
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------|------|------| |------|------|------|
| `id` | number | 合同 ID | | `id` | number | 合同 ID |
| `customer_id` | number | 客户 ID | | `customer_id` | number | 客户 ID(加盟合同必填,采购合同为 null |
| `customer_name` | string | 客户名称JOIN 自 customers | | `customer_name` | string | 客户名称JOIN 自 customers |
| `supplier_id` | number | 供应商 ID采购合同必填 | | `supplier_id` | number | 供应商 ID采购合同必填,加盟合同为 null |
| `supplier_name` | string | 供应商名称JOIN 自 suppliers | | `supplier_name` | string | 供应商名称JOIN 自 suppliers |
| `contract_name` | string | 合同名称 | | `contract_name` | string | 合同名称 |
| `contract_no` | string | 合同编号 | | `contract_no` | string | 合同编号 |
@@ -495,7 +601,7 @@ Authorization: Bearer <token>
| `expiry_date` | date | 到期日期 | | `expiry_date` | date | 到期日期 |
| `employee_id` | number | 业务员 ID | | `employee_id` | number | 业务员 ID |
| `employee_name` | string | 业务员姓名JOIN 自 employees | | `employee_name` | string | 业务员姓名JOIN 自 employees |
| `status` | string | 状态:草稿/生效中/已完成/作废 | | `status` | string | 状态:草稿/生效/完成/作废 |
| `remark` | string | 备注 | | `remark` | string | 备注 |
| `type` | string | 合同类型:`franchise`(加盟)/ `supply`(采购) | | `type` | string | 合同类型:`franchise`(加盟)/ `supply`(采购) |
| `responsible_user_id` | number | 负责人用户 ID | | `responsible_user_id` | number | 负责人用户 ID |
@@ -523,10 +629,13 @@ Authorization: Bearer <token>
| `type` | ❌ | 合同类型(见下方说明) | | `type` | ❌ | 合同类型(见下方说明) |
| `responsible_user_id` | ❌ | 负责人(默认为当前用户,仅管理员可在前端修改) | | `responsible_user_id` | ❌ | 负责人(默认为当前用户,仅管理员可在前端修改) |
**合同类型自动设置与角色限制规则** **合同类型限制(`validateContractType` 中间件)**
- 采购经理(`procurement_manager`)创建的合同**只能**为 `supply`(采购合同),且必须选择 `supplier_id`
- 招商经理(`franchise_manager`)创建的合同**只能**为 `franchise`(加盟合同),且必须选择 `customer_id` | 部门 | 允许的合同类型 | 说明 |
- 其他角色默认 `franchise`,也可手动指定 `type` |------|-------------|------|
| 招商部 (`franchise_manager`) | 仅 `franchise` | 只能创建加盟合同,且必须选择 `customer_id` |
| 采购部 (`procurement_manager`) | 仅 `supply` | 只能创建采购合同,且必须选择 `supplier_id` |
| 信息技术部/总经理/财务 | 不限制 | 默认 `franchise`,可手动指定 |
**校验规则:** **校验规则:**
- `customer_id` 必须为已存在的客户 - `customer_id` 必须为已存在的客户
@@ -537,10 +646,12 @@ Authorization: Bearer <token>
所有字段均可选。可更新字段包括 `type``supplier_id``responsible_user_id` 所有字段均可选。可更新字段包括 `type``supplier_id``responsible_user_id`
**校验规则:** 更新 `customer_id``supplier_id``employee_id` 时会校验关联记录是否存在。 **校验规则:** 更新 `customer_id``supplier_id``employee_id` 时会校验关联记录是否存在。合同类型限制同创建。
#### DELETE /api/contracts/:id — 删除合同 #### DELETE /api/contracts/:id — 删除合同
**限制:** 被售后记录引用的合同无法删除(返回 400
--- ---
### 6. 售后管理 ### 6. 售后管理
@@ -574,6 +685,7 @@ Authorization: Bearer <token>
| `service_date` | date | 售后日期 | | `service_date` | date | 售后日期 |
| `remark` | string | 备注 | | `remark` | string | 备注 |
| `responsible_user_id` | number | 负责人用户 ID | | `responsible_user_id` | number | 负责人用户 ID |
| `responsible_user_name` | string | 负责人姓名JOIN 自 users → employees |
| `created_at` | datetime | 创建时间 | | `created_at` | datetime | 创建时间 |
| `updated_at` | datetime | 更新时间 | | `updated_at` | datetime | 更新时间 |
@@ -666,6 +778,17 @@ Authorization: Bearer <token>
> 需要 `supplier:read` / `supplier:create` / `supplier:update` / `supplier:delete` 权限。 > 需要 `supplier:read` / `supplier:create` / `supplier:update` / `supplier:delete` 权限。
> 无数据范围隔离,拥有权限即可查看所有供应商。 > 无数据范围隔离,拥有权限即可查看所有供应商。
#### GET /api/suppliers/simple — 简易供应商列表
需要 `supplier:read` 权限。返回所有正常状态供应商的基本信息,用于前端下拉选择。
**响应数据字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | number | 供应商 ID |
| `name` | string | 供应商名称 |
#### GET /api/suppliers — 供应商列表 #### GET /api/suppliers — 供应商列表
**查询参数:** **查询参数:**
@@ -755,7 +878,7 @@ Authorization: Bearer <token>
|------------|------|------| |------------|------|------|
| 400 | 400 | 参数错误 / 校验失败 / 关联记录不存在 | | 400 | 400 | 参数错误 / 校验失败 / 关联记录不存在 |
| 401 | 401 | 未登录 / Token 无效 | | 401 | 401 | 未登录 / Token 无效 |
| 403 | 403 | 权限不足 / 账号被禁用 / 角色操作限制 | | 403 | 403 | 权限不足 / 账号被禁用 / 合同类型不匹配 |
| 404 | 404 | 资源不存在 | | 404 | 404 | 资源不存在 |
| 409 | 409 | 数据冲突(如用户名重复) | | 409 | 409 | 数据冲突(如用户名重复) |
| 500 | 500 | 服务器内部错误 | | 500 | 500 | 服务器内部错误 |
@@ -768,7 +891,7 @@ Authorization: Bearer <token>
#### 1. users — 系统用户表 #### 1. users — 系统用户表
存储系统登录账号,每个用户关联一个角色和一个员工。 存储系统登录账号,每个用户关联一个部门和一个员工。
| 字段 | 类型 | 约束 | 说明 | | 字段 | 类型 | 约束 | 说明 |
|------|------|------|------| |------|------|------|------|
@@ -776,19 +899,18 @@ Authorization: Bearer <token>
| `username` | VARCHAR(50) | NOT NULL, UNIQUE | 用户名(登录账号) | | `username` | VARCHAR(50) | NOT NULL, UNIQUE | 用户名(登录账号) |
| `password` | VARCHAR(255) | NOT NULL | 密码bcrypt 哈希) | | `password` | VARCHAR(255) | NOT NULL | 密码bcrypt 哈希) |
| `is_active` | TINYINT(1) | NOT NULL DEFAULT 1 | 账号状态0-禁用, 1-启用 | | `is_active` | TINYINT(1) | NOT NULL DEFAULT 1 | 账号状态0-禁用, 1-启用 |
| `role_id` | INT | NULLABLE | 角色 ID → roles.id | | `department_id` | INT | NULLABLE | 部门 ID → departments.id |
| `employee_id` | INT | NULLABLE, UNIQUE | 员工 ID → employees.id | | `employee_id` | INT | NULLABLE, UNIQUE | 员工 ID → employees.id |
| `department` | VARCHAR(100) | NULLABLE | 所属部门(冗余字段,从 employees 同步) |
| `created_at` | DATETIME | DEFAULT CURRENT_TIMESTAMP | 创建时间 | | `created_at` | DATETIME | DEFAULT CURRENT_TIMESTAMP | 创建时间 |
| `updated_at` | DATETIME | ON UPDATE CURRENT_TIMESTAMP | 更新时间 | | `updated_at` | DATETIME | ON UPDATE CURRENT_TIMESTAMP | 更新时间 |
#### 2. roles — 角色 #### 2. departments — 部门
| 字段 | 类型 | 约束 | 说明 | | 字段 | 类型 | 约束 | 说明 |
|------|------|------|------| |------|------|------|------|
| `id` | INT | PK, AUTO_INCREMENT | 角色 ID | | `id` | INT | PK, AUTO_INCREMENT | 部门 ID |
| `name` | VARCHAR(50) | NOT NULL, UNIQUE | 角色标识(英文) | | `name` | VARCHAR(50) | NOT NULL, UNIQUE | 部门标识(英文) |
| `description` | VARCHAR(200) | NULLABLE | 角色描述(中文) | | `description` | VARCHAR(200) | NULLABLE | 部门名称(中文) |
| `created_at` | DATETIME | DEFAULT CURRENT_TIMESTAMP | 创建时间 | | `created_at` | DATETIME | DEFAULT CURRENT_TIMESTAMP | 创建时间 |
#### 3. permissions — 权限表 #### 3. permissions — 权限表
@@ -801,11 +923,11 @@ Authorization: Bearer <token>
| `resource` | VARCHAR(50) | NOT NULL | 资源名称 | | `resource` | VARCHAR(50) | NOT NULL | 资源名称 |
| `action` | 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 | | `permission_id` | INT | PK (联合) | 权限 ID → permissions.id |
#### 5. customers — 加盟商信息表 #### 5. customers — 加盟商信息表
@@ -821,7 +943,6 @@ Authorization: Bearer <token>
| `address` | VARCHAR(200) | — | 详细地址 | | `address` | VARCHAR(200) | — | 详细地址 |
| `email` | VARCHAR(100) | — | 电子邮箱 | | `email` | VARCHAR(100) | — | 电子邮箱 |
| `remark` | TEXT | — | 备注信息 | | `remark` | TEXT | — | 备注信息 |
| `responsible_user_id` | INT | — | 负责人用户 ID → users.id |
| `created_at` | DATETIME | DEFAULT CURRENT_TIMESTAMP | 创建时间 | | `created_at` | DATETIME | DEFAULT CURRENT_TIMESTAMP | 创建时间 |
| `updated_at` | DATETIME | ON UPDATE CURRENT_TIMESTAMP | 更新时间 | | `updated_at` | DATETIME | ON UPDATE CURRENT_TIMESTAMP | 更新时间 |
@@ -850,8 +971,8 @@ Authorization: Bearer <token>
| 字段 | 类型 | 约束 | 说明 | | 字段 | 类型 | 约束 | 说明 |
|------|------|------|------| |------|------|------|------|
| `id` | INT | PK, AUTO_INCREMENT | 合同 ID | | `id` | INT | PK, AUTO_INCREMENT | 合同 ID |
| `customer_id` | INT | NULLABLE, INDEX | 客户 ID → customers.id加盟合同必填 | | `customer_id` | INT | NULLABLE, INDEX | 客户 ID → customers.id加盟合同必填,采购合同为 null |
| `supplier_id` | INT | NULLABLE, INDEX | 供应商 ID → suppliers.id采购合同必填 | | `supplier_id` | INT | NULLABLE, INDEX | 供应商 ID → suppliers.id采购合同必填,加盟合同为 null |
| `contract_name` | VARCHAR(200) | NOT NULL | 合同名称 | | `contract_name` | VARCHAR(200) | NOT NULL | 合同名称 |
| `contract_no` | VARCHAR(100) | — | 合同编号 | | `contract_no` | VARCHAR(100) | — | 合同编号 |
| `contract_content` | TEXT | — | 合同内容/条款 | | `contract_content` | TEXT | — | 合同内容/条款 |
@@ -859,7 +980,7 @@ Authorization: Bearer <token>
| `effective_date` | DATE | INDEX | 生效日期 | | `effective_date` | DATE | INDEX | 生效日期 |
| `expiry_date` | DATE | — | 到期日期 | | `expiry_date` | DATE | — | 到期日期 |
| `employee_id` | INT | INDEX | 业务员 ID → employees.id | | `employee_id` | INT | INDEX | 业务员 ID → employees.id |
| `status` | VARCHAR(20) | NOT NULL DEFAULT '生效' | 状态:草稿/生效中/已完成/作废 | | `status` | VARCHAR(20) | NOT NULL DEFAULT '生效' | 状态:草稿/生效/完成/作废 |
| `remark` | TEXT | — | 备注 | | `remark` | TEXT | — | 备注 |
| `type` | VARCHAR(20) | NOT NULL DEFAULT 'franchise' | 类型:`franchise`(加盟) / `supply`(采购) | | `type` | VARCHAR(20) | NOT NULL DEFAULT 'franchise' | 类型:`franchise`(加盟) / `supply`(采购) |
| `responsible_user_id` | INT | — | 负责人用户 ID → users.id | | `responsible_user_id` | INT | — | 负责人用户 ID → users.id |
@@ -918,13 +1039,13 @@ Authorization: Bearer <token>
### 表关系 ER 图 ### 表关系 ER 图
``` ```
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────────────────────
roles │ │ permissions │ │ role_permissions departments │ │ permissions │ │ department_permissions │
│──────────────│ │──────────────│ │──────────────────│ │──────────────│ │──────────────│ │────────────────────────
│ id (PK) │◄─┐ │ id (PK) │◄─┐ │ role_id (PK,FK) │ │ id (PK) │◄─┐ │ id (PK) │◄─┐ │ department_id (PK,FK) │
│ name │ │ │ name │ └──│ permission_id │ name │ │ │ name │ │ permission_id (PK,FK)
│ description │ │ │ description │ │ (PK,FK) │ description │ │ │ description │ └──│ → permissions.id
└──────────────┘ │ │ resource │ └──────────────────┘ └──────────────┘ │ │ resource │ └────────────────────────
│ │ action │ │ │ action │
│ └──────────────┘ │ └──────────────┘
@@ -935,12 +1056,12 @@ Authorization: Bearer <token>
│ username │ │ │ name │ │ username │ │ │ name │
│ password │ │ │ gender │ │ password │ │ │ gender │
│ is_active │ │ │ age │ │ is_active │ │ │ age │
role_id (FK)─┼──┘ │ education │ department_id│──┘ │ education │
employee_id │─ ─ ─│ department │◄──────── users.department (冗余) (FK) │ │ department │◄──── 数据范围隔离字段
(FK) ──────┼──→ │ entry_date │ employee_id │─ ─ ─│ entry_date │
department │ │ position │ (FK) ──────┼──→ │ position │
│ (冗余) │ │ salary │ └──────┬───────┘ │ salary │
└──────┬───────┘ │ phone │ │ phone │
│ │ email │ │ │ email │
│ │ status │ │ │ status │
│ └──────┬───────┘ │ └──────┬───────┘
@@ -951,7 +1072,6 @@ Authorization: Bearer <token>
│──────────────────────────────────────────────│ │──────────────────────────────────────────────│
│ id (PK) │ │ id (PK) │
│ name, phone, province, city, district, ... │ │ name, phone, province, city, district, ... │
│ responsible_user_id ──────────→ users.id │
└────────────────────┬─────────────────────────┘ └────────────────────┬─────────────────────────┘
┌──────────┴──────────┐ ┌──────────┴──────────┐
@@ -995,12 +1115,11 @@ Authorization: Bearer <token>
| 关系 | 类型 | 说明 | | 关系 | 类型 | 说明 |
|------|------|------| |------|------|------|
| users → roles | 多对一 | 一个用户属于一个角色,一个角色可以有多个用户 | | users → departments | 多对一 | 一个用户属于一个部门,一个部门可以有多个用户 |
| users → employees | 一对一 | 一个用户关联一个员工(通过 employee_id唯一约束 | | users → employees | 一对一 | 一个用户关联一个员工(通过 employee_id唯一约束 |
| roles ↔ permissions | 多对多 | 通过 role_permissions 中间表关联 | | departments ↔ permissions | 多对多 | 通过 department_permissions 中间表关联 |
| customers → users | 多对一 | 通过 responsible_user_id 指定负责人 | | contracts → customers | 多对一 | 通过 customer_id 关联客户(加盟合同必填,采购合同为 null |
| contracts → customers | 多对一 | 通过 customer_id 关联客户(加盟合同必填 | | contracts → suppliers | 多对一 | 通过 supplier_id 关联供应商(采购合同必填,加盟合同为 null |
| contracts → suppliers | 多对一 | 通过 supplier_id 关联供应商(采购合同必填) |
| contracts → employees | 多对一 | 通过 employee_id 关联业务员 | | contracts → employees | 多对一 | 通过 employee_id 关联业务员 |
| contracts → users | 多对一 | 通过 responsible_user_id 指定负责人 | | contracts → users | 多对一 | 通过 responsible_user_id 指定负责人 |
| after_sales → customers | 多对一 | 通过 customer_id 关联客户 | | after_sales → customers | 多对一 | 通过 customer_id 关联客户 |
@@ -1011,20 +1130,20 @@ Authorization: Bearer <token>
--- ---
## RBAC 权限系统 ## 部门权限系统
### 角色定义 ### 部门定义
系统预置 6 个角色,对应蜜雪冰城总部的组织架构: 系统预置 6 个部门,对应蜜雪冰城总部的组织架构:
| 角色标识 | 中文名 | 所属部门 | 职责概述 | | 部门标识 | 中文名 | 职责概述 |
|---------|--------|---------|---------| |---------|--------|---------|
| `admin` | 系统管理员 | 信息技术部 | 系统全权管理,拥有所有权限 | | `admin` | 信息技术部 | 系统全权管理,拥有所有权限 |
| `general_manager` | 总经理 | 总经理办公室 | 全局只读,查看所有业务数据 | | `general_manager` | 总经理办公室 | 全局只读,查看所有业务数据 |
| `franchise_manager` | 招商经理 | 招商部 | 管理加盟商和加盟合同 | | `franchise_manager` | 招商部 | 管理加盟商和加盟合同 |
| `operations_manager` | 运营经理 | 运营部 | 维护加盟商信息、处理售后 | | `operations_manager` | 运营部 | 维护加盟商信息、处理售后 |
| `procurement_manager` | 采购经理 | 采购部 | 管理产品、供应商和采购合同 | | `procurement_manager` | 采购部 | 管理产品、供应商和采购合同 |
| `finance` | 财务人员 | 财务部 | 只读查看业务数据(加盟商、合同、售后、员工) | | `finance` | 财务部 | 只读查看业务数据(加盟商、合同、售后、员工) |
### 权限列表 ### 权限列表
@@ -1065,15 +1184,15 @@ Authorization: Bearer <token>
| **用户 (user)** | | | **用户 (user)** | |
| `user:manage` | 管理用户账号 | | `user:manage` | 管理用户账号 |
### 角色-权限映射 ### 部门-权限映射
| 权限 | admin | general_manager | franchise_manager | operations_manager | procurement_manager | finance | | 权限 | admin | general_manager | franchise_manager | operations_manager | procurement_manager | finance |
|------|:-----:|:---------------:|:-----------------:|:------------------:|:-------------------:|:-------:| |------|:-----:|:---------------:|:-----------------:|:------------------:|:-------------------:|:-------:|
| customer:read | ✅ | ✅ | ✅ | ✅ | | ✅ | | customer:read | ✅ | ✅ | ✅ | ✅ | | ✅ |
| customer:create | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | | customer:create | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| customer:update | ✅ | ❌ | ✅ | | ❌ | ❌ | | customer:update | ✅ | ❌ | ✅ | | ❌ | ❌ |
| customer:delete | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | | customer:delete | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| contract:read | ✅ | ✅ | ✅ | | ✅ | ✅ | | contract:read | ✅ | ✅ | ✅ | | ✅ | ✅ |
| contract:create | ✅ | ❌ | ✅ | ❌ | ✅ | ❌ | | contract:create | ✅ | ❌ | ✅ | ❌ | ✅ | ❌ |
| contract:update | ✅ | ❌ | ✅ | ❌ | ✅ | ❌ | | contract:update | ✅ | ❌ | ✅ | ❌ | ✅ | ❌ |
| contract:delete | ✅ | ❌ | ✅ | ❌ | ✅ | ❌ | | contract:delete | ✅ | ❌ | ✅ | ❌ | ✅ | ❌ |
@@ -1097,30 +1216,64 @@ Authorization: Bearer <token>
### 数据范围隔离 ### 数据范围隔离
拥有操作权限 ≠ 能看到所有数据。系统通过 `getDataScope()` 函数在 SQL 查询层面做数据隔离: 拥有操作权限 ≠ 能看到所有数据。系统通过 `dataScope()` 中间件在 SQL 查询层面做数据隔离:
| 资源 | admin | general_manager | franchise_manager | operations_manager | procurement_manager | finance | | 资源 | admin | general_manager | franchise_manager | operations_manager | procurement_manager | finance |
|------|-------|-----------------|-------------------|--------------------|--------------------|---------| |------|-------|-----------------|-------------------|--------------------|--------------------|---------|
| customers | 全部 | 全部 | 仅自己负责 | 仅自己负责 | | 全部 | | customers | 全部 | 全部 | 全部(过渡期) | 全部(过渡期) | 全部 | 全部 |
| contracts | 全部 | 全部 | 仅自己负责的加盟合同 | 仅自己负责的加盟合同 | 仅采购合同 | 全部 | | contracts | 全部 | 全部 | 仅加盟合同 | | 仅采购合同 | 全部 |
| after_sales | 全部 | 全部 | — | 仅自己负责 | — | 全部 | | after_sales | 全部 | 全部 | — | 全部(过渡期) | — | 全部 |
| employees | 全部 | 全部 | 仅本部门 | 仅本部门 | 仅本部门 | 全部 | | employees | 全部 | 全部 | 仅本部门 | 仅本部门 | 仅本部门 | 全部 |
| products | 全部 | 全部 | — | — | 全部 | — | | products | 全部 | 全部 | — | — | 全部 | — |
| suppliers | 全部 | 全部 | — | — | 全部 | — | | suppliers | 全部 | 全部 | — | — | 全部 | — |
| users | 全部 | — | — | — | — | — | | users | 全部 | — | — | — | — | — |
> **"—"** 表示该角色无此资源的权限,请求会被权限中间件直接拦截(返回 403 > **"—"** 表示该部门无此资源的权限,请求会被 `checkPermission` 中间件直接拦截(返回 403
**隔离机制说明:** **隔离机制说明:**
- **负责人类资源**customers、contracts、after_sales通过 `responsible_user_id` 字段过滤,只能看到自己负责的记录
- **合同类型过滤**contracts除了负责人类过滤还按合同 `type` 字段过滤franchise/supply
- **部门类资源**employees通过 `department` 字段过滤,只能看到自己部门的员工
- **全局类资源**products、suppliers无数据范围过滤只要有权限就能看到全部
**合同类型与角色限制:** | 隔离类型 | 资源 | 实现方式 |
- 采购经理(`procurement_manager`)只能查看和操作采购合同(`type = 'supply'` |---------|------|---------|
- 招商经理(`franchise_manager`)和运营经理(`operations_manager`)只能查看和操作加盟合同(`type = 'franchise'`)中自己负责的记录 | 合同类型过滤 | 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)
```
--- ---
@@ -1128,14 +1281,27 @@ Authorization: Bearer <token>
系统首次启动时自动创建以下账号,密码均为 `123456` 系统首次启动时自动创建以下账号,密码均为 `123456`
| 用户名 | 姓名 | 角色 | 部门 | 职位 | | 用户名 | 姓名 | 部门标识 | 部门 | 职位 |
|--------|------|------|------|------| |--------|------|---------|------|------|
| `admin` | 系统管理员 | admin | 信息技术部 | 系统管理员 | | `admin` | 系统管理员 | `admin` | 信息技术部 | 系统管理员 |
| `zhangchao` | 张超 | general_manager | 总经理办公室 | 总经理 | | `zhangchao` | 张超 | `general_manager` | 总经理办公室 | 总经理 |
| `liming` | 李明 | franchise_manager | 招商部 | 招商经理 | | `liming` | 李明 | `franchise_manager` | 招商部 | 招商经理 |
| `wangli` | 王丽 | operations_manager | 运营部 | 运营经理 | | `wangli` | 王丽 | `operations_manager` | 运营部 | 运营经理 |
| `zhaoqiang` | 赵强 | procurement_manager | 采购部 | 采购经理 | | `zhaoqiang` | 赵强 | `procurement_manager` | 采购部 | 采购经理 |
| `chenfang` | 陈芳 | finance | 财务部 | 财务主管 | | `chenfang` | 陈芳 | `finance` | 财务部 | 财务主管 |
**额外预置账号**(业务员工,同样密码 `123456`
| 用户名 | 姓名 | 部门 | 职位 |
|--------|------|------|------|
| `liuyang` | 刘洋 | 招商部 | 招商专员 |
| `sunting` | 孙婷 | 招商部 | 招商专员 |
| `zhoujie` | 周杰 | 运营部 | 运营专员 |
| `wumin` | 吴敏 | 运营部 | 运营专员 |
| `zhengwei` | 郑伟 | 运营部 | 售后工程师 |
| `huanglei` | 黄磊 | 采购部 | 采购专员 |
| `mali` | 马丽 | 财务部 | 会计 |
| `linfeng` | 林峰 | 总经理办公室 | 副总经理 |
### 快速测试 ### 快速测试
@@ -1145,7 +1311,7 @@ curl -X POST http://127.0.0.1:3000/api/user/login \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"username":"admin","password":"123456"}' -d '{"username":"admin","password":"123456"}'
# 以招商经理身份登录(只能看到自己负责的加盟商 # 以招商经理身份登录(只能看到加盟合同
curl -X POST http://127.0.0.1:3000/api/user/login \ curl -X POST http://127.0.0.1:3000/api/user/login \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"username":"liming","password":"123456"}' -d '{"username":"liming","password":"123456"}'

574
db.js
View File

@@ -40,9 +40,8 @@ async function initDB() {
username VARCHAR(50) NOT NULL COMMENT '用户名 (登录账号)', username VARCHAR(50) NOT NULL COMMENT '用户名 (登录账号)',
password VARCHAR(255) NOT NULL COMMENT '登录密码 (应存储加密后的值)', password VARCHAR(255) NOT NULL COMMENT '登录密码 (应存储加密后的值)',
is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '账号状态: 0-禁用, 1-启用', 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表)', employee_id INT DEFAULT NULL COMMENT '员工ID (关联employees表)',
department VARCHAR(100) DEFAULT NULL COMMENT '所属部门 (从employees同步的冗余字段)',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id), PRIMARY KEY (id),
@@ -61,7 +60,6 @@ async function initDB() {
address VARCHAR(200) DEFAULT NULL COMMENT '详细地址', address VARCHAR(200) DEFAULT NULL COMMENT '详细地址',
email VARCHAR(100) DEFAULT NULL COMMENT '电子邮箱', email VARCHAR(100) DEFAULT NULL COMMENT '电子邮箱',
remark TEXT 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 '创建时间', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id), PRIMARY KEY (id),
@@ -170,18 +168,30 @@ 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) {
// 列已删除则忽略
}
console.log('[init] 数据表初始化完成') 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(` await pool.query(`
CREATE TABLE IF NOT EXISTS roles ( CREATE TABLE IF NOT EXISTS departments (
id INT NOT NULL AUTO_INCREMENT COMMENT '角色ID (主键)', id INT NOT NULL AUTO_INCREMENT COMMENT '部门ID (主键)',
name VARCHAR(50) NOT NULL COMMENT '角色标识 (英文)', name VARCHAR(50) NOT NULL COMMENT '部门标识 (英文)',
description VARCHAR(200) DEFAULT NULL COMMENT '角色描述 (中文)', description VARCHAR(200) DEFAULT NULL COMMENT '部门名称 (中文)',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_roles_name (name) UNIQUE KEY uk_departments_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门表'
`) `)
await pool.query(` await pool.query(`
@@ -197,12 +207,12 @@ async function initDB() {
`) `)
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS role_permissions ( CREATE TABLE IF NOT EXISTS department_permissions (
role_id INT NOT NULL COMMENT '角色ID', department_id INT NOT NULL COMMENT '部门ID',
permission_id INT NOT NULL COMMENT '权限ID', permission_id INT NOT NULL COMMENT '权限ID',
PRIMARY KEY (role_id, permission_id), PRIMARY KEY (department_id, permission_id),
KEY idx_rp_permission_id (permission_id) KEY idx_dp_permission_id (permission_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色-权限关联表' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门-权限关联表'
`) `)
await pool.query(` await pool.query(`
@@ -223,27 +233,28 @@ async function initDB() {
`) `)
// ============ 2.4 种子数据 ============ // ============ 2.4 种子数据 ============
await seedRolesAndPermissions() await seedDepartmentsAndPermissions()
await seedDefaultUsers() await seedDefaultUsers()
await seedBusinessData()
} }
// ============ 3. 种子数据:角色与权限 ============ // ============ 3. 种子数据:部门与权限 ============
async function seedRolesAndPermissions() { async function seedDepartmentsAndPermissions() {
const [roles] = await pool.query('SELECT id FROM roles LIMIT 1') const [depts] = await pool.query('SELECT id FROM departments LIMIT 1')
if (roles.length > 0) return // 已初始化过,跳过 if (depts.length > 0) return // 已初始化过,跳过
const roleList = [ const deptList = [
{ name: 'admin', description: '系统管理员' }, { name: 'admin', description: '信息技术部' },
{ name: 'general_manager', description: '总经理' }, { name: 'general_manager', description: '总经理办公室' },
{ name: 'franchise_manager', description: '招商经理' }, { name: 'franchise_manager', description: '招商' },
{ name: 'operations_manager', description: '运营经理' }, { name: 'operations_manager', description: '运营' },
{ name: 'procurement_manager', description: '采购经理' }, { name: 'procurement_manager', description: '采购' },
{ name: 'finance', description: '财务人员' }, { name: 'finance', description: '财务' },
] ]
for (const r of roleList) { for (const d of deptList) {
await pool.query('INSERT INTO roles (name, description) VALUES (?, ?)', [r.name, r.description]) await pool.query('INSERT INTO departments (name, description) VALUES (?, ?)', [d.name, d.description])
} }
console.log('[init] 已初始化 6 个角色') console.log('[init] 已初始化 6 个部门')
const permissionList = [ const permissionList = [
{ name: 'customer:read', description: '查看加盟商', resource: 'customer', action: 'read' }, { name: 'customer:read', description: '查看加盟商', resource: 'customer', action: 'read' },
@@ -280,10 +291,10 @@ async function seedRolesAndPermissions() {
} }
console.log('[init] 已初始化 25 个权限') console.log('[init] 已初始化 25 个权限')
// 角色-权限映射 // 部门-权限映射
const allPerms = permissionList.map(p => p.name) const allPerms = permissionList.map(p => p.name)
const rolePermMap = { const deptPermMap = {
admin: allPerms, // 管理员拥有所有权限 admin: allPerms,
general_manager: [ general_manager: [
'customer:read', 'contract:read', 'after_sale:read', 'customer:read', 'contract:read', 'after_sale:read',
'product:read', 'supplier:read', 'employee:read', 'product:read', 'supplier:read', 'employee:read',
@@ -294,12 +305,12 @@ async function seedRolesAndPermissions() {
'employee:read', 'employee:read',
], ],
operations_manager: [ operations_manager: [
'customer:read', 'customer:update', 'customer:read',
'contract:read',
'after_sale:read', 'after_sale:create', 'after_sale:update', 'after_sale:delete', 'after_sale:read', 'after_sale:create', 'after_sale:update', 'after_sale:delete',
'employee:read', 'employee:read',
], ],
procurement_manager: [ procurement_manager: [
'customer:read',
'contract:read', 'contract:create', 'contract:update', 'contract:delete', 'contract:read', 'contract:create', 'contract:update', 'contract:delete',
'product:read', 'product:create', 'product:update', 'product:delete', 'product:read', 'product:create', 'product:update', 'product:delete',
'supplier:read', 'supplier:create', 'supplier:update', 'supplier:delete', 'supplier:read', 'supplier:create', 'supplier:update', 'supplier:delete',
@@ -310,28 +321,28 @@ async function seedRolesAndPermissions() {
], ],
} }
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 [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])) 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) { for (const permName of permNames) {
await pool.query( await pool.query(
'INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)', 'INSERT IGNORE INTO department_permissions (department_id, permission_id) VALUES (?, ?)',
[roleIdMap[roleName], permIdMap[permName]] [deptIdMap[deptName], permIdMap[permName]]
) )
} }
} }
console.log('[init] 已初始化角色-权限映射') console.log('[init] 已初始化部门-权限映射')
} }
// ============ 4. 种子数据:默认用户 ============ // ============ 4. 种子数据:默认用户 ============
async function seedDefaultUsers() { async function seedDefaultUsers() {
const hash = await bcrypt.hash('123456', 10) const hash = await bcrypt.hash('123456', 10)
const [roleRows] = await pool.query('SELECT id, name FROM roles') const [deptRows] = await pool.query('SELECT id, name FROM departments')
const roleIdMap = Object.fromEntries(roleRows.map(r => [r.name, r.id])) const deptIdMap = Object.fromEntries(deptRows.map(d => [d.name, d.id]))
const seedEmployees = [ const seedEmployees = [
{ name: '张超', gender: '男', age: 42, education: '硕士', department: '总经理办公室', entry_date: '2015-03-01', position: '总经理', salary: 50000, phone: '13800001001', email: 'zhangchao@mixue.com' }, { name: '张超', gender: '男', age: 42, education: '硕士', department: '总经理办公室', entry_date: '2015-03-01', position: '总经理', salary: 50000, phone: '13800001001', email: 'zhangchao@mixue.com' },
@@ -343,12 +354,12 @@ async function seedDefaultUsers() {
] ]
const seedUsers = [ const seedUsers = [
{ username: 'zhangchao', roleName: 'general_manager' }, { username: 'zhangchao', deptName: 'general_manager' },
{ username: 'liming', roleName: 'franchise_manager' }, { username: 'liming', deptName: 'franchise_manager' },
{ username: 'wangli', roleName: 'operations_manager' }, { username: 'wangli', deptName: 'operations_manager' },
{ username: 'zhaoqiang', roleName: 'procurement_manager' }, { username: 'zhaoqiang', deptName: 'procurement_manager' },
{ username: 'chenfang', roleName: 'finance' }, { username: 'chenfang', deptName: 'finance' },
{ username: 'admin', roleName: 'admin' }, { username: 'admin', deptName: 'admin' },
] ]
for (let i = 0; i < seedUsers.length; i++) { for (let i = 0; i < seedUsers.length; i++) {
@@ -367,14 +378,471 @@ async function seedDefaultUsers() {
) )
const empId = empResult.insertId const empId = empResult.insertId
// 再插入 users关联 employee 和 role // 再插入 users关联 employee 和 department
await pool.query( 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)
VALUES (?, ?, 1, ?, ?, ?)`, VALUES (?, ?, 1, ?, ?)`,
[usr.username, hash, roleIdMap[usr.roleName], empId, emp.department] [usr.username, hash, deptIdMap[usr.deptName], empId]
) )
console.log(`[init] 已创建用户 ${usr.username} (${emp.name} - ${emp.department})`) console.log(`[init] 已创建用户 ${usr.username} (${emp.name} - ${emp.department})`)
} }
} }
// ============ 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' },
{ empName: '孙婷', username: 'sunting', deptName: 'franchise_manager' },
{ empName: '周杰', username: 'zhoujie', deptName: 'operations_manager' },
{ empName: '吴敏', username: 'wumin', deptName: 'operations_manager' },
{ empName: '郑伟', username: 'zhengwei', deptName: 'operations_manager' },
{ empName: '黄磊', username: 'huanglei', deptName: 'procurement_manager' },
{ empName: '马丽', username: 'mali', deptName: 'finance' },
{ empName: '林峰', username: 'linfeng', deptName: 'general_manager' },
]
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) VALUES (?, ?, 1, ?, ?)',
[u.username, hash, deptIdMap2[u.deptName], emp.id]
)
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] 已初始化测试用合同和售后单')
}
}
module.exports = { pool, initDB } module.exports = { pool, initDB }

View File

@@ -1,4 +1,4 @@
// middleware/permissions.js —— RBAC 权限检查 + 数据范围过滤 // middleware/permissions.js —— 接口权限 + 数据范围 + 合同类型校验
/** /**
* 检查当前用户是否拥有指定权限 * 检查当前用户是否拥有指定权限
@@ -16,58 +16,81 @@ function checkPermission(resource, action) {
} }
/** /**
* 根据当前用户角色,返回该资源的数据范围过滤条件 * 根据用户部门计算数据范围,挂到 req.scope
* @param {object} user - req.user含 roleName, id, department * @param {string} resource - 资源名customers / contracts / after_sales / employees
* @param {string} resource - 资源名customers / contracts / after_sales / products / suppliers / employees / users
* @returns {{ where?: string, values?: any[], deny?: boolean }}
* - where + values追加到 SQL WHERE 子句的条件
* - denytrue 表示该角色无权访问此资源
*/ */
function getDataScope(user, resource) { function dataScope(resource) {
const { roleName, id: userId, department } = user return (req, res, next) => {
const deptName = req.user.departmentName // 如 'franchise_manager'
// admin 和 general_manager总经理:全量数据,无限制 // 信息技术部和总经理办公室:全量数据
if (roleName === 'admin' || roleName === 'general_manager') { if (deptName === 'admin' || deptName === 'general_manager') {
return {} return next()
} }
switch (resource) { let sql = null, params = []
case 'customers':
if (roleName === 'finance') return {}
return { where: 'responsible_user_id = ?', values: [userId] }
case 'contracts': switch (resource) {
if (roleName === 'finance') return {} case 'customers':
if (roleName === 'procurement_manager') { // 过渡期:招商部和运营部也能看全量加盟商
return { where: 'type = ?', values: ['supply'] } break
}
if (roleName === 'franchise_manager' || roleName === 'operations_manager') {
return { where: 'responsible_user_id = ? AND type = ?', values: [userId, 'franchise'] }
}
return {}
case 'after_sales': case 'contracts':
if (roleName === 'finance') return {} if (deptName === 'franchise_manager') {
if (roleName === 'operations_manager') { sql = 'AND type = ?'
return { where: 'responsible_user_id = ?', values: [userId], tableAlias: 'a' } params = ['franchise']
} } else if (deptName === 'procurement_manager') {
// franchise_manager 和 procurement_manager 无权限checkPermission 已拦截) sql = 'AND type = ?'
return {} params = ['supply']
}
// finance 全量看operations_manager 无合同权限checkPermission 已拦截)
break
case 'employees': case 'after_sales':
if (roleName === 'finance') return {} // 过渡期:运营部看全量售后
// 所有非管理员/总经理角色:只能看自己部门的员工 break
return { where: 'department = ?', values: [department] }
case 'products': case 'employees':
case 'suppliers': if (['franchise_manager', 'operations_manager', 'procurement_manager'].includes(deptName)) {
case 'users': sql = 'AND department = ?'
// 无数据范围过滤,仅靠 checkPermission 控制访问 params = [req.user.departmentDesc] // 如 '招商部'
return {} }
// finance 全量看员工
break
}
default: if (sql) {
return {} req.scope = { sql, params }
}
next()
} }
} }
module.exports = { checkPermission, getDataScope } /**
* 合同创建/修改时校验类型是否匹配部门
* 招商部只能操作加盟合同,采购部只能操作采购合同
*/
function validateContractType(req, res, next) {
const deptName = req.user.departmentName
const type = req.body.type
// 未传 type 则跳过(路由会默认 'franchise'
if (type === undefined) return next()
// 管理员/总经理/财务不限制
if (['admin', 'general_manager', 'finance'].includes(deptName)) 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()
}
module.exports = { checkPermission, dataScope, validateContractType }

View File

@@ -1,6 +1,5 @@
// routes/afterSales.js —— 售后管理 CRUD // routes/afterSales.js —— 售后管理 CRUD(纯数据库操作,权限由中间件层控制)
const { pool } = require('../db') const { pool } = require('../db')
const { getDataScope } = require('../middleware/permissions')
// 格式化日期为 YYYY-MM-DD // 格式化日期为 YYYY-MM-DD
function formatDate(dateStr) { function formatDate(dateStr) {
@@ -46,16 +45,10 @@ async function list(req, res) {
let where = 'WHERE 1=1' let where = 'WHERE 1=1'
const params = [] const params = []
// 数据范围过滤 // 数据范围过滤(由中间件注入 req.scope已含表别名前缀
const scope = getDataScope(req.user, 'after_sales') if (req.scope && req.scope.sql) {
if (scope.deny) { where += ' ' + req.scope.sql
return res.status(403).json({ code: 403, message: '无权访问此资源' }) params.push(...req.scope.params)
}
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)
} }
if (id) { if (id) {
@@ -115,14 +108,10 @@ async function detail(req, res) {
let sql = `${LIST_SELECT} WHERE a.id = ?` let sql = `${LIST_SELECT} WHERE a.id = ?`
const params = [req.params.id] const params = [req.params.id]
const scope = getDataScope(req.user, 'after_sales') // 数据范围过滤(由中间件注入 req.scope已含表别名前缀
if (scope.deny) { if (req.scope && req.scope.sql) {
return res.status(403).json({ code: 403, message: '无权访问此资源' }) sql += ' ' + req.scope.sql
} params.push(...req.scope.params)
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)
} }
const [rows] = await pool.query(sql, params) const [rows] = await pool.query(sql, params)
@@ -140,7 +129,7 @@ async function detail(req, res) {
async function create(req, res) { async function create(req, res) {
const { const {
customer_id, feedback, employee_id, handle_method, customer_id, feedback, employee_id, handle_method,
handle_status, service_date, remark, responsible_user_id, handle_status, service_date, remark,
} = req.body || {} } = req.body || {}
if (!customer_id) { if (!customer_id) {
@@ -156,17 +145,20 @@ async function create(req, res) {
if (cust.length === 0) { if (cust.length === 0) {
return res.status(400).json({ code: 400, message: '关联客户不存在' }) return res.status(400).json({ code: 400, message: '关联客户不存在' })
} }
// 校验业务员 // 校验业务员,并通过 employee_id 自动推导负责人
let ownerId = req.user.id
if (employee_id) { if (employee_id) {
const [emp] = await pool.query('SELECT id FROM employees WHERE id = ?', [employee_id]) const [emp] = await pool.query('SELECT id FROM employees WHERE id = ?', [employee_id])
if (emp.length === 0) { if (emp.length === 0) {
return res.status(400).json({ code: 400, message: '关联业务员不存在' }) 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( const [result] = await pool.query(
`INSERT INTO after_sales `INSERT INTO after_sales
(customer_id, feedback, employee_id, handle_method, handle_status, service_date, remark, responsible_user_id) (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 { try {
// 确认记录存在且在数据范围内 // 确认记录存在且在数据范围内
let checkSql = 'SELECT id FROM after_sales WHERE id = ?' let checkSql = 'SELECT id, employee_id FROM after_sales WHERE id = ?'
const checkParams = [id] let checkParams = [id]
const scope = getDataScope(req.user, 'after_sales') if (req.scope && req.scope.sql) {
if (scope.deny) { checkSql += ' ' + req.scope.sql
return res.status(403).json({ code: 403, message: '无权访问此资源' }) checkParams.push(...req.scope.params)
}
if (scope.where) {
checkSql += ' AND ' + scope.where
checkParams.push(...scope.values)
} }
const [existing] = await pool.query(checkSql, checkParams) const [existing] = await pool.query(checkSql, checkParams)
if (existing.length === 0) { 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 sets = []
const params = [] const params = []
for (const f of fields) { for (const f of fields) {
@@ -259,14 +263,10 @@ async function remove(req, res) {
const { id } = req.params const { id } = req.params
try { try {
let checkSql = 'SELECT id FROM after_sales WHERE id = ?' let checkSql = 'SELECT id FROM after_sales WHERE id = ?'
const checkParams = [id] let checkParams = [id]
const scope = getDataScope(req.user, 'after_sales') if (req.scope && req.scope.sql) {
if (scope.deny) { checkSql += ' ' + req.scope.sql
return res.status(403).json({ code: 403, message: '无权访问此资源' }) checkParams.push(...req.scope.params)
}
if (scope.where) {
checkSql += ' AND ' + scope.where
checkParams.push(...scope.values)
} }
const [existing] = await pool.query(checkSql, checkParams) const [existing] = await pool.query(checkSql, checkParams)
if (existing.length === 0) { if (existing.length === 0) {

View File

@@ -1,6 +1,5 @@
// routes/contracts.js —— 合同管理 CRUD // routes/contracts.js —— 合同管理 CRUD(纯数据库操作,权限由中间件层控制)
const { pool } = require('../db') const { pool } = require('../db')
const { getDataScope } = require('../middleware/permissions')
// 格式化日期为 YYYY-MM-DD // 格式化日期为 YYYY-MM-DD
function formatDate(dateStr) { function formatDate(dateStr) {
@@ -55,16 +54,10 @@ async function list(req, res) {
let where = 'WHERE 1=1' let where = 'WHERE 1=1'
const params = [] const params = []
// 数据范围过滤(列表查询使用表别名 c需要加前缀) // 数据范围过滤(由中间件注入 req.scope无别名list/detail 需机械加 c. 前缀)
const scope = getDataScope(req.user, 'contracts') if (req.scope && req.scope.sql) {
if (scope.deny) { where += ' ' + req.scope.sql.replace(/\btype\b/g, 'c.type')
return res.status(403).json({ code: 403, message: '无权访问此资源' }) params.push(...req.scope.params)
}
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)
} }
if (id) { if (id) {
@@ -139,15 +132,10 @@ async function detail(req, res) {
let sql = `${DETAIL_SELECT} WHERE c.id = ?` let sql = `${DETAIL_SELECT} WHERE c.id = ?`
const params = [req.params.id] const params = [req.params.id]
const scope = getDataScope(req.user, 'contracts') // 数据范围过滤(由中间件注入 req.scope无别名detail 需机械加 c. 前缀)
if (scope.deny) { if (req.scope && req.scope.sql) {
return res.status(403).json({ code: 403, message: '无权访问此资源' }) sql += ' ' + req.scope.sql.replace(/\btype\b/g, 'c.type')
} params.push(...req.scope.params)
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)
} }
const [rows] = await pool.query(sql, params) const [rows] = await pool.query(sql, params)
@@ -168,14 +156,8 @@ async function create(req, res) {
amount, effective_date, expiry_date, employee_id, status, remark, amount, effective_date, expiry_date, employee_id, status, remark,
} = req.body || {} } = req.body || {}
// 根据角色限制合同类型 // 合同类型默认值(类型校验由中间件层控制)
const contractType = req.body.type || (req.user.roleName === 'procurement_manager' ? 'supply' : 'franchise') const contractType = req.body.type || '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: '招商经理只能创建加盟合同' })
}
if (contractType === 'supply' && !supplier_id) { if (contractType === 'supply' && !supplier_id) {
return res.status(400).json({ code: 400, message: '采购合同必须选择供应商' }) return res.status(400).json({ code: 400, message: '采购合同必须选择供应商' })
@@ -202,12 +184,18 @@ async function create(req, res) {
return res.status(400).json({ code: 400, message: '关联供应商不存在' }) return res.status(400).json({ code: 400, message: '关联供应商不存在' })
} }
} }
// 校验业务员(如果填了) // 校验业务员(如果填了),并通过 employee_id 自动推导负责人
let responsibleUserId = req.user.id
if (employee_id) { if (employee_id) {
const [emp] = await pool.query('SELECT id FROM employees WHERE id = ?', [employee_id]) const [emp] = await pool.query('SELECT id FROM employees WHERE id = ?', [employee_id])
if (emp.length === 0) { if (emp.length === 0) {
return res.status(400).json({ code: 400, message: '关联业务员不存在' }) 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( const [result] = await pool.query(
@@ -224,7 +212,7 @@ async function create(req, res) {
status || '生效中', status || '生效中',
remark || null, remark || null,
contractType, contractType,
req.body.responsible_user_id || req.user.id] responsibleUserId]
) )
const [rows] = await pool.query(`${DETAIL_SELECT} WHERE c.id = ?`, [result.insertId]) const [rows] = await pool.query(`${DETAIL_SELECT} WHERE c.id = ?`, [result.insertId])
@@ -240,20 +228,17 @@ async function update(req, res) {
const { id } = req.params const { id } = req.params
const fields = [ const fields = [
'customer_id', 'supplier_id', 'contract_name', 'contract_no', 'contract_content', '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 { try {
// 确认记录存在且在数据范围内detail/update/delete 不用表别名) // 确认记录存在且在数据范围内detail/update/delete 不用表别名)
let checkSql = 'SELECT id FROM contracts WHERE id = ?' let checkSql = 'SELECT id, employee_id FROM contracts WHERE id = ?'
const checkParams = [id] let checkParams = [id]
const scope = getDataScope(req.user, 'contracts') if (req.scope && req.scope.sql) {
if (scope.deny) { checkSql += ' ' + req.scope.sql
return res.status(403).json({ code: 403, message: '无权访问此资源' }) checkParams.push(...req.scope.params)
}
if (scope.where) {
checkSql += ' AND ' + scope.where
checkParams.push(...scope.values)
} }
const [existing] = await pool.query(checkSql, checkParams) const [existing] = await pool.query(checkSql, checkParams)
if (existing.length === 0) { if (existing.length === 0) {
@@ -273,6 +258,22 @@ async function update(req, res) {
return res.status(400).json({ code: 400, message: '关联业务员不存在' }) 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) { if (req.body.supplier_id) {
const [sup] = await pool.query('SELECT id FROM suppliers WHERE id = ?', [req.body.supplier_id]) const [sup] = await pool.query('SELECT id FROM suppliers WHERE id = ?', [req.body.supplier_id])
if (sup.length === 0) { if (sup.length === 0) {
@@ -313,14 +314,10 @@ async function remove(req, res) {
const { id } = req.params const { id } = req.params
try { try {
let checkSql = 'SELECT id FROM contracts WHERE id = ?' let checkSql = 'SELECT id FROM contracts WHERE id = ?'
const checkParams = [id] let checkParams = [id]
const scope = getDataScope(req.user, 'contracts') if (req.scope && req.scope.sql) {
if (scope.deny) { checkSql += ' ' + req.scope.sql
return res.status(403).json({ code: 403, message: '无权访问此资源' }) checkParams.push(...req.scope.params)
}
if (scope.where) {
checkSql += ' AND ' + scope.where
checkParams.push(...scope.values)
} }
const [existing] = await pool.query(checkSql, checkParams) const [existing] = await pool.query(checkSql, checkParams)
if (existing.length === 0) { if (existing.length === 0) {

View File

@@ -1,6 +1,5 @@
// routes/customers.js —— 客户管理 CRUD // routes/customers.js —— 客户管理 CRUD(纯数据库操作,权限由中间件层控制)
const { pool } = require('../db') const { pool } = require('../db')
const { getDataScope } = require('../middleware/permissions')
// 提取分页参数 // 提取分页参数
function pagination(query) { function pagination(query) {
@@ -19,14 +18,10 @@ async function list(req, res) {
let where = 'WHERE 1=1' let where = 'WHERE 1=1'
const params = [] const params = []
// 数据范围过滤 // 数据范围过滤(由中间件注入 req.scope
const scope = getDataScope(req.user, 'customers') if (req.scope && req.scope.sql) {
if (scope.deny) { where += ' ' + req.scope.sql
return res.status(403).json({ code: 403, message: '无权访问此资源' }) params.push(...req.scope.params)
}
if (scope.where) {
where += ' AND ' + scope.where
params.push(...scope.values)
} }
if (id) { if (id) {
@@ -56,12 +51,10 @@ async function list(req, res) {
params params
) )
// 查分页数据JOIN users+employees 获取负责人姓名) // 查分页数据
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT c.*, e.name AS responsible_user_name `SELECT c.*
FROM customers 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 DESC LIMIT ? OFFSET ?`,
[...params, pageSize, offset] [...params, pageSize, offset]
) )
@@ -89,13 +82,9 @@ async function detail(req, res) {
let sql = 'SELECT * FROM customers WHERE id = ?' let sql = 'SELECT * FROM customers WHERE id = ?'
const params = [req.params.id] const params = [req.params.id]
const scope = getDataScope(req.user, 'customers') if (req.scope && req.scope.sql) {
if (scope.deny) { sql += ' ' + req.scope.sql
return res.status(403).json({ code: 403, message: '无权访问此资源' }) params.push(...req.scope.params)
}
if (scope.where) {
sql += ' AND ' + scope.where
params.push(...scope.values)
} }
const [rows] = await pool.query(sql, params) const [rows] = await pool.query(sql, params)
@@ -113,7 +102,7 @@ async function detail(req, res) {
async function create(req, res) { async function create(req, res) {
const { const {
name, phone, province, city, district, name, phone, province, city, district,
address, email, remark, responsible_user_id, address, email, remark,
} = req.body || {} } = req.body || {}
if (!name) { if (!name) {
@@ -121,14 +110,11 @@ async function create(req, res) {
} }
try { try {
// 如果没指定负责人,默认设为当前用户
const ownerId = responsible_user_id || req.user.id
const [result] = await pool.query( const [result] = await pool.query(
`INSERT INTO customers (name, phone, province, city, district, address, email, remark, responsible_user_id) `INSERT INTO customers (name, phone, province, city, district, address, email, remark)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[name, phone || null, province || null, city || null, district || null, [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]) const [rows] = await pool.query('SELECT * FROM customers WHERE id = ?', [result.insertId])
res.json({ code: 0, message: 'ok', data: rows[0] }) res.json({ code: 0, message: 'ok', data: rows[0] })
@@ -143,20 +129,16 @@ async function update(req, res) {
const { id } = req.params const { id } = req.params
const fields = [ const fields = [
'name', 'phone', 'province', 'city', 'district', 'name', 'phone', 'province', 'city', 'district',
'address', 'email', 'remark', 'responsible_user_id', 'address', 'email', 'remark',
] ]
try { try {
// 确认记录存在且在数据范围内 // 确认记录存在且在数据范围内
let checkSql = 'SELECT id FROM customers WHERE id = ?' let checkSql = 'SELECT id FROM customers WHERE id = ?'
const checkParams = [id] let checkParams = [id] // let, 后续可能 push scope.params
const scope = getDataScope(req.user, 'customers') if (req.scope && req.scope.sql) {
if (scope.deny) { checkSql += ' ' + req.scope.sql
return res.status(403).json({ code: 403, message: '无权访问此资源' }) checkParams.push(...req.scope.params)
}
if (scope.where) {
checkSql += ' AND ' + scope.where
checkParams.push(...scope.values)
} }
const [existing] = await pool.query(checkSql, checkParams) const [existing] = await pool.query(checkSql, checkParams)
if (existing.length === 0) { if (existing.length === 0) {
@@ -192,14 +174,10 @@ async function remove(req, res) {
const { id } = req.params const { id } = req.params
try { try {
let checkSql = 'SELECT id FROM customers WHERE id = ?' let checkSql = 'SELECT id FROM customers WHERE id = ?'
const checkParams = [id] let checkParams = [id]
const scope = getDataScope(req.user, 'customers') if (req.scope && req.scope.sql) {
if (scope.deny) { checkSql += ' ' + req.scope.sql
return res.status(403).json({ code: 403, message: '无权访问此资源' }) checkParams.push(...req.scope.params)
}
if (scope.where) {
checkSql += ' AND ' + scope.where
checkParams.push(...scope.values)
} }
const [existing] = await pool.query(checkSql, checkParams) const [existing] = await pool.query(checkSql, checkParams)
if (existing.length === 0) { 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 { pool } = require('../db')
const { getDataScope } = require('../middleware/permissions') const bcrypt = require('bcryptjs')
// 格式化日期为 YYYY-MM-DD // 格式化日期为 YYYY-MM-DD
function formatDate(dateStr) { function formatDate(dateStr) {
@@ -36,14 +36,10 @@ async function list(req, res) {
let where = 'WHERE 1=1' let where = 'WHERE 1=1'
const params = [] const params = []
// 数据范围过滤(部门隔离 // 数据范围过滤(由中间件注入 req.scope
const scope = getDataScope(req.user, 'employees') if (req.scope && req.scope.sql) {
if (scope.deny) { where += ' ' + req.scope.sql
return res.status(403).json({ code: 403, message: '无权访问此资源' }) params.push(...req.scope.params)
}
if (scope.where) {
where += ' AND ' + scope.where
params.push(...scope.values)
} }
if (id) { if (id) {
@@ -96,13 +92,10 @@ async function detail(req, res) {
let sql = 'SELECT * FROM employees WHERE id = ?' let sql = 'SELECT * FROM employees WHERE id = ?'
const params = [req.params.id] const params = [req.params.id]
const scope = getDataScope(req.user, 'employees') // 数据范围过滤(由中间件注入 req.scope
if (scope.deny) { if (req.scope && req.scope.sql) {
return res.status(403).json({ code: 403, message: '无权访问此资源' }) sql += ' ' + req.scope.sql
} params.push(...req.scope.params)
if (scope.where) {
sql += ' AND ' + scope.where
params.push(...scope.values)
} }
const [rows] = await pool.query(sql, params) const [rows] = await pool.query(sql, params)
@@ -145,6 +138,18 @@ async function create(req, res) {
remark || null] remark || null]
) )
const [rows] = await pool.query('SELECT * FROM employees WHERE id = ?', [result.insertId]) const [rows] = await pool.query('SELECT * FROM employees WHERE id = ?', [result.insertId])
// 如果中间件 allowUserCreation 要求同步创建用户账号
if (req._createUser) {
const { username, password, department_id } = req._createUser
const hash = await bcrypt.hash(password, 10)
await pool.query(
'INSERT INTO users (username, password, is_active, department_id, employee_id) VALUES (?, ?, 1, ?, ?)',
[username, hash, department_id, result.insertId]
)
delete req._createUser // 清理,避免影响后续中间件
}
res.json({ code: 0, message: 'ok', data: rows[0] }) res.json({ code: 0, message: 'ok', data: rows[0] })
} catch (e) { } catch (e) {
console.error('[employees create] error:', e) console.error('[employees create] error:', e)
@@ -163,14 +168,10 @@ async function update(req, res) {
try { try {
// 确认记录存在且在数据范围内 // 确认记录存在且在数据范围内
let checkSql = 'SELECT id FROM employees WHERE id = ?' let checkSql = 'SELECT id FROM employees WHERE id = ?'
const checkParams = [id] let checkParams = [id]
const scope = getDataScope(req.user, 'employees') if (req.scope && req.scope.sql) {
if (scope.deny) { checkSql += ' ' + req.scope.sql
return res.status(403).json({ code: 403, message: '无权访问此资源' }) checkParams.push(...req.scope.params)
}
if (scope.where) {
checkSql += ' AND ' + scope.where
checkParams.push(...scope.values)
} }
const [existing] = await pool.query(checkSql, checkParams) const [existing] = await pool.query(checkSql, checkParams)
if (existing.length === 0) { if (existing.length === 0) {
@@ -197,11 +198,6 @@ async function update(req, res) {
params.push(id) params.push(id)
await pool.query(`UPDATE employees SET ${sets.join(', ')} WHERE id = ?`, params) 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 [rows] = await pool.query('SELECT * FROM employees WHERE id = ?', [id]) const [rows] = await pool.query('SELECT * FROM employees WHERE id = ?', [id])
res.json({ code: 0, message: 'ok', data: rows[0] }) res.json({ code: 0, message: 'ok', data: rows[0] })
} catch (e) { } catch (e) {
@@ -215,14 +211,10 @@ async function remove(req, res) {
const { id } = req.params const { id } = req.params
try { try {
let checkSql = 'SELECT id FROM employees WHERE id = ?' let checkSql = 'SELECT id FROM employees WHERE id = ?'
const checkParams = [id] let checkParams = [id]
const scope = getDataScope(req.user, 'employees') if (req.scope && req.scope.sql) {
if (scope.deny) { checkSql += ' ' + req.scope.sql
return res.status(403).json({ code: 403, message: '无权访问此资源' }) checkParams.push(...req.scope.params)
}
if (scope.where) {
checkSql += ' AND ' + scope.where
checkParams.push(...scope.values)
} }
const [existing] = await pool.query(checkSql, checkParams) const [existing] = await pool.query(checkSql, checkParams)
if (existing.length === 0) { if (existing.length === 0) {

View File

@@ -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 bcrypt = require('bcryptjs')
const { pool } = require('../db') const { pool } = require('../db')
// 安全字段:关联查询 roles 和 employees // 安全字段:关联查询 departments 和 employees
const USER_LIST_SQL = ` 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.created_at, u.updated_at, 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 e.name AS real_name
FROM users u 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 LEFT JOIN employees e ON u.employee_id = e.id
` `
@@ -32,10 +32,10 @@ async function login(req, res) {
try { try {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT u.id, u.username, u.password, u.is_active, u.role_id, u.department, u.employee_id, `SELECT u.id, u.username, u.password, u.is_active, u.department_id, u.employee_id,
r.name AS role_name, e.name AS real_name d.name AS dept_name, d.description AS dept_desc, e.name AS real_name
FROM users u 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 LEFT JOIN employees e ON u.employee_id = e.id
WHERE u.username = ?`, WHERE u.username = ?`,
[username] [username]
@@ -56,12 +56,12 @@ async function login(req, res) {
return res.status(400).json({ code: 400, message: '账号或密码错误' }) return res.status(400).json({ code: 400, message: '账号或密码错误' })
} }
// 查询该角色的所有权限标识 // 查询该部门的所有权限标识
const [perms] = await pool.query( const [perms] = await pool.query(
`SELECT p.name FROM permissions p `SELECT p.name FROM permissions p
JOIN role_permissions rp ON p.id = rp.permission_id JOIN department_permissions dp ON p.id = dp.permission_id
WHERE rp.role_id = ?`, WHERE dp.department_id = ?`,
[user.role_id] [user.department_id]
) )
const permissions = perms.map(p => p.name) const permissions = perms.map(p => p.name)
@@ -70,9 +70,9 @@ async function login(req, res) {
id: user.id, id: user.id,
username: user.username, username: user.username,
name: user.real_name || user.username, name: user.real_name || user.username,
role_id: user.role_id, department_id: user.department_id,
roleName: user.role_name, departmentName: user.dept_name,
department: user.department, departmentDesc: user.dept_desc,
permissions, permissions,
}, },
process.env.JWT_SECRET, process.env.JWT_SECRET,
@@ -88,9 +88,9 @@ async function login(req, res) {
id: user.id, id: user.id,
username: user.username, username: user.username,
name: user.real_name || user.username, name: user.real_name || user.username,
role_id: user.role_id, department_id: user.department_id,
roleName: user.role_name, departmentName: user.dept_name,
department: user.department, departmentDesc: user.dept_desc,
permissions, permissions,
}, },
}, },
@@ -177,7 +177,7 @@ async function changePassword(req, res) {
async function list(req, res) { async function list(req, res) {
try { try {
const { page, pageSize, offset } = pagination(req.query) 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' let where = 'WHERE 1=1'
const params = [] const params = []
@@ -194,9 +194,9 @@ async function list(req, res) {
where += ' AND u.id = ?' where += ' AND u.id = ?'
params.push(Number(id)) params.push(Number(id))
} }
if (role_id) { if (department_id) {
where += ' AND u.role_id = ?' where += ' AND u.department_id = ?'
params.push(Number(role_id)) params.push(Number(department_id))
} }
if (is_active !== undefined && is_active !== '') { if (is_active !== undefined && is_active !== '') {
where += ' AND u.is_active = ?' where += ' AND u.is_active = ?'
@@ -251,7 +251,7 @@ async function detail(req, res) {
// POST /api/users —— 创建用户 // POST /api/users —— 创建用户
async function create(req, res) { 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 } = req.body || {}
if (!username || !password) { if (!username || !password) {
return res.status(400).json({ code: 400, message: '用户名和密码必填' }) return res.status(400).json({ code: 400, message: '用户名和密码必填' })
@@ -263,31 +263,27 @@ async function create(req, res) {
return res.status(400).json({ code: 400, message: '密码至少 6 位' }) return res.status(400).json({ code: 400, message: '密码至少 6 位' })
} }
// 校验 role_id 是否存在 // 校验 department_id 是否存在
if (role_id) { if (department_id) {
const [role] = await pool.query('SELECT id FROM roles WHERE id = ?', [role_id]) const [dept] = await pool.query('SELECT id FROM departments WHERE id = ?', [department_id])
if (role.length === 0) { if (dept.length === 0) {
return res.status(400).json({ code: 400, message: '指定的角色不存在' }) return res.status(400).json({ code: 400, message: '指定的部门不存在' })
} }
} }
// 校验 employee_id 是否存在 // 校验 employee_id 是否存在
if (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) { if (emp.length === 0) {
return res.status(400).json({ code: 400, message: '指定的员工不存在' }) return res.status(400).json({ code: 400, message: '指定的员工不存在' })
} }
// 如果没传 department自动从员工表同步
if (!department) {
req.body.department = emp[0].department
}
} }
try { try {
const hash = await bcrypt.hash(password, 10) const hash = await bcrypt.hash(password, 10)
const [result] = await pool.query( const [result] = await pool.query(
'INSERT INTO users (username, password, is_active, role_id, employee_id, department) VALUES (?, ?, ?, ?, ?, ?)', 'INSERT INTO users (username, password, is_active, department_id, employee_id) VALUES (?, ?, ?, ?, ?)',
[username, hash, is_active, role_id || null, employee_id || null, req.body.department || department || null] [username, hash, is_active, department_id || null, employee_id || null]
) )
const [rows] = await pool.query( const [rows] = await pool.query(
@@ -307,42 +303,30 @@ async function create(req, res) {
// PUT /api/users/:id —— 更新用户 // PUT /api/users/:id —— 更新用户
async function update(req, res) { async function update(req, res) {
const { id } = req.params const { id } = req.params
const fields = ['username', 'role_id', 'employee_id', 'department', 'is_active'] const fields = ['username', 'department_id', 'employee_id', 'is_active']
try { 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 FROM users WHERE id = ?', [id])
if (existing.length === 0) { if (existing.length === 0) {
return res.status(404).json({ code: 404, message: '用户不存在' }) return res.status(404).json({ code: 404, message: '用户不存在' })
} }
// 不允许修改自己的角色或禁用自己 // 不允许修改自己的部门或禁用自己(此检查已移至中间件 protectSelfUpdate
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: '不能禁用自己' })
}
}
// 校验 role_id // 校验 department_id
if (req.body.role_id) { if (req.body.department_id) {
const [role] = await pool.query('SELECT id FROM roles WHERE id = ?', [req.body.role_id]) const [dept] = await pool.query('SELECT id FROM departments WHERE id = ?', [req.body.department_id])
if (role.length === 0) { if (dept.length === 0) {
return res.status(400).json({ code: 400, message: '指定的角色不存在' }) return res.status(400).json({ code: 400, message: '指定的部门不存在' })
} }
} }
// 校验 employee_id // 校验 employee_id
if (req.body.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) { if (emp.length === 0) {
return res.status(400).json({ code: 400, message: '指定的员工不存在' }) return res.status(400).json({ code: 400, message: '指定的员工不存在' })
} }
// 自动同步 department
if (req.body.department === undefined) {
req.body.department = emp[0].department
}
} }
const sets = [] const sets = []
@@ -390,24 +374,12 @@ async function update(req, res) {
async function remove(req, res) { async function remove(req, res) {
const { id } = req.params const { id } = req.params
try { try {
if (Number(id) === req.user.id) { // 不能删除自己/最后一个管理员(此检查已移至中间件 protectUserDelete
return res.status(400).json({ code: 400, message: '不能删除自己' }) const [existing] = await pool.query('SELECT id FROM users WHERE id = ?', [id])
}
const [existing] = await pool.query('SELECT id, role_id FROM users WHERE id = ?', [id])
if (existing.length === 0) { if (existing.length === 0) {
return res.status(404).json({ code: 404, message: '用户不存在' }) 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]) await pool.query('DELETE FROM users WHERE id = ?', [id])
res.json({ code: 0, message: 'ok' }) res.json({ code: 0, message: 'ok' })
} catch (e) { } catch (e) {

View File

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

View File

@@ -52,7 +52,8 @@ function check(label, res, expectStatus) {
} else { } else {
failCount++ failCount++
console.log(`${label} 预期 HTTP ${expectStatus}, 实际 HTTP ${res.status}`) 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 return res
} }
@@ -70,7 +71,7 @@ function checkValue(label, actual, expected) {
// ========== 主流程 ========== // ========== 主流程 ==========
async function main() { async function main() {
console.log('╔══════════════════════════════════════════════════╗') console.log('╔══════════════════════════════════════════════════╗')
console.log('║ 蜜雪冰城企业管理系统 — 全量接口测试 ║') console.log('║ 蜜雪冰城企业管理系统 — 全量接口测试 V2 ║')
console.log('╚══════════════════════════════════════════════════╝') console.log('╚══════════════════════════════════════════════════╝')
console.log(`服务地址: ${BASE}\n`) console.log(`服务地址: ${BASE}\n`)
@@ -82,12 +83,12 @@ async function main() {
console.log('━'.repeat(55)) console.log('━'.repeat(55))
const accounts = [ const accounts = [
{ username: 'admin', password: '123456', label: '系统管理员' }, { username: 'admin', password: '123456', label: '信息技术部' },
{ username: 'liming', password: '123456', label: '招商经理' }, { username: 'zhangchao', password: '123456', label: '总经理办公室' },
{ username: 'wangli', password: '123456', label: '运营经理' }, { username: 'liming', password: '123456', label: '招商部' },
{ username: 'zhaoqiang', password: '123456', label: '采购经理' }, { username: 'wangli', password: '123456', label: '运营部' },
{ username: 'chenfang', password: '123456', label: '财务人员' }, { username: 'zhaoqiang', password: '123456', label: '采购部' },
{ username: 'zhangchao', password: '123456', label: '总经理' }, { username: 'chenfang', password: '123456', label: '财务部' },
] ]
for (const acct of accounts) { for (const acct of accounts) {
@@ -98,49 +99,56 @@ async function main() {
} }
} }
// 错误密码
let r = await req('POST', '/api/user/login', { username: 'admin', password: 'wrong' }) 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: '' }) 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('\n' + '━'.repeat(55))
console.log('【2】 JWT payload 验证(新字段') console.log('【2】 JWT payload 验证(departmentName / departmentDesc / permissions')
console.log('━'.repeat(55)) console.log('━'.repeat(55))
// 验证 admin 登录返回的 userInfo 结构
const adminLogin = await req('POST', '/api/user/login', { username: 'admin', password: '123456' }) const adminLogin = await req('POST', '/api/user/login', { username: 'admin', password: '123456' })
const adminInfo = adminLogin.body?.data?.userInfo const adminInfo = adminLogin.body?.data?.userInfo
if (adminInfo) { if (adminInfo) {
checkValue('admin userInfo.name', adminInfo.name, '系统管理员') checkValue('admin name', adminInfo.name, '系统管理员')
checkValue('admin userInfo.roleName', adminInfo.roleName, 'admin') checkValue('admin departmentName', adminInfo.departmentName, 'admin')
checkValue('admin userInfo.department', adminInfo.department, '信息技术部') checkValue('admin departmentDesc', adminInfo.departmentDesc, '信息技术部')
check('admin userInfo.permissions 是数组', { status: Array.isArray(adminInfo.permissions) ? 200 : 500 }, 200) check('admin permissions 是数组', { status: Array.isArray(adminInfo.permissions) ? 200 : 500 }, 200)
checkValue('admin permissions 数量', adminInfo.permissions?.length, 25) checkValue('admin permissions 数量', adminInfo.permissions?.length, 25)
} }
// 验证 liming招商经理的权限
const limingLogin = await req('POST', '/api/user/login', { username: 'liming', password: '123456' }) const limingLogin = await req('POST', '/api/user/login', { username: 'liming', password: '123456' })
const limingInfo = limingLogin.body?.data?.userInfo const limingInfo = limingLogin.body?.data?.userInfo
if (limingInfo) { if (limingInfo) {
checkValue('liming userInfo.name', limingInfo.name, '李明') checkValue('liming name', limingInfo.name, '李明')
checkValue('liming userInfo.roleName', limingInfo.roleName, 'franchise_manager') checkValue('liming departmentName', limingInfo.departmentName, 'franchise_manager')
checkValue('liming userInfo.department', limingInfo.department, '招商部') checkValue('liming departmentDesc', limingInfo.departmentDesc, '招商部')
check('liming 有 customer:read', { status: limingInfo.permissions?.includes('customer:read') ? 200 : 500 }, 200) 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 无 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 无 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) r = await req('GET', '/api/user/info', null, tokens.admin)
check('GET /api/user/info (admin)', r, 200) check('GET /api/user/info (admin)', r, 200)
if (r.body?.data) { if (r.body?.data) {
check('info 包含 role_name 字段', { status: r.body.data.role_name ? 200 : 500 }, 200) check('info 有 dept_name', { status: r.body.data.dept_name ? 200 : 500 }, 200)
check('info 包含 department 字段', { status: r.body.data.department ? 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('【3】 权限隔离测试(无权限应返回 403')
console.log('━'.repeat(55)) console.log('━'.repeat(55))
// 招商经理 → products无权限 // 招商 → products无权限
r = await req('GET', '/api/products', null, tokens.liming) r = await req('GET', '/api/products', null, tokens.liming)
check('liming → GET /api/products → 403', r, 403) check('liming → GET /api/products → 403', r, 403)
r = await req('POST', '/api/products', { name: 'x' }, tokens.liming) r = await req('POST', '/api/products', { name: 'x' }, tokens.liming)
check('liming → POST /api/products → 403', r, 403) check('liming → POST /api/products → 403', r, 403)
// 招商经理 → suppliers无权限 // 招商 → suppliers无权限
r = await req('GET', '/api/suppliers', null, tokens.liming) r = await req('GET', '/api/suppliers', null, tokens.liming)
check('liming → GET /api/suppliers → 403', r, 403) check('liming → GET /api/suppliers → 403', r, 403)
// 招商经理 → after-sales无权限 // 招商 → after-sales无权限
r = await req('GET', '/api/after-sales', null, tokens.liming) r = await req('GET', '/api/after-sales', null, tokens.liming)
check('liming → GET /api/after-sales → 403', r, 403) check('liming → GET /api/after-sales → 403', r, 403)
// 采购经理 → customers无权限 // 运营部 → products无权限
r = await req('GET', '/api/customers', null, tokens.zhaoqiang) r = await req('GET', '/api/products', null, tokens.wangli)
check('zhaoqiang → GET /api/customers → 403', r, 403) 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) r = await req('GET', '/api/after-sales', null, tokens.zhaoqiang)
check('zhaoqiang → GET /api/after-sales → 403', r, 403) check('zhaoqiang → GET /api/after-sales → 403', r, 403)
// 财务人员products无权限 // 财务POST customers只有 read
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
r = await req('POST', '/api/customers', { name: '财务创建' }, tokens.chenfang) 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) 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) 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) 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) 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 // 无 token
r = await req('GET', '/api/customers', null, null) r = await req('GET', '/api/customers', null, null)
check('无 token → GET /api/customers → 401', r, 401) check('无 token → GET /api/customers → 401', r, 401)
// ══════════════════════════════════════════ // ══════════════════════════════════════════
// 【4】 数据范围隔离测试 // 【4】 简易列表接口
// ══════════════════════════════════════════ // ══════════════════════════════════════════
console.log('\n' + '━'.repeat(55)) console.log('\n' + '━'.repeat(55))
console.log('【4】 数据范围隔离测试') console.log('【4】 简易列表接口(下拉选择用)')
console.log('━'.repeat(55)) console.log('━'.repeat(55))
// 4.1 加盟商范围隔离admin 创建 2 个加盟商,分别分配给 liming 和 wangli r = await req('GET', '/api/user/list', null, tokens.admin)
// 获取 liming 和 wangli 的 user ID check('GET /api/user/list (admin)', r, 200)
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)
if (r.body?.data) { if (r.body?.data) {
const limingCusts = r.body.data.list || [] checkValue('user/list 数量 >= 6', r.body.data.length >= 6 ? 'YES' : 'NO', 'YES')
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')
} }
// wangli 只能看到自己负责的 // 放宽为 auth 后,非管理员也能调
r = await req('GET', '/api/customers', null, tokens.wangli) r = await req('GET', '/api/user/list', null, tokens.liming)
check('wangli GET /api/customers (应只看到自己的)', r, 200) 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) { if (r.body?.data) {
const wangliCusts = r.body.data.list || [] check('customers/simple 有 id+name', { status: r.body.data[0]?.id && r.body.data[0]?.name ? 200 : 500 }, 200)
const allBelongToWangli = wangliCusts.every(c => c.responsible_user_id === wangliUserId)
checkValue('wangli 只看到自己的加盟商', allBelongToWangli ? 'YES' : 'NO', 'YES')
} }
// 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) 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) { 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 的加盟商详情 r = await req('GET', '/api/customers', null, tokens.wangli)
if (custBId) { check('wangli GET /api/customers (全量-过渡期)', r, 200)
r = await req('GET', '/api/customers/' + custBId, null, tokens.liming) if (r.body?.data) {
check('liming GET wangli的加盟商详情 → 404', r, 404) checkValue('wangli 看到加盟商数 = admin', r.body.data.total >= adminCustCount ? 'YES' : 'NO', 'YES')
} }
// liming 不能修改 wangli 的加盟商 // 采购部现在也能看加盟商
if (custBId) { r = await req('GET', '/api/customers', null, tokens.zhaoqiang)
r = await req('PUT', '/api/customers/' + custBId, { name: '越权修改' }, tokens.liming) check('zhaoqiang GET /api/customers (采购部可看)', r, 200)
check('liming PUT wangli的加盟商 → 404', r, 404)
// 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 个不同部门的员工 // 采购部只看 supply
r = await req('POST', '/api/employees', { name: '招商部测试员工', department: '招商部', position: '专员', status: 1 }, tokens.admin) r = await req('GET', '/api/contracts', null, tokens.zhaoqiang)
check('admin 创建招商部员工', r, 200) check('zhaoqiang GET /api/contracts (只看采购合同)', r, 200)
const empLmId = r.body?.data?.id if (r.body?.data) {
if (empLmId) created.employees.push(empLmId) 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) // 运营部无合同权限(走不到 dataScope
check('admin 创建运营部员工', r, 200) r = await req('GET', '/api/contracts', null, tokens.wangli)
const empWlId = r.body?.data?.id check('wangli → GET /api/contracts → 403', r, 403)
if (empWlId) created.employees.push(empWlId)
// 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) r = await req('GET', '/api/employees', null, tokens.liming)
check('liming GET /api/employees (部门隔离)', r, 200) check('liming GET /api/employees (部门隔离)', r, 200)
if (r.body?.data) { if (r.body?.data) {
const limingEmps = r.body.data.list || [] const allInDept = (r.body.data.list || []).every(e => e.department === '招商部')
const allInDept = limingEmps.every(e => e.department === '招商部') checkValue('liming 只看招商部员工', allInDept ? 'YES' : 'NO', 'YES')
checkValue('liming 只看到招商部员工', allInDept ? 'YES' : 'NO', 'YES')
} }
// wangli 只能看到运营部员工
r = await req('GET', '/api/employees', null, tokens.wangli) r = await req('GET', '/api/employees', null, tokens.wangli)
check('wangli GET /api/employees (部门隔离)', r, 200) check('wangli GET /api/employees (部门隔离)', r, 200)
if (r.body?.data) { if (r.body?.data) {
const wangliEmps = r.body.data.list || [] const allInDept = (r.body.data.list || []).every(e => e.department === '运营部')
const allInDept = wangliEmps.every(e => e.department === '运营部') checkValue('wangli 只看运营部员工', allInDept ? 'YES' : 'NO', 'YES')
checkValue('wangli 只看到运营部员工', allInDept ? 'YES' : 'NO', 'YES')
} }
// admin 看到全部
r = await req('GET', '/api/employees', null, tokens.admin) r = await req('GET', '/api/employees', null, tokens.admin)
check('admin GET /api/employees (应看到全部)', r, 200) 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)
// ══════════════════════════════════════════ // ══════════════════════════════════════════
// 【5suppliers CRUD 测试 // 【6合同类型校验中间件
// ══════════════════════════════════════════ // ══════════════════════════════════════════
console.log('\n' + '━'.repeat(55)) 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)) console.log('━'.repeat(55))
r = await req('POST', '/api/suppliers', { r = await req('POST', '/api/suppliers', {
@@ -377,53 +377,56 @@ async function main() {
if (supId) created.suppliers.push(supId) if (supId) created.suppliers.push(supId)
r = await req('POST', '/api/suppliers', { name: '' }, tokens.zhaoqiang) 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) { if (supId) {
r = await req('GET', '/api/suppliers/' + supId, null, tokens.zhaoqiang) r = await req('GET', '/api/suppliers/' + supId, null, tokens.zhaoqiang)
check('zhaoqiang GET /api/suppliers/:id (详情)', r, 200) check('GET /api/suppliers/:id (详情)', r, 200)
if (r.body?.data) { if (r.body?.data) checkValue('供应商名称', r.body.data.name, '柠檬供应商-测试')
checkValue('供应商名称', r.body.data.name, '柠檬供应商-测试')
}
r = await req('PUT', '/api/suppliers/' + supId, { phone: '13800008888', remark: '更新联系方式' }, tokens.zhaoqiang) r = await req('PUT', '/api/suppliers/' + supId, { phone: '13800008888' }, tokens.zhaoqiang)
check('zhaoqiang PUT /api/suppliers/:id (更新)', r, 200) check('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('GET', '/api/suppliers?name=柠檬', null, tokens.zhaoqiang) 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('\n' + '━'.repeat(55))
console.log('【6】 users CRUD管理员操作') console.log('【8】 users CRUD管理员操作,新字段 department_id')
console.log('━'.repeat(55)) console.log('━'.repeat(55))
r = await req('GET', '/api/users', null, tokens.admin) r = await req('GET', '/api/users', null, tokens.admin)
check('admin GET /api/users (列表)', r, 200) 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', { r = await req('POST', '/api/users', {
username: 'testuser_' + Date.now(), username: 'testuser_' + Date.now(),
password: 'pass123456', password: 'pass123456',
role_id: 3, // franchise_manager department_id: 3, // franchise_manager 的 ID
employee_id: empLmId,
department: '招商部',
}, tokens.admin) }, 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 const newUserId = r.body?.data?.id
if (newUserId) { if (newUserId) {
created.users.push(newUserId) created.users.push(newUserId)
if (r.body?.data) { if (r.body?.data) {
check('新用户有 role_name', { status: r.body.data.role_name ? 200 : 500 }, 200) check('新用户有 dept_name', { status: r.body.data.dept_name ? 200 : 500 }, 200)
check('新用户有 real_name (从employees)', { status: r.body.data.real_name ? 200 : 500 }, 200) check('新用户有 dept_desc', { status: r.body.data.dept_desc ? 200 : 500 }, 200)
console.log(` ↳ 新用户: ${r.body.data.username}, 角色: ${r.body.data.role_name}, 姓名: ${r.body.data.real_name}`)
} }
r = await req('GET', '/api/users/' + newUserId, null, tokens.admin) 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) r = await req('PUT', '/api/users/' + newUserId, { is_active: 0 }, tokens.admin)
check('admin PUT /api/users/:id (禁用)', r, 200) 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) 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 // admin 不能删除自己
const adminUser = allUsers.body?.data?.list?.find(u => u.username === 'admin')
const adminUserId = adminUser?.id
r = await req('DELETE', '/api/users/' + adminUserId, null, tokens.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 不能禁用自己 // admin 不能禁用自己
r = await req('PUT', '/api/users/' + adminUserId, { is_active: 0 }, tokens.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('\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)) console.log('━'.repeat(55))
r = await req('POST', '/api/customers', { r = await req('POST', '/api/customers', {
@@ -463,17 +509,18 @@ async function main() {
const custCrudId = r.body?.data?.id const custCrudId = r.body?.data?.id
if (custCrudId) { if (custCrudId) {
created.customers.push(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) r = await req('POST', '/api/customers', { name: '' }, tokens.admin)
check('POST /api/customers (缺名)', r, 400) check('POST /api/customers (缺名 → 400)', r, 400)
if (custCrudId) { if (custCrudId) {
r = await req('GET', '/api/customers/' + custCrudId, null, tokens.admin) r = await req('GET', '/api/customers/' + custCrudId, null, tokens.admin)
check('GET /api/customers/:id (详情)', r, 200) 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) check('PUT /api/customers/:id (更新)', r, 200)
r = await req('GET', '/api/customers?name=CRUD', null, tokens.admin) 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) 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('\n' + '━'.repeat(55))
console.log('【8】 employees CRUD') console.log('【11】 employees CRUD')
console.log('━'.repeat(55)) console.log('━'.repeat(55))
r = await req('POST', '/api/employees', { r = await req('POST', '/api/employees', {
@@ -503,21 +550,18 @@ async function main() {
r = await req('GET', '/api/employees/' + empCrudId, null, tokens.admin) r = await req('GET', '/api/employees/' + empCrudId, null, tokens.admin)
check('GET /api/employees/:id (详情)', r, 200) 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) 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) 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('\n' + '━'.repeat(55))
console.log('【9】 products CRUD采购经理操作)') console.log('【12】 products CRUD采购操作)')
console.log('━'.repeat(55)) console.log('━'.repeat(55))
r = await req('POST', '/api/products', { r = await req('POST', '/api/products', {
@@ -529,7 +573,7 @@ async function main() {
if (prodId) created.products.push(prodId) if (prodId) created.products.push(prodId)
r = await req('POST', '/api/products', { name: '' }, tokens.zhaoqiang) r = await req('POST', '/api/products', { name: '' }, tokens.zhaoqiang)
check('POST /api/products (缺名)', r, 400) check('POST /api/products (缺名 → 400)', r, 400)
if (prodId) { if (prodId) {
r = await req('GET', '/api/products/' + prodId, null, tokens.zhaoqiang) 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) r = await req('GET', '/api/products?name=柠檬', null, tokens.zhaoqiang)
check('GET /api/products?name=柠檬 (搜索)', r, 200) 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('\n' + '━'.repeat(55))
console.log('【10】 contracts CRUD(含 type 字段)') console.log('【13】 contracts CRUD')
console.log('━'.repeat(55)) console.log('━'.repeat(55))
// 采购经理创建合同 → 自动 type='supply' // 招商部正确创建加盟合同
r = await req('POST', '/api/contracts', { 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: '生效', effective_date: '2026-06-01', expiry_date: '2027-05-31', status: '生效',
}, tokens.zhaoqiang) }, tokens.liming)
check('zhaoqiang POST /api/contracts (自动type=supply)', r, 200) check('liming POST /api/contracts (加盟合同)', r, 200)
if (r.body?.data) { if (r.body?.data) {
checkValue('合同type自动设为supply', r.body.data.type, 'supply') checkValue('合同type自动=franchise', r.body.data.type, 'franchise')
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')
check('合同有 customer_name 联查', { status: r.body.data.customer_name ? 200 : 500 }, 200) check('合同有 customer_name 联查', { status: r.body.data.customer_name ? 200 : 500 }, 200)
created.contracts.push(r.body.data.id) created.contracts.push(r.body.data.id)
} }
r = await req('POST', '/api/contracts', { customer_id: 99999, contract_name: '无效' }, tokens.admin) // 采购部正确创建采购合同(需要 supplier_id
check('POST /api/contracts (客户不存在)', r, 400) 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) r = await req('POST', '/api/contracts', { customer_id: 99999, contract_name: '无效' }, tokens.admin)
check('GET /api/contracts (列表)', r, 200) check('POST /api/contracts (客户不存在 → 400)', r, 400)
// ══════════════════════════════════════════ // ══════════════════════════════════════════
// 【11】 after-sales CRUD 测试 // 【14】 after-sales CRUD
// ══════════════════════════════════════════ // ══════════════════════════════════════════
console.log('\n' + '━'.repeat(55)) console.log('\n' + '━'.repeat(55))
console.log('【11】 after-sales CRUD') console.log('【14】 after-sales CRUD')
console.log('━'.repeat(55)) console.log('━'.repeat(55))
r = await req('POST', '/api/after-sales', { r = await req('POST', '/api/after-sales', {
customer_id: custAId, feedback: '冰淇淋机不出料', customer_id: custCrudId, feedback: '冰淇淋机不出料',
employee_id: empWlId, handle_method: '派工程师上门检修', handle_method: '派工程师上门', handle_status: '处理中',
handle_status: '处理中', service_date: '2026-06-20', service_date: '2026-06-20',
}, tokens.wangli) }, tokens.wangli)
check('wangli POST /api/after-sales (创建)', r, 200) check('wangli POST /api/after-sales (创建)', r, 200)
const asId = r.body?.data?.id const asId = r.body?.data?.id
if (asId) { if (asId) {
created.afterSales.push(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) 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) r = await req('POST', '/api/after-sales', { customer_id: custCrudId }, tokens.wangli)
check('POST /api/after-sales (缺feedback)', r, 400) check('POST /api/after-sales (缺feedback → 400)', r, 400)
if (asId) { if (asId) {
r = await req('GET', '/api/after-sales/' + asId, null, tokens.wangli) 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('\n' + '━'.repeat(55))
console.log('【12】 个人信息 & 改密') console.log('【15】 个人信息 & 改密')
console.log('━'.repeat(55)) console.log('━'.repeat(55))
r = await req('POST', '/api/user/logout', null, tokens.admin) r = await req('POST', '/api/user/logout', null, tokens.admin)
check('POST /api/user/logout', r, 200) check('POST /api/user/logout', r, 200)
r = await req('PUT', '/api/user/password', { oldPassword: 'wrong', newPassword: 'newpwd999' }, tokens.admin) 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) 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) 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('\n' + '━'.repeat(55))
console.log('【13】 测试数据清理') console.log('【16】 测试数据清理')
console.log('━'.repeat(55)) console.log('━'.repeat(55))
// 按依赖顺序删除
for (const id of created.afterSales) { for (const id of created.afterSales) {
await req('DELETE', '/api/after-sales/' + id, null, tokens.admin) await req('DELETE', '/api/after-sales/' + id, null, tokens.admin)
} }