diff --git a/API.md b/API.md index a172cce..889d4ee 100644 --- a/API.md +++ b/API.md @@ -7,25 +7,27 @@ - [快速开始](#快速开始) - [技术栈](#技术栈) - [项目结构](#项目结构) +- [架构分层](#架构分层) - [认证机制](#认证机制) - [API 接口总览](#api-接口总览) - - [用户认证](#1-用户认证) - - [用户管理](#2-用户管理) - - [加盟商管理](#3-加盟商管理) - - [员工管理](#4-员工管理) - - [合同管理](#5-合同管理) - - [售后管理](#6-售后管理) - - [产品管理](#7-产品管理) - - [供应商管理](#8-供应商管理) + - [1. 用户认证](#1-用户认证) + - [2. 用户管理](#2-用户管理) + - [3. 加盟商管理](#3-加盟商管理) + - [4. 员工管理](#4-员工管理) + - [5. 合同管理](#5-合同管理) + - [6. 售后管理](#6-售后管理) + - [7. 产品管理](#7-产品管理) + - [8. 供应商管理](#8-供应商管理) - [统一响应格式](#统一响应格式) - [数据库设计](#数据库设计) - [表结构详情](#表结构详情) - [表关系 ER 图](#表关系-er-图) -- [RBAC 权限系统](#rbac-权限系统) - - [角色定义](#角色定义) +- [部门权限系统](#部门权限系统) + - [部门定义](#部门定义) - [权限列表](#权限列表) - - [角色-权限映射](#角色-权限映射) + - [部门-权限映射](#部门-权限映射) - [数据范围隔离](#数据范围隔离) + - [中间件说明](#中间件说明) - [预置账号](#预置账号) --- @@ -70,7 +72,7 @@ node test-all.js | `JWT_SECRET` | JWT 签名密钥 | — | | `JWT_EXPIRES_IN` | Token 有效期 | `2h` | -> 首次启动时,系统会自动创建数据库、建表、初始化角色权限和预置用户。 +> 首次启动时,系统会自动创建数据库、建表、初始化部门权限和预置用户。 --- @@ -93,22 +95,43 @@ backmanager-server/ ├── server.js # 入口:Express 应用、鉴权中间件、路由注册 ├── db.js # 数据库:连接池、建表、种子数据 ├── middleware/ -│ └── permissions.js # RBAC:权限检查 + 数据范围过滤 +│ ├── permissions.js # 权限检查 + 数据范围 + 合同类型校验 +│ ├── users.js # 用户自保护规则(禁止自改部门、自删等) +│ └── employees.js # 员工创建时同步创建用户账号 ├── routes/ │ ├── users.js # 用户登录/信息/CRUD -│ ├── customers.js # 加盟商 CRUD -│ ├── employees.js # 员工 CRUD +│ ├── customers.js # 加盟商 CRUD + 简易列表 +│ ├── employees.js # 员工 CRUD + 简易列表 │ ├── contracts.js # 合同 CRUD │ ├── afterSales.js # 售后 CRUD │ ├── products.js # 产品 CRUD -│ └── suppliers.js # 供应商 CRUD -├── test-all.js # 集成测试(覆盖 6 个角色的权限与数据隔离) +│ └── suppliers.js # 供应商 CRUD + 简易列表 +├── test-all.js # 集成测试(覆盖 6 个部门的权限与数据隔离,131 条用例) +├── API.md # 本文档 ├── .env.example # 环境变量模板 └── package.json ``` --- +## 架构分层 + +本项目采用**三层分离架构**,各层职责清晰: + +``` +请求 → 中间件层(认证/权限/数据范围/业务规则)→ 路由层(纯 CRUD,数据库操作)→ 响应 +``` + +| 分层 | 文件 | 职责 | +|------|------|------| +| **API 端点层** | `server.js` | 路由注册,串联中间件链 | +| **中间件层** | `middleware/*.js` | JWT 认证、权限校验、数据范围注入、业务规则校验 | +| **数据库查询层** | `routes/*.js` | 纯 SQL CRUD 操作,不做权限判断和业务逻辑 | + +**路由层纯粹性要求:** 所有 `routes/*.js` 只包含数据库的增删改查操作,不参与权限判断、数据范围计算等逻辑。权限和数据范围由中间件通过 `req.user.permissions` 和 `req.scope` 注入。 + +--- + ## 认证机制 所有 API(除登录外)需要在请求头中携带 JWT Token: @@ -128,19 +151,29 @@ Authorization: Bearer "id": 1, "username": "admin", "name": "系统管理员", - "role_id": 1, - "roleName": "admin", - "department": "信息技术部", + "department_id": 1, + "departmentName": "admin", + "departmentDesc": "信息技术部", "permissions": ["customer:read", "customer:create", "customer:update", "customer:delete", "contract:read", "..."] } ``` -### 认证流程 +### 中间件执行流程 ``` -客户端请求 → auth 中间件验证 Token → checkPermission 检查权限 → 路由处理 → 数据范围过滤(getDataScope)→ 返回响应 +请求 → auth(验证 Token)→ checkPermission(权限检查)→ dataScope(数据范围注入)→ [业务中间件] → 路由处理 → 响应 ``` +| 中间件 | 文件 | 说明 | +|--------|------|------| +| `auth` | `server.js` | 解析并验证 JWT Token,将用户信息挂载到 `req.user` | +| `checkPermission(resource, action)` | `middleware/permissions.js` | 检查 `req.user.permissions` 是否包含 `resource:action` | +| `dataScope(resource)` | `middleware/permissions.js` | 根据用户部门计算数据范围,挂载 `req.scope = { sql, params }` | +| `validateContractType` | `middleware/permissions.js` | 限制招商部只能操作加盟合同,采购部只能操作采购合同 | +| `protectSelfUpdate` | `middleware/users.js` | 禁止用户修改自己的部门或禁用自己 | +| `protectUserDelete` | `middleware/users.js` | 禁止删除自己或最后一个管理员 | +| `allowUserCreation` | `middleware/employees.js` | 允许有 `user:manage` 权限的用户在创建员工时同步创建账号 | + ### 错误响应 | HTTP 状态码 | 场景 | @@ -158,6 +191,38 @@ Authorization: Bearer - **分页响应**:列表接口返回 `{ list, total, page, pageSize, totalPages }` - **搜索参数**:通过 URL Query String 传递,如 `GET /api/customers?name=张&phone=138` - **部分更新**:PUT 接口只传需要修改的字段即可,未传字段保持不变 +- **权限要求**:每个接口所需的权限标注在接口标题下方 + +### 接口权限速查表 + +| 接口 | 方法 | 权限 | 数据范围 | +|------|------|------|---------| +| `/api/user/login` | POST | 无 | — | +| `/api/user/info` | GET | 登录即可 | 仅自己 | +| `/api/user/list` | GET | 登录即可 | 所有启用用户 | +| `/api/user/logout` | POST | 登录即可 | — | +| `/api/user/password` | PUT | 登录即可 | 仅自己 | +| `/api/users` | GET/POST | `user:manage` | 全部 | +| `/api/users/:id` | GET/PUT/DELETE | `user:manage` | 全部 | +| `/api/customers/simple` | GET | `customer:read` | 全部客户 | +| `/api/customers` | GET | `customer:read` | 过渡期全量 | +| `/api/customers` | POST | `customer:create` | — | +| `/api/customers/:id` | GET/PUT/DELETE | 对应权限 | 过渡期全量 | +| `/api/employees/simple` | GET | 登录即可 | 全部在职员工 | +| `/api/employees` | GET | `employee:read` | 按部门隔离 | +| `/api/employees` | POST | `employee:create` | — | +| `/api/employees/:id` | GET/PUT/DELETE | 对应权限 | 按部门隔离 | +| `/api/contracts` | GET | `contract:read` | 按合同类型隔离 | +| `/api/contracts` | POST | `contract:create` | 按部门限制类型 | +| `/api/contracts/:id` | GET/PUT/DELETE | 对应权限 | 按合同类型隔离 | +| `/api/after-sales` | GET | `after_sale:read` | 过渡期全量 | +| `/api/after-sales` | POST | `after_sale:create` | — | +| `/api/after-sales/:id` | GET/PUT/DELETE | 对应权限 | 过渡期全量 | +| `/api/products` | GET | `product:read` | 全部 | +| `/api/products/:id` | GET/PUT/DELETE | 对应权限 | 全部 | +| `/api/suppliers/simple` | GET | `supplier:read` | 全部正常供应商 | +| `/api/suppliers` | GET | `supplier:read` | 全部 | +| `/api/suppliers/:id` | GET/PUT/DELETE | 对应权限 | 全部 | --- @@ -188,9 +253,9 @@ Authorization: Bearer "id": 1, "username": "admin", "name": "系统管理员", - "role_id": 1, - "roleName": "admin", - "department": "信息技术部", + "department_id": 1, + "departmentName": "admin", + "departmentDesc": "信息技术部", "permissions": ["customer:read", "customer:create", "..."] } } @@ -209,11 +274,30 @@ Authorization: Bearer #### GET /api/user/info — 获取当前用户信息 -需要 Token。返回当前登录用户的完整信息(含角色名、员工名)。 +需要 Token。 + +返回当前登录用户的完整信息(含部门名称、员工姓名)。 + +**响应数据字段:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | number | 用户 ID | +| `username` | string | 用户名 | +| `is_active` | number | 状态:0-禁用, 1-启用 | +| `department_id` | number | 部门 ID | +| `employee_id` | number | 关联员工 ID | +| `dept_name` | string | 部门标识(如 `admin`) | +| `dept_desc` | string | 部门中文名(如 信息技术部) | +| `real_name` | string | 真实姓名(来自员工表) | +| `created_at` | datetime | 创建时间 | +| `updated_at` | datetime | 更新时间 | #### GET /api/user/list — 简易用户列表 -需要 Token。返回所有启用用户的基本信息(id、用户名、真实姓名),用于前端下拉选择负责人。 +需要 Token(仅需登录,无需特定权限)。 + +返回所有启用用户的基本信息,用于前端下拉选择负责人。 **响应数据字段:** @@ -249,7 +333,7 @@ Authorization: Bearer ### 2. 用户管理 -> 需要 `user:manage` 权限(仅系统管理员拥有)。 +> 需要 `user:manage` 权限(仅系统管理员 / 信息技术部拥有)。 #### GET /api/users — 用户列表 @@ -260,7 +344,7 @@ Authorization: Bearer | `username` | 按用户名模糊搜索 | `?username=admin` | | `real_name` | 按真实姓名模糊搜索 | `?real_name=张` | | `id` | 按用户 ID 精确搜索 | `?id=1` | -| `role_id` | 按角色 ID 过滤 | `?role_id=2` | +| `department_id` | 按部门 ID 过滤 | `?department_id=2` | | `is_active` | 按状态过滤(0-禁用, 1-启用) | `?is_active=1` | | `page` | 页码 | `?page=1` | | `pageSize` | 每页条数 | `?pageSize=10` | @@ -272,12 +356,11 @@ Authorization: Bearer | `id` | number | 用户 ID | | `username` | string | 用户名 | | `is_active` | number | 状态:0-禁用, 1-启用 | -| `role_id` | number | 角色 ID | -| `role_name` | string | 角色标识(如 `admin`) | -| `role_description` | string | 角色描述(如 系统管理员) | +| `department_id` | number | 部门 ID | | `employee_id` | number | 关联员工 ID | +| `dept_name` | string | 部门标识(如 `admin`) | +| `dept_desc` | string | 部门中文名(如 信息技术部) | | `real_name` | string | 真实姓名(来自员工表) | -| `department` | string | 所属部门 | | `created_at` | datetime | 创建时间 | | `updated_at` | datetime | 更新时间 | @@ -293,24 +376,28 @@ Authorization: Bearer |------|------|------| | `username` | ✅ | 用户名(3-50 字符) | | `password` | ✅ | 密码(至少 6 位) | -| `role_id` | ❌ | 角色 ID(需为已存在的角色) | -| `employee_id` | ❌ | 关联员工 ID(需为已存在的员工,会自动同步部门信息) | -| `department` | ❌ | 所属部门(若填了 employee_id 且未传 department,会自动从员工表同步) | +| `department_id` | ❌ | 部门 ID(需为已存在的部门) | +| `employee_id` | ❌ | 关联员工 ID(需为已存在的员工) | | `is_active` | ❌ | 状态,默认 1 | +**校验规则:** +- `department_id` 必须存在于 `departments` 表 +- `employee_id` 必须存在于 `employees` 表 +- `username` 不可重复(返回 409) + #### PUT /api/users/:id — 更新用户 -**可更新字段:** `username`、`role_id`、`employee_id`、`department`、`is_active`、`password` +**可更新字段:** `username`、`department_id`、`employee_id`、`is_active`、`password` -**限制:** -- 不能修改自己的角色 -- 不能禁用自己 +**自保护限制(`protectSelfUpdate` 中间件):** +- 不能修改自己的 `department_id` +- 不能禁用自己(`is_active` 不能设为 0) #### DELETE /api/users/:id — 删除用户 -**限制:** +**自保护限制(`protectUserDelete` 中间件):** - 不能删除自己 -- 不能删除最后一个管理员账号(当系统中仅剩 1 个 admin 角色用户时,拒绝删除) +- 不能删除信息技术部的最后一个管理员账号 --- @@ -318,6 +405,17 @@ Authorization: Bearer > 需要 `customer:read` / `customer:create` / `customer:update` / `customer:delete` 权限。 +#### GET /api/customers/simple — 简易加盟商列表 + +需要 `customer:read` 权限。无数据范围限制,用于前端下拉选择。 + +**响应数据字段:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | number | 加盟商 ID | +| `name` | string | 加盟商姓名 | + #### GET /api/customers — 加盟商列表 **查询参数:** @@ -343,8 +441,6 @@ Authorization: Bearer | `address` | string | 详细地址 | | `email` | string | 电子邮箱 | | `remark` | string | 备注信息 | -| `responsible_user_id` | number | 负责人用户 ID | -| `responsible_user_name` | string | 负责人姓名(JOIN 自 users → employees) | | `created_at` | datetime | 创建时间 | | `updated_at` | datetime | 更新时间 | @@ -362,7 +458,6 @@ Authorization: Bearer | `address` | ❌ | 详细地址 | | `email` | ❌ | 电子邮箱 | | `remark` | ❌ | 备注 | -| `responsible_user_id` | ❌ | 负责人(默认为当前登录用户,仅管理员可在前端修改) | #### PUT /api/customers/:id — 更新加盟商 @@ -370,6 +465,8 @@ Authorization: Bearer #### DELETE /api/customers/:id — 删除加盟商 +**限制:** 被合同或售后记录引用的客户无法删除(返回 400)。 + --- ### 4. 员工管理 @@ -378,7 +475,7 @@ Authorization: Bearer #### GET /api/employees/simple — 简易员工列表 -需要 Token。无数据范围限制,用于前端下拉选择(如售后分配业务员)。支持按部门过滤。 +需要 Token(仅需登录)。无数据范围限制,用于前端下拉选择。支持按部门过滤。 **查询参数:** @@ -444,18 +541,27 @@ Authorization: Bearer | `email` | ❌ | 邮箱 | | `status` | ❌ | 在职状态,默认 1 | | `remark` | ❌ | 备注 | +| `username` | ❌ | 需同步创建用户时填写(需 `user:manage` 权限) | +| `password` | ❌ | 需同步创建用户时填写(需 `user:manage` 权限) | +| `department_id` | ❌ | 需同步创建用户时填写(指定用户所属部门 ID) | + +**同步创建用户账号(`allowUserCreation` 中间件):** + +当同时传入 `username`、`password`、`department_id` 三个字段时: +- 需要拥有 `user:manage` 权限 +- 系统会在创建员工后自动创建关联的系统用户账号 +- `username` 长度 3-50 字符,`password` 至少 6 位 #### PUT /api/employees/:id — 更新员工 所有字段均可选,只传需要修改的字段。 -**特殊行为:** 更新 `department` 字段时,会自动同步到关联的 users 表的 `department` 字段。 - #### DELETE /api/employees/:id — 删除员工 **特殊行为:** -- 删除员工时会同步删除关联的用户账号(users 表中 `employee_id` 匹配的记录) +- 删除员工时会同步删除关联的用户账号(`users` 表中 `employee_id` 匹配的记录) - 不能删除当前登录用户自己关联的员工账号 +- 被合同或售后记录引用的员工无法删除(返回 400) --- @@ -470,7 +576,7 @@ Authorization: Bearer | 参数 | 说明 | |------|------| | `id` | 按 ID 精确搜索 | -| `status` | 按合同状态过滤(草稿/生效中/已完成/作废) | +| `status` | 按合同状态过滤(草稿/生效/完成/作废) | | `customer_name` | 按客户名称模糊搜索 | | `contract_no` | 按合同编号模糊搜索 | | `contract_name` | 按合同名称模糊搜索 | @@ -483,9 +589,9 @@ Authorization: Bearer | 字段 | 类型 | 说明 | |------|------|------| | `id` | number | 合同 ID | -| `customer_id` | number | 客户 ID | +| `customer_id` | number | 客户 ID(加盟合同必填,采购合同为 null) | | `customer_name` | string | 客户名称(JOIN 自 customers) | -| `supplier_id` | number | 供应商 ID(采购合同必填) | +| `supplier_id` | number | 供应商 ID(采购合同必填,加盟合同为 null) | | `supplier_name` | string | 供应商名称(JOIN 自 suppliers) | | `contract_name` | string | 合同名称 | | `contract_no` | string | 合同编号 | @@ -495,7 +601,7 @@ Authorization: Bearer | `expiry_date` | date | 到期日期 | | `employee_id` | number | 业务员 ID | | `employee_name` | string | 业务员姓名(JOIN 自 employees) | -| `status` | string | 状态:草稿/生效中/已完成/作废 | +| `status` | string | 状态:草稿/生效/完成/作废 | | `remark` | string | 备注 | | `type` | string | 合同类型:`franchise`(加盟)/ `supply`(采购) | | `responsible_user_id` | number | 负责人用户 ID | @@ -523,10 +629,13 @@ Authorization: Bearer | `type` | ❌ | 合同类型(见下方说明) | | `responsible_user_id` | ❌ | 负责人(默认为当前用户,仅管理员可在前端修改) | -**合同类型自动设置与角色限制规则:** -- 采购经理(`procurement_manager`)创建的合同**只能**为 `supply`(采购合同),且必须选择 `supplier_id` -- 招商经理(`franchise_manager`)创建的合同**只能**为 `franchise`(加盟合同),且必须选择 `customer_id` -- 其他角色默认 `franchise`,也可手动指定 `type` +**合同类型限制(`validateContractType` 中间件):** + +| 部门 | 允许的合同类型 | 说明 | +|------|-------------|------| +| 招商部 (`franchise_manager`) | 仅 `franchise` | 只能创建加盟合同,且必须选择 `customer_id` | +| 采购部 (`procurement_manager`) | 仅 `supply` | 只能创建采购合同,且必须选择 `supplier_id` | +| 信息技术部/总经理/财务 | 不限制 | 默认 `franchise`,可手动指定 | **校验规则:** - `customer_id` 必须为已存在的客户 @@ -537,10 +646,12 @@ Authorization: Bearer 所有字段均可选。可更新字段包括 `type`、`supplier_id` 和 `responsible_user_id`。 -**校验规则:** 更新 `customer_id`、`supplier_id`、`employee_id` 时会校验关联记录是否存在。 +**校验规则:** 更新 `customer_id`、`supplier_id`、`employee_id` 时会校验关联记录是否存在。合同类型限制同创建。 #### DELETE /api/contracts/:id — 删除合同 +**限制:** 被售后记录引用的合同无法删除(返回 400)。 + --- ### 6. 售后管理 @@ -574,6 +685,7 @@ Authorization: Bearer | `service_date` | date | 售后日期 | | `remark` | string | 备注 | | `responsible_user_id` | number | 负责人用户 ID | +| `responsible_user_name` | string | 负责人姓名(JOIN 自 users → employees) | | `created_at` | datetime | 创建时间 | | `updated_at` | datetime | 更新时间 | @@ -666,6 +778,17 @@ Authorization: Bearer > 需要 `supplier:read` / `supplier:create` / `supplier:update` / `supplier:delete` 权限。 > 无数据范围隔离,拥有权限即可查看所有供应商。 +#### GET /api/suppliers/simple — 简易供应商列表 + +需要 `supplier:read` 权限。返回所有正常状态供应商的基本信息,用于前端下拉选择。 + +**响应数据字段:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | number | 供应商 ID | +| `name` | string | 供应商名称 | + #### GET /api/suppliers — 供应商列表 **查询参数:** @@ -755,7 +878,7 @@ Authorization: Bearer |------------|------|------| | 400 | 400 | 参数错误 / 校验失败 / 关联记录不存在 | | 401 | 401 | 未登录 / Token 无效 | -| 403 | 403 | 权限不足 / 账号被禁用 / 角色操作限制 | +| 403 | 403 | 权限不足 / 账号被禁用 / 合同类型不匹配 | | 404 | 404 | 资源不存在 | | 409 | 409 | 数据冲突(如用户名重复) | | 500 | 500 | 服务器内部错误 | @@ -768,7 +891,7 @@ Authorization: Bearer #### 1. users — 系统用户表 -存储系统登录账号,每个用户关联一个角色和一个员工。 +存储系统登录账号,每个用户关联一个部门和一个员工。 | 字段 | 类型 | 约束 | 说明 | |------|------|------|------| @@ -776,19 +899,18 @@ Authorization: Bearer | `username` | VARCHAR(50) | NOT NULL, UNIQUE | 用户名(登录账号) | | `password` | VARCHAR(255) | NOT NULL | 密码(bcrypt 哈希) | | `is_active` | TINYINT(1) | NOT NULL DEFAULT 1 | 账号状态:0-禁用, 1-启用 | -| `role_id` | INT | NULLABLE | 角色 ID → roles.id | +| `department_id` | INT | NULLABLE | 部门 ID → departments.id | | `employee_id` | INT | NULLABLE, UNIQUE | 员工 ID → employees.id | -| `department` | VARCHAR(100) | NULLABLE | 所属部门(冗余字段,从 employees 同步) | | `created_at` | DATETIME | DEFAULT CURRENT_TIMESTAMP | 创建时间 | | `updated_at` | DATETIME | ON UPDATE CURRENT_TIMESTAMP | 更新时间 | -#### 2. roles — 角色表 +#### 2. departments — 部门表 | 字段 | 类型 | 约束 | 说明 | |------|------|------|------| -| `id` | INT | PK, AUTO_INCREMENT | 角色 ID | -| `name` | VARCHAR(50) | NOT NULL, UNIQUE | 角色标识(英文) | -| `description` | VARCHAR(200) | NULLABLE | 角色描述(中文) | +| `id` | INT | PK, AUTO_INCREMENT | 部门 ID | +| `name` | VARCHAR(50) | NOT NULL, UNIQUE | 部门标识(英文) | +| `description` | VARCHAR(200) | NULLABLE | 部门名称(中文) | | `created_at` | DATETIME | DEFAULT CURRENT_TIMESTAMP | 创建时间 | #### 3. permissions — 权限表 @@ -801,11 +923,11 @@ Authorization: Bearer | `resource` | VARCHAR(50) | NOT NULL | 资源名称 | | `action` | VARCHAR(50) | NOT NULL | 操作类型 | -#### 4. role_permissions — 角色-权限关联表 +#### 4. department_permissions — 部门-权限关联表 | 字段 | 类型 | 约束 | 说明 | |------|------|------|------| -| `role_id` | INT | PK (联合) | 角色 ID → roles.id | +| `department_id` | INT | PK (联合) | 部门 ID → departments.id | | `permission_id` | INT | PK (联合) | 权限 ID → permissions.id | #### 5. customers — 加盟商信息表 @@ -821,7 +943,6 @@ Authorization: Bearer | `address` | VARCHAR(200) | — | 详细地址 | | `email` | VARCHAR(100) | — | 电子邮箱 | | `remark` | TEXT | — | 备注信息 | -| `responsible_user_id` | INT | — | 负责人用户 ID → users.id | | `created_at` | DATETIME | DEFAULT CURRENT_TIMESTAMP | 创建时间 | | `updated_at` | DATETIME | ON UPDATE CURRENT_TIMESTAMP | 更新时间 | @@ -850,8 +971,8 @@ Authorization: Bearer | 字段 | 类型 | 约束 | 说明 | |------|------|------|------| | `id` | INT | PK, AUTO_INCREMENT | 合同 ID | -| `customer_id` | INT | NULLABLE, INDEX | 客户 ID → customers.id(加盟合同必填) | -| `supplier_id` | INT | NULLABLE, INDEX | 供应商 ID → suppliers.id(采购合同必填) | +| `customer_id` | INT | NULLABLE, INDEX | 客户 ID → customers.id(加盟合同必填,采购合同为 null) | +| `supplier_id` | INT | NULLABLE, INDEX | 供应商 ID → suppliers.id(采购合同必填,加盟合同为 null) | | `contract_name` | VARCHAR(200) | NOT NULL | 合同名称 | | `contract_no` | VARCHAR(100) | — | 合同编号 | | `contract_content` | TEXT | — | 合同内容/条款 | @@ -859,7 +980,7 @@ Authorization: Bearer | `effective_date` | DATE | INDEX | 生效日期 | | `expiry_date` | DATE | — | 到期日期 | | `employee_id` | INT | INDEX | 业务员 ID → employees.id | -| `status` | VARCHAR(20) | NOT NULL DEFAULT '生效中' | 状态:草稿/生效中/已完成/作废 | +| `status` | VARCHAR(20) | NOT NULL DEFAULT '生效' | 状态:草稿/生效/完成/作废 | | `remark` | TEXT | — | 备注 | | `type` | VARCHAR(20) | NOT NULL DEFAULT 'franchise' | 类型:`franchise`(加盟) / `supply`(采购) | | `responsible_user_id` | INT | — | 负责人用户 ID → users.id | @@ -918,13 +1039,13 @@ Authorization: Bearer ### 表关系 ER 图 ``` -┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ -│ roles │ │ permissions │ │ role_permissions │ -│──────────────│ │──────────────│ │──────────────────│ -│ id (PK) │◄─┐ │ id (PK) │◄─┐ │ role_id (PK,FK) │ -│ name │ │ │ name │ └──│ permission_id │ -│ description │ │ │ description │ │ (PK,FK) │ -└──────────────┘ │ │ resource │ └──────────────────┘ +┌──────────────┐ ┌──────────────┐ ┌────────────────────────┐ +│ departments │ │ permissions │ │ department_permissions │ +│──────────────│ │──────────────│ │────────────────────────│ +│ id (PK) │◄─┐ │ id (PK) │◄─┐ │ department_id (PK,FK) │ +│ name │ │ │ name │ │ │ permission_id (PK,FK) │ +│ description │ │ │ description │ └──│ → permissions.id │ +└──────────────┘ │ │ resource │ └────────────────────────┘ │ │ action │ │ └──────────────┘ │ @@ -935,12 +1056,12 @@ Authorization: Bearer │ username │ │ │ name │ │ password │ │ │ gender │ │ is_active │ │ │ age │ -│ role_id (FK)─┼──┘ │ education │ -│ employee_id │─ ─ ─│ department │◄──────── users.department (冗余) -│ (FK) ──────┼──→ │ entry_date │ -│ department │ │ position │ -│ (冗余) │ │ salary │ -└──────┬───────┘ │ phone │ +│ department_id│──┘ │ education │ +│ (FK) │ │ department │◄──── 数据范围隔离字段 +│ employee_id │─ ─ ─│ entry_date │ +│ (FK) ──────┼──→ │ position │ +└──────┬───────┘ │ salary │ + │ │ phone │ │ │ email │ │ │ status │ │ └──────┬───────┘ @@ -951,7 +1072,6 @@ Authorization: Bearer │──────────────────────────────────────────────│ │ id (PK) │ │ name, phone, province, city, district, ... │ -│ responsible_user_id ──────────→ users.id │ └────────────────────┬─────────────────────────┘ │ ┌──────────┴──────────┐ @@ -995,12 +1115,11 @@ Authorization: Bearer | 关系 | 类型 | 说明 | |------|------|------| -| users → roles | 多对一 | 一个用户属于一个角色,一个角色可以有多个用户 | +| users → departments | 多对一 | 一个用户属于一个部门,一个部门可以有多个用户 | | users → employees | 一对一 | 一个用户关联一个员工(通过 employee_id,唯一约束) | -| roles ↔ permissions | 多对多 | 通过 role_permissions 中间表关联 | -| customers → users | 多对一 | 通过 responsible_user_id 指定负责人 | -| contracts → customers | 多对一 | 通过 customer_id 关联客户(加盟合同必填) | -| contracts → suppliers | 多对一 | 通过 supplier_id 关联供应商(采购合同必填) | +| departments ↔ permissions | 多对多 | 通过 department_permissions 中间表关联 | +| contracts → customers | 多对一 | 通过 customer_id 关联客户(加盟合同必填,采购合同为 null) | +| contracts → suppliers | 多对一 | 通过 supplier_id 关联供应商(采购合同必填,加盟合同为 null) | | contracts → employees | 多对一 | 通过 employee_id 关联业务员 | | contracts → users | 多对一 | 通过 responsible_user_id 指定负责人 | | after_sales → customers | 多对一 | 通过 customer_id 关联客户 | @@ -1011,20 +1130,20 @@ Authorization: Bearer --- -## RBAC 权限系统 +## 部门权限系统 -### 角色定义 +### 部门定义 -系统预置 6 个角色,对应蜜雪冰城总部的组织架构: +系统预置 6 个部门,对应蜜雪冰城总部的组织架构: -| 角色标识 | 中文名 | 所属部门 | 职责概述 | -|---------|--------|---------|---------| -| `admin` | 系统管理员 | 信息技术部 | 系统全权管理,拥有所有权限 | -| `general_manager` | 总经理 | 总经理办公室 | 全局只读,查看所有业务数据 | -| `franchise_manager` | 招商经理 | 招商部 | 管理加盟商和加盟合同 | -| `operations_manager` | 运营经理 | 运营部 | 维护加盟商信息、处理售后 | -| `procurement_manager` | 采购经理 | 采购部 | 管理产品、供应商和采购合同 | -| `finance` | 财务人员 | 财务部 | 只读查看业务数据(加盟商、合同、售后、员工) | +| 部门标识 | 中文名 | 职责概述 | +|---------|--------|---------| +| `admin` | 信息技术部 | 系统全权管理,拥有所有权限 | +| `general_manager` | 总经理办公室 | 全局只读,查看所有业务数据 | +| `franchise_manager` | 招商部 | 管理加盟商和加盟合同 | +| `operations_manager` | 运营部 | 维护加盟商信息、处理售后 | +| `procurement_manager` | 采购部 | 管理产品、供应商和采购合同 | +| `finance` | 财务部 | 只读查看业务数据(加盟商、合同、售后、员工) | ### 权限列表 @@ -1065,15 +1184,15 @@ Authorization: Bearer | **用户 (user)** | | | `user:manage` | 管理用户账号 | -### 角色-权限映射 +### 部门-权限映射 | 权限 | admin | general_manager | franchise_manager | operations_manager | procurement_manager | finance | |------|:-----:|:---------------:|:-----------------:|:------------------:|:-------------------:|:-------:| -| customer:read | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | +| customer:read | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | customer:create | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | -| customer:update | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | +| customer:update | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | | customer:delete | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | -| contract:read | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| contract:read | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | | contract:create | ✅ | ❌ | ✅ | ❌ | ✅ | ❌ | | contract:update | ✅ | ❌ | ✅ | ❌ | ✅ | ❌ | | contract:delete | ✅ | ❌ | ✅ | ❌ | ✅ | ❌ | @@ -1097,30 +1216,64 @@ Authorization: Bearer ### 数据范围隔离 -拥有操作权限 ≠ 能看到所有数据。系统通过 `getDataScope()` 函数在 SQL 查询层面做数据隔离: +拥有操作权限 ≠ 能看到所有数据。系统通过 `dataScope()` 中间件在 SQL 查询层面做数据隔离: | 资源 | admin | general_manager | franchise_manager | operations_manager | procurement_manager | finance | |------|-------|-----------------|-------------------|--------------------|--------------------|---------| -| customers | 全部 | 全部 | 仅自己负责 | 仅自己负责 | — | 全部 | -| contracts | 全部 | 全部 | 仅自己负责的加盟合同 | 仅自己负责的加盟合同 | 仅采购合同 | 全部 | -| after_sales | 全部 | 全部 | — | 仅自己负责 | — | 全部 | +| customers | 全部 | 全部 | 全部(过渡期) | 全部(过渡期) | 全部 | 全部 | +| contracts | 全部 | 全部 | 仅加盟合同 | — | 仅采购合同 | 全部 | +| after_sales | 全部 | 全部 | — | 全部(过渡期) | — | 全部 | | employees | 全部 | 全部 | 仅本部门 | 仅本部门 | 仅本部门 | 全部 | | products | 全部 | 全部 | — | — | 全部 | — | | suppliers | 全部 | 全部 | — | — | 全部 | — | | users | 全部 | — | — | — | — | — | -> **"—"** 表示该角色无此资源的权限,请求会被权限中间件直接拦截(返回 403)。 +> **"—"** 表示该部门无此资源的权限,请求会被 `checkPermission` 中间件直接拦截(返回 403)。 **隔离机制说明:** -- **负责人类资源**(customers、contracts、after_sales):通过 `responsible_user_id` 字段过滤,只能看到自己负责的记录 -- **合同类型过滤**(contracts):除了负责人类过滤,还按合同 `type` 字段过滤(franchise/supply) -- **部门类资源**(employees):通过 `department` 字段过滤,只能看到自己部门的员工 -- **全局类资源**(products、suppliers):无数据范围过滤,只要有权限就能看到全部 -**合同类型与角色限制:** -- 采购经理(`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 系统首次启动时自动创建以下账号,密码均为 `123456`: -| 用户名 | 姓名 | 角色 | 部门 | 职位 | -|--------|------|------|------|------| -| `admin` | 系统管理员 | admin | 信息技术部 | 系统管理员 | -| `zhangchao` | 张超 | general_manager | 总经理办公室 | 总经理 | -| `liming` | 李明 | franchise_manager | 招商部 | 招商经理 | -| `wangli` | 王丽 | operations_manager | 运营部 | 运营经理 | -| `zhaoqiang` | 赵强 | procurement_manager | 采购部 | 采购经理 | -| `chenfang` | 陈芳 | finance | 财务部 | 财务主管 | +| 用户名 | 姓名 | 部门标识 | 部门 | 职位 | +|--------|------|---------|------|------| +| `admin` | 系统管理员 | `admin` | 信息技术部 | 系统管理员 | +| `zhangchao` | 张超 | `general_manager` | 总经理办公室 | 总经理 | +| `liming` | 李明 | `franchise_manager` | 招商部 | 招商经理 | +| `wangli` | 王丽 | `operations_manager` | 运营部 | 运营经理 | +| `zhaoqiang` | 赵强 | `procurement_manager` | 采购部 | 采购经理 | +| `chenfang` | 陈芳 | `finance` | 财务部 | 财务主管 | + +**额外预置账号**(业务员工,同样密码 `123456`): + +| 用户名 | 姓名 | 部门 | 职位 | +|--------|------|------|------| +| `liuyang` | 刘洋 | 招商部 | 招商专员 | +| `sunting` | 孙婷 | 招商部 | 招商专员 | +| `zhoujie` | 周杰 | 运营部 | 运营专员 | +| `wumin` | 吴敏 | 运营部 | 运营专员 | +| `zhengwei` | 郑伟 | 运营部 | 售后工程师 | +| `huanglei` | 黄磊 | 采购部 | 采购专员 | +| `mali` | 马丽 | 财务部 | 会计 | +| `linfeng` | 林峰 | 总经理办公室 | 副总经理 | ### 快速测试 @@ -1145,7 +1311,7 @@ curl -X POST http://127.0.0.1:3000/api/user/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"123456"}' -# 以招商经理身份登录(只能看到自己负责的加盟商) +# 以招商经理身份登录(只能看到加盟合同) curl -X POST http://127.0.0.1:3000/api/user/login \ -H "Content-Type: application/json" \ -d '{"username":"liming","password":"123456"}' diff --git a/db.js b/db.js index 199e470..a46d845 100644 --- a/db.js +++ b/db.js @@ -40,9 +40,8 @@ async function initDB() { username VARCHAR(50) NOT NULL COMMENT '用户名 (登录账号)', password VARCHAR(255) NOT NULL COMMENT '登录密码 (应存储加密后的值)', is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '账号状态: 0-禁用, 1-启用', - role_id INT DEFAULT NULL COMMENT '角色ID (关联roles表)', + department_id INT DEFAULT NULL COMMENT '部门ID (关联departments表)', employee_id INT DEFAULT NULL COMMENT '员工ID (关联employees表)', - department VARCHAR(100) DEFAULT NULL COMMENT '所属部门 (从employees同步的冗余字段)', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (id), @@ -61,7 +60,6 @@ async function initDB() { address VARCHAR(200) DEFAULT NULL COMMENT '详细地址', email VARCHAR(100) DEFAULT NULL COMMENT '电子邮箱', remark TEXT DEFAULT NULL COMMENT '备注信息', - responsible_user_id INT DEFAULT NULL COMMENT '负责人用户ID (用于数据范围隔离)', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (id), @@ -170,18 +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] 数据表初始化完成') - // ============ 2.3 RBAC 权限相关表 ============ + // ============ 2.3 部门与权限相关表 ============ + // 迁移旧表 + await pool.query('DROP TABLE IF EXISTS role_permissions') + await pool.query('DROP TABLE IF EXISTS roles') + await pool.query(` - CREATE TABLE IF NOT EXISTS roles ( - id INT NOT NULL AUTO_INCREMENT COMMENT '角色ID (主键)', - name VARCHAR(50) NOT NULL COMMENT '角色标识 (英文)', - description VARCHAR(200) DEFAULT NULL COMMENT '角色描述 (中文)', + CREATE TABLE IF NOT EXISTS departments ( + id INT NOT NULL AUTO_INCREMENT COMMENT '部门ID (主键)', + name VARCHAR(50) NOT NULL COMMENT '部门标识 (英文)', + description VARCHAR(200) DEFAULT NULL COMMENT '部门名称 (中文)', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (id), - UNIQUE KEY uk_roles_name (name) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表' + UNIQUE KEY uk_departments_name (name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门表' `) await pool.query(` @@ -197,12 +207,12 @@ async function initDB() { `) await pool.query(` - CREATE TABLE IF NOT EXISTS role_permissions ( - role_id INT NOT NULL COMMENT '角色ID', + CREATE TABLE IF NOT EXISTS department_permissions ( + department_id INT NOT NULL COMMENT '部门ID', permission_id INT NOT NULL COMMENT '权限ID', - PRIMARY KEY (role_id, permission_id), - KEY idx_rp_permission_id (permission_id) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色-权限关联表' + PRIMARY KEY (department_id, permission_id), + KEY idx_dp_permission_id (permission_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门-权限关联表' `) await pool.query(` @@ -223,27 +233,28 @@ async function initDB() { `) // ============ 2.4 种子数据 ============ - await seedRolesAndPermissions() + await seedDepartmentsAndPermissions() await seedDefaultUsers() + await seedBusinessData() } -// ============ 3. 种子数据:角色与权限 ============ -async function seedRolesAndPermissions() { - const [roles] = await pool.query('SELECT id FROM roles LIMIT 1') - if (roles.length > 0) return // 已初始化过,跳过 +// ============ 3. 种子数据:部门与权限 ============ +async function seedDepartmentsAndPermissions() { + const [depts] = await pool.query('SELECT id FROM departments LIMIT 1') + if (depts.length > 0) return // 已初始化过,跳过 - const roleList = [ - { name: 'admin', description: '系统管理员' }, - { name: 'general_manager', description: '总经理' }, - { name: 'franchise_manager', description: '招商经理' }, - { name: 'operations_manager', description: '运营经理' }, - { name: 'procurement_manager', description: '采购经理' }, - { name: 'finance', description: '财务人员' }, + const deptList = [ + { name: 'admin', description: '信息技术部' }, + { name: 'general_manager', description: '总经理办公室' }, + { name: 'franchise_manager', description: '招商部' }, + { name: 'operations_manager', description: '运营部' }, + { name: 'procurement_manager', description: '采购部' }, + { name: 'finance', description: '财务部' }, ] - for (const r of roleList) { - await pool.query('INSERT INTO roles (name, description) VALUES (?, ?)', [r.name, r.description]) + for (const d of deptList) { + await pool.query('INSERT INTO departments (name, description) VALUES (?, ?)', [d.name, d.description]) } - console.log('[init] 已初始化 6 个角色') + console.log('[init] 已初始化 6 个部门') const permissionList = [ { name: 'customer:read', description: '查看加盟商', resource: 'customer', action: 'read' }, @@ -280,10 +291,10 @@ async function seedRolesAndPermissions() { } console.log('[init] 已初始化 25 个权限') - // 角色-权限映射 + // 部门-权限映射 const allPerms = permissionList.map(p => p.name) - const rolePermMap = { - admin: allPerms, // 管理员拥有所有权限 + const deptPermMap = { + admin: allPerms, general_manager: [ 'customer:read', 'contract:read', 'after_sale:read', 'product:read', 'supplier:read', 'employee:read', @@ -294,12 +305,12 @@ async function seedRolesAndPermissions() { 'employee:read', ], operations_manager: [ - 'customer:read', 'customer:update', - 'contract:read', + 'customer:read', 'after_sale:read', 'after_sale:create', 'after_sale:update', 'after_sale:delete', 'employee:read', ], procurement_manager: [ + 'customer:read', 'contract:read', 'contract:create', 'contract:update', 'contract:delete', 'product:read', 'product:create', 'product:update', 'product:delete', 'supplier:read', 'supplier:create', 'supplier:update', 'supplier:delete', @@ -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 roleIdMap = Object.fromEntries(roleRows.map(r => [r.name, r.id])) + const deptIdMap = Object.fromEntries(deptRows.map(d => [d.name, d.id])) const permIdMap = Object.fromEntries(permRows.map(p => [p.name, p.id])) - for (const [roleName, permNames] of Object.entries(rolePermMap)) { + for (const [deptName, permNames] of Object.entries(deptPermMap)) { for (const permName of permNames) { await pool.query( - 'INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)', - [roleIdMap[roleName], permIdMap[permName]] + 'INSERT IGNORE INTO department_permissions (department_id, permission_id) VALUES (?, ?)', + [deptIdMap[deptName], permIdMap[permName]] ) } } - console.log('[init] 已初始化角色-权限映射') + console.log('[init] 已初始化部门-权限映射') } // ============ 4. 种子数据:默认用户 ============ async function seedDefaultUsers() { const hash = await bcrypt.hash('123456', 10) - const [roleRows] = await pool.query('SELECT id, name FROM roles') - const roleIdMap = Object.fromEntries(roleRows.map(r => [r.name, r.id])) + const [deptRows] = await pool.query('SELECT id, name FROM departments') + const deptIdMap = Object.fromEntries(deptRows.map(d => [d.name, d.id])) const seedEmployees = [ { name: '张超', gender: '男', age: 42, education: '硕士', department: '总经理办公室', entry_date: '2015-03-01', position: '总经理', salary: 50000, phone: '13800001001', email: 'zhangchao@mixue.com' }, @@ -343,12 +354,12 @@ async function seedDefaultUsers() { ] const seedUsers = [ - { username: 'zhangchao', roleName: 'general_manager' }, - { username: 'liming', roleName: 'franchise_manager' }, - { username: 'wangli', roleName: 'operations_manager' }, - { username: 'zhaoqiang', roleName: 'procurement_manager' }, - { username: 'chenfang', roleName: 'finance' }, - { username: 'admin', roleName: 'admin' }, + { username: 'zhangchao', deptName: 'general_manager' }, + { username: 'liming', deptName: 'franchise_manager' }, + { username: 'wangli', deptName: 'operations_manager' }, + { username: 'zhaoqiang', deptName: 'procurement_manager' }, + { username: 'chenfang', deptName: 'finance' }, + { username: 'admin', deptName: 'admin' }, ] for (let i = 0; i < seedUsers.length; i++) { @@ -367,14 +378,471 @@ async function seedDefaultUsers() { ) const empId = empResult.insertId - // 再插入 users,关联 employee 和 role + // 再插入 users,关联 employee 和 department await pool.query( - `INSERT INTO users (username, password, is_active, role_id, employee_id, department) - VALUES (?, ?, 1, ?, ?, ?)`, - [usr.username, hash, roleIdMap[usr.roleName], empId, emp.department] + `INSERT INTO users (username, password, is_active, department_id, employee_id) + VALUES (?, ?, 1, ?, ?)`, + [usr.username, hash, deptIdMap[usr.deptName], empId] ) 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 } diff --git a/middleware/permissions.js b/middleware/permissions.js index f5e95b0..855a1d2 100644 --- a/middleware/permissions.js +++ b/middleware/permissions.js @@ -1,4 +1,4 @@ -// middleware/permissions.js —— RBAC 权限检查 + 数据范围过滤 +// middleware/permissions.js —— 接口权限 + 数据范围 + 合同类型校验 /** * 检查当前用户是否拥有指定权限 @@ -16,58 +16,81 @@ function checkPermission(resource, action) { } /** - * 根据当前用户角色,返回该资源的数据范围过滤条件 - * @param {object} user - req.user(含 roleName, id, department) - * @param {string} resource - 资源名:customers / contracts / after_sales / products / suppliers / employees / users - * @returns {{ where?: string, values?: any[], deny?: boolean }} - * - where + values:追加到 SQL WHERE 子句的条件 - * - deny:true 表示该角色无权访问此资源 + * 根据用户部门计算数据范围,挂到 req.scope + * @param {string} resource - 资源名:customers / contracts / after_sales / employees */ -function getDataScope(user, resource) { - const { roleName, id: userId, department } = user +function dataScope(resource) { + return (req, res, next) => { + const deptName = req.user.departmentName // 如 'franchise_manager' - // admin 和 general_manager(总经理):全量数据,无限制 - if (roleName === 'admin' || roleName === 'general_manager') { - return {} - } + // 信息技术部和总经理办公室:全量数据 + if (deptName === 'admin' || deptName === 'general_manager') { + return next() + } - switch (resource) { - case 'customers': - if (roleName === 'finance') return {} - return { where: 'responsible_user_id = ?', values: [userId] } + let sql = null, params = [] - case 'contracts': - if (roleName === 'finance') return {} - if (roleName === 'procurement_manager') { - return { where: 'type = ?', values: ['supply'] } - } - if (roleName === 'franchise_manager' || roleName === 'operations_manager') { - return { where: 'responsible_user_id = ? AND type = ?', values: [userId, 'franchise'] } - } - return {} + switch (resource) { + case 'customers': + // 过渡期:招商部和运营部也能看全量加盟商 + break - case 'after_sales': - if (roleName === 'finance') return {} - if (roleName === 'operations_manager') { - return { where: 'responsible_user_id = ?', values: [userId], tableAlias: 'a' } - } - // franchise_manager 和 procurement_manager 无权限(checkPermission 已拦截) - return {} + case 'contracts': + if (deptName === 'franchise_manager') { + sql = 'AND type = ?' + params = ['franchise'] + } else if (deptName === 'procurement_manager') { + sql = 'AND type = ?' + params = ['supply'] + } + // finance 全量看;operations_manager 无合同权限(checkPermission 已拦截) + break - case 'employees': - if (roleName === 'finance') return {} - // 所有非管理员/总经理角色:只能看自己部门的员工 - return { where: 'department = ?', values: [department] } + case 'after_sales': + // 过渡期:运营部看全量售后 + break - case 'products': - case 'suppliers': - case 'users': - // 无数据范围过滤,仅靠 checkPermission 控制访问 - return {} + case 'employees': + if (['franchise_manager', 'operations_manager', 'procurement_manager'].includes(deptName)) { + sql = 'AND department = ?' + params = [req.user.departmentDesc] // 如 '招商部' + } + // finance 全量看员工 + break + } - default: - return {} + if (sql) { + req.scope = { sql, params } + } + next() } } -module.exports = { checkPermission, getDataScope } +/** + * 合同创建/修改时校验类型是否匹配部门 + * 招商部只能操作加盟合同,采购部只能操作采购合同 + */ +function validateContractType(req, res, next) { + const deptName = req.user.departmentName + const type = req.body.type + + // 未传 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 } diff --git a/routes/afterSales.js b/routes/afterSales.js index e37d655..6d19917 100644 --- a/routes/afterSales.js +++ b/routes/afterSales.js @@ -1,6 +1,5 @@ -// routes/afterSales.js —— 售后管理 CRUD +// routes/afterSales.js —— 售后管理 CRUD(纯数据库操作,权限由中间件层控制) const { pool } = require('../db') -const { getDataScope } = require('../middleware/permissions') // 格式化日期为 YYYY-MM-DD function formatDate(dateStr) { @@ -46,16 +45,10 @@ async function list(req, res) { let where = 'WHERE 1=1' const params = [] - // 数据范围过滤 - const scope = getDataScope(req.user, 'after_sales') - if (scope.deny) { - return res.status(403).json({ code: 403, message: '无权访问此资源' }) - } - if (scope.where) { - // list 查询有 JOIN,需要表别名前缀避免列名歧义 - const scopeCol = scope.tableAlias ? scope.where.replace(/\bresponsible_user_id\b/g, `${scope.tableAlias}.responsible_user_id`) : scope.where - where += ' AND ' + scopeCol - params.push(...scope.values) + // 数据范围过滤(由中间件注入 req.scope,已含表别名前缀) + if (req.scope && req.scope.sql) { + where += ' ' + req.scope.sql + params.push(...req.scope.params) } if (id) { @@ -115,14 +108,10 @@ async function detail(req, res) { let sql = `${LIST_SELECT} WHERE a.id = ?` const params = [req.params.id] - const scope = getDataScope(req.user, 'after_sales') - if (scope.deny) { - return res.status(403).json({ code: 403, message: '无权访问此资源' }) - } - if (scope.where) { - const scopeCol = scope.tableAlias ? scope.where.replace('responsible_user_id', `${scope.tableAlias}.responsible_user_id`) : scope.where - sql += ' AND ' + scopeCol - params.push(...scope.values) + // 数据范围过滤(由中间件注入 req.scope,已含表别名前缀) + if (req.scope && req.scope.sql) { + sql += ' ' + req.scope.sql + params.push(...req.scope.params) } const [rows] = await pool.query(sql, params) @@ -140,7 +129,7 @@ async function detail(req, res) { async function create(req, res) { const { customer_id, feedback, employee_id, handle_method, - handle_status, service_date, remark, responsible_user_id, + handle_status, service_date, remark, } = req.body || {} if (!customer_id) { @@ -156,17 +145,20 @@ async function create(req, res) { if (cust.length === 0) { return res.status(400).json({ code: 400, message: '关联客户不存在' }) } - // 校验业务员 + // 校验业务员,并通过 employee_id 自动推导负责人 + let ownerId = req.user.id if (employee_id) { const [emp] = await pool.query('SELECT id FROM employees WHERE id = ?', [employee_id]) if (emp.length === 0) { return res.status(400).json({ code: 400, message: '关联业务员不存在' }) } + // 业务员即负责人:查找该员工对应的系统用户 + const [userRows] = await pool.query('SELECT id FROM users WHERE employee_id = ? LIMIT 1', [employee_id]) + if (userRows.length > 0) { + ownerId = userRows[0].id + } } - // 如果没指定负责人,默认设为当前用户 - const ownerId = responsible_user_id || req.user.id - const [result] = await pool.query( `INSERT INTO after_sales (customer_id, feedback, employee_id, handle_method, handle_status, service_date, remark, responsible_user_id) @@ -198,15 +190,11 @@ async function update(req, res) { try { // 确认记录存在且在数据范围内 - let checkSql = 'SELECT id FROM after_sales WHERE id = ?' - const checkParams = [id] - const scope = getDataScope(req.user, 'after_sales') - if (scope.deny) { - return res.status(403).json({ code: 403, message: '无权访问此资源' }) - } - if (scope.where) { - checkSql += ' AND ' + scope.where - checkParams.push(...scope.values) + let checkSql = 'SELECT id, employee_id FROM after_sales WHERE id = ?' + let checkParams = [id] + if (req.scope && req.scope.sql) { + checkSql += ' ' + req.scope.sql + checkParams.push(...req.scope.params) } const [existing] = await pool.query(checkSql, checkParams) if (existing.length === 0) { @@ -226,6 +214,22 @@ async function update(req, res) { } } + // 业务员即负责人:当 employee_id 变更时自动推导 responsible_user_id + if (req.body.employee_id !== undefined) { + if (req.body.employee_id) { + const [userRows] = await pool.query('SELECT id FROM users WHERE employee_id = ? LIMIT 1', [req.body.employee_id]) + if (userRows.length > 0) { + req.body.responsible_user_id = userRows[0].id + } else { + // 该员工无对应系统用户时,设为当前登录用户 + req.body.responsible_user_id = req.user.id + } + } else { + // 业务员被清空时,清空负责人 + req.body.responsible_user_id = null + } + } + const sets = [] const params = [] for (const f of fields) { @@ -259,14 +263,10 @@ async function remove(req, res) { const { id } = req.params try { let checkSql = 'SELECT id FROM after_sales WHERE id = ?' - const checkParams = [id] - const scope = getDataScope(req.user, 'after_sales') - if (scope.deny) { - return res.status(403).json({ code: 403, message: '无权访问此资源' }) - } - if (scope.where) { - checkSql += ' AND ' + scope.where - checkParams.push(...scope.values) + let checkParams = [id] + if (req.scope && req.scope.sql) { + checkSql += ' ' + req.scope.sql + checkParams.push(...req.scope.params) } const [existing] = await pool.query(checkSql, checkParams) if (existing.length === 0) { diff --git a/routes/contracts.js b/routes/contracts.js index 18d035b..168b33f 100644 --- a/routes/contracts.js +++ b/routes/contracts.js @@ -1,6 +1,5 @@ -// routes/contracts.js —— 合同管理 CRUD +// routes/contracts.js —— 合同管理 CRUD(纯数据库操作,权限由中间件层控制) const { pool } = require('../db') -const { getDataScope } = require('../middleware/permissions') // 格式化日期为 YYYY-MM-DD function formatDate(dateStr) { @@ -55,16 +54,10 @@ async function list(req, res) { let where = 'WHERE 1=1' const params = [] - // 数据范围过滤(列表查询使用表别名 c,需要加前缀) - const scope = getDataScope(req.user, 'contracts') - if (scope.deny) { - return res.status(403).json({ code: 403, message: '无权访问此资源' }) - } - if (scope.where) { - // 给 scope.where 中的字段加上表别名 c. - const scopedWhere = scope.where.replace(/\b(responsible_user_id|type)\b/g, 'c.$1') - where += ' AND ' + scopedWhere - params.push(...scope.values) + // 数据范围过滤(由中间件注入 req.scope,无别名;list/detail 需机械加 c. 前缀) + if (req.scope && req.scope.sql) { + where += ' ' + req.scope.sql.replace(/\btype\b/g, 'c.type') + params.push(...req.scope.params) } if (id) { @@ -139,15 +132,10 @@ async function detail(req, res) { let sql = `${DETAIL_SELECT} WHERE c.id = ?` const params = [req.params.id] - const scope = getDataScope(req.user, 'contracts') - if (scope.deny) { - return res.status(403).json({ code: 403, message: '无权访问此资源' }) - } - if (scope.where) { - // detail 查询使用表别名 c,需要给 scope.where 加上前缀 - const scopedWhere = scope.where.replace(/\b(responsible_user_id|type)\b/g, 'c.$1') - sql += ' AND ' + scopedWhere - params.push(...scope.values) + // 数据范围过滤(由中间件注入 req.scope,无别名;detail 需机械加 c. 前缀) + if (req.scope && req.scope.sql) { + sql += ' ' + req.scope.sql.replace(/\btype\b/g, 'c.type') + params.push(...req.scope.params) } const [rows] = await pool.query(sql, params) @@ -168,14 +156,8 @@ async function create(req, res) { amount, effective_date, expiry_date, employee_id, status, remark, } = req.body || {} - // 根据角色限制合同类型 - const contractType = req.body.type || (req.user.roleName === 'procurement_manager' ? 'supply' : 'franchise') - if (req.user.roleName === 'procurement_manager' && contractType !== 'supply') { - return res.status(403).json({ code: 403, message: '采购经理只能创建采购合同' }) - } - if (req.user.roleName === 'franchise_manager' && contractType !== 'franchise') { - return res.status(403).json({ code: 403, message: '招商经理只能创建加盟合同' }) - } + // 合同类型默认值(类型校验由中间件层控制) + const contractType = req.body.type || 'franchise' if (contractType === 'supply' && !supplier_id) { return res.status(400).json({ code: 400, message: '采购合同必须选择供应商' }) @@ -202,12 +184,18 @@ async function create(req, res) { return res.status(400).json({ code: 400, message: '关联供应商不存在' }) } } - // 校验业务员(如果填了) + // 校验业务员(如果填了),并通过 employee_id 自动推导负责人 + let responsibleUserId = req.user.id if (employee_id) { const [emp] = await pool.query('SELECT id FROM employees WHERE id = ?', [employee_id]) if (emp.length === 0) { return res.status(400).json({ code: 400, message: '关联业务员不存在' }) } + // 业务员即负责人:查找该员工对应的系统用户 + const [userRows] = await pool.query('SELECT id FROM users WHERE employee_id = ? LIMIT 1', [employee_id]) + if (userRows.length > 0) { + responsibleUserId = userRows[0].id + } } const [result] = await pool.query( @@ -224,7 +212,7 @@ async function create(req, res) { status || '生效中', remark || null, contractType, - req.body.responsible_user_id || req.user.id] + responsibleUserId] ) const [rows] = await pool.query(`${DETAIL_SELECT} WHERE c.id = ?`, [result.insertId]) @@ -240,20 +228,17 @@ async function update(req, res) { const { id } = req.params const fields = [ 'customer_id', 'supplier_id', 'contract_name', 'contract_no', 'contract_content', - 'amount', 'effective_date', 'expiry_date', 'employee_id', 'status', 'remark', 'type', 'responsible_user_id', + 'amount', 'effective_date', 'expiry_date', 'employee_id', 'status', 'remark', 'type', + 'responsible_user_id', ] try { // 确认记录存在且在数据范围内(detail/update/delete 不用表别名) - let checkSql = 'SELECT id FROM contracts WHERE id = ?' - const checkParams = [id] - const scope = getDataScope(req.user, 'contracts') - if (scope.deny) { - return res.status(403).json({ code: 403, message: '无权访问此资源' }) - } - if (scope.where) { - checkSql += ' AND ' + scope.where - checkParams.push(...scope.values) + let checkSql = 'SELECT id, employee_id FROM contracts WHERE id = ?' + let checkParams = [id] + if (req.scope && req.scope.sql) { + checkSql += ' ' + req.scope.sql + checkParams.push(...req.scope.params) } const [existing] = await pool.query(checkSql, checkParams) if (existing.length === 0) { @@ -273,6 +258,22 @@ async function update(req, res) { return res.status(400).json({ code: 400, message: '关联业务员不存在' }) } } + + // 业务员即负责人:当 employee_id 变更时自动推导 responsible_user_id + if (req.body.employee_id !== undefined) { + if (req.body.employee_id) { + const [userRows] = await pool.query('SELECT id FROM users WHERE employee_id = ? LIMIT 1', [req.body.employee_id]) + if (userRows.length > 0) { + req.body.responsible_user_id = userRows[0].id + } else { + // 该员工无对应系统用户时,设为当前登录用户 + req.body.responsible_user_id = req.user.id + } + } else { + // 业务员被清空时,清空负责人 + req.body.responsible_user_id = null + } + } if (req.body.supplier_id) { const [sup] = await pool.query('SELECT id FROM suppliers WHERE id = ?', [req.body.supplier_id]) if (sup.length === 0) { @@ -313,14 +314,10 @@ async function remove(req, res) { const { id } = req.params try { let checkSql = 'SELECT id FROM contracts WHERE id = ?' - const checkParams = [id] - const scope = getDataScope(req.user, 'contracts') - if (scope.deny) { - return res.status(403).json({ code: 403, message: '无权访问此资源' }) - } - if (scope.where) { - checkSql += ' AND ' + scope.where - checkParams.push(...scope.values) + let checkParams = [id] + if (req.scope && req.scope.sql) { + checkSql += ' ' + req.scope.sql + checkParams.push(...req.scope.params) } const [existing] = await pool.query(checkSql, checkParams) if (existing.length === 0) { diff --git a/routes/customers.js b/routes/customers.js index eb65a52..fb274f1 100644 --- a/routes/customers.js +++ b/routes/customers.js @@ -1,6 +1,5 @@ -// routes/customers.js —— 客户管理 CRUD +// routes/customers.js —— 客户管理 CRUD(纯数据库操作,权限由中间件层控制) const { pool } = require('../db') -const { getDataScope } = require('../middleware/permissions') // 提取分页参数 function pagination(query) { @@ -19,14 +18,10 @@ async function list(req, res) { let where = 'WHERE 1=1' const params = [] - // 数据范围过滤 - const scope = getDataScope(req.user, 'customers') - if (scope.deny) { - return res.status(403).json({ code: 403, message: '无权访问此资源' }) - } - if (scope.where) { - where += ' AND ' + scope.where - params.push(...scope.values) + // 数据范围过滤(由中间件注入 req.scope) + if (req.scope && req.scope.sql) { + where += ' ' + req.scope.sql + params.push(...req.scope.params) } if (id) { @@ -56,12 +51,10 @@ async function list(req, res) { params ) - // 查分页数据(JOIN users+employees 获取负责人姓名) + // 查分页数据 const [rows] = await pool.query( - `SELECT c.*, e.name AS responsible_user_name + `SELECT c.* FROM customers c - LEFT JOIN users u ON c.responsible_user_id = u.id - LEFT JOIN employees e ON u.employee_id = e.id ${where} ORDER BY c.id DESC LIMIT ? OFFSET ?`, [...params, pageSize, offset] ) @@ -89,13 +82,9 @@ async function detail(req, res) { let sql = 'SELECT * FROM customers WHERE id = ?' const params = [req.params.id] - const scope = getDataScope(req.user, 'customers') - if (scope.deny) { - return res.status(403).json({ code: 403, message: '无权访问此资源' }) - } - if (scope.where) { - sql += ' AND ' + scope.where - params.push(...scope.values) + if (req.scope && req.scope.sql) { + sql += ' ' + req.scope.sql + params.push(...req.scope.params) } const [rows] = await pool.query(sql, params) @@ -113,7 +102,7 @@ async function detail(req, res) { async function create(req, res) { const { name, phone, province, city, district, - address, email, remark, responsible_user_id, + address, email, remark, } = req.body || {} if (!name) { @@ -121,14 +110,11 @@ async function create(req, res) { } try { - // 如果没指定负责人,默认设为当前用户 - const ownerId = responsible_user_id || req.user.id - const [result] = await pool.query( - `INSERT INTO customers (name, phone, province, city, district, address, email, remark, responsible_user_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO customers (name, phone, province, city, district, address, email, remark) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [name, phone || null, province || null, city || null, district || null, - address || null, email || null, remark || null, ownerId] + address || null, email || null, remark || null] ) const [rows] = await pool.query('SELECT * FROM customers WHERE id = ?', [result.insertId]) res.json({ code: 0, message: 'ok', data: rows[0] }) @@ -143,20 +129,16 @@ async function update(req, res) { const { id } = req.params const fields = [ 'name', 'phone', 'province', 'city', 'district', - 'address', 'email', 'remark', 'responsible_user_id', + 'address', 'email', 'remark', ] try { // 确认记录存在且在数据范围内 let checkSql = 'SELECT id FROM customers WHERE id = ?' - const checkParams = [id] - const scope = getDataScope(req.user, 'customers') - if (scope.deny) { - return res.status(403).json({ code: 403, message: '无权访问此资源' }) - } - if (scope.where) { - checkSql += ' AND ' + scope.where - checkParams.push(...scope.values) + let checkParams = [id] // let, 后续可能 push scope.params + if (req.scope && req.scope.sql) { + checkSql += ' ' + req.scope.sql + checkParams.push(...req.scope.params) } const [existing] = await pool.query(checkSql, checkParams) if (existing.length === 0) { @@ -192,14 +174,10 @@ async function remove(req, res) { const { id } = req.params try { let checkSql = 'SELECT id FROM customers WHERE id = ?' - const checkParams = [id] - const scope = getDataScope(req.user, 'customers') - if (scope.deny) { - return res.status(403).json({ code: 403, message: '无权访问此资源' }) - } - if (scope.where) { - checkSql += ' AND ' + scope.where - checkParams.push(...scope.values) + let checkParams = [id] + if (req.scope && req.scope.sql) { + checkSql += ' ' + req.scope.sql + checkParams.push(...req.scope.params) } const [existing] = await pool.query(checkSql, checkParams) if (existing.length === 0) { @@ -216,4 +194,17 @@ async function remove(req, res) { } } -module.exports = { list, detail, create, update, remove } +// GET /api/customers/simple —— 简易加盟商列表(无数据范围限制,用于下拉选择) +async function simpleList(req, res) { + try { + const [rows] = await pool.query( + 'SELECT id, name FROM customers ORDER BY id' + ) + res.json({ code: 0, message: 'ok', data: rows }) + } catch (e) { + console.error('[customers simpleList] error:', e) + res.status(500).json({ code: 500, message: e.message }) + } +} + +module.exports = { list, detail, create, update, remove, simpleList } diff --git a/routes/employees.js b/routes/employees.js index 992d43f..3144922 100644 --- a/routes/employees.js +++ b/routes/employees.js @@ -1,6 +1,6 @@ -// routes/employees.js —— 员工管理 CRUD +// routes/employees.js —— 员工管理 CRUD(纯数据库操作,权限由中间件层控制) const { pool } = require('../db') -const { getDataScope } = require('../middleware/permissions') +const bcrypt = require('bcryptjs') // 格式化日期为 YYYY-MM-DD function formatDate(dateStr) { @@ -36,14 +36,10 @@ async function list(req, res) { let where = 'WHERE 1=1' const params = [] - // 数据范围过滤(部门隔离) - const scope = getDataScope(req.user, 'employees') - if (scope.deny) { - return res.status(403).json({ code: 403, message: '无权访问此资源' }) - } - if (scope.where) { - where += ' AND ' + scope.where - params.push(...scope.values) + // 数据范围过滤(由中间件注入 req.scope) + if (req.scope && req.scope.sql) { + where += ' ' + req.scope.sql + params.push(...req.scope.params) } if (id) { @@ -96,13 +92,10 @@ async function detail(req, res) { let sql = 'SELECT * FROM employees WHERE id = ?' const params = [req.params.id] - const scope = getDataScope(req.user, 'employees') - if (scope.deny) { - return res.status(403).json({ code: 403, message: '无权访问此资源' }) - } - if (scope.where) { - sql += ' AND ' + scope.where - params.push(...scope.values) + // 数据范围过滤(由中间件注入 req.scope) + if (req.scope && req.scope.sql) { + sql += ' ' + req.scope.sql + params.push(...req.scope.params) } const [rows] = await pool.query(sql, params) @@ -145,6 +138,18 @@ async function create(req, res) { remark || null] ) const [rows] = await pool.query('SELECT * FROM employees WHERE id = ?', [result.insertId]) + + // 如果中间件 allowUserCreation 要求同步创建用户账号 + if (req._createUser) { + const { username, password, department_id } = 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] }) } catch (e) { console.error('[employees create] error:', e) @@ -163,14 +168,10 @@ async function update(req, res) { try { // 确认记录存在且在数据范围内 let checkSql = 'SELECT id FROM employees WHERE id = ?' - const checkParams = [id] - const scope = getDataScope(req.user, 'employees') - if (scope.deny) { - return res.status(403).json({ code: 403, message: '无权访问此资源' }) - } - if (scope.where) { - checkSql += ' AND ' + scope.where - checkParams.push(...scope.values) + let checkParams = [id] + if (req.scope && req.scope.sql) { + checkSql += ' ' + req.scope.sql + checkParams.push(...req.scope.params) } const [existing] = await pool.query(checkSql, checkParams) if (existing.length === 0) { @@ -197,11 +198,6 @@ async function update(req, res) { params.push(id) await pool.query(`UPDATE employees SET ${sets.join(', ')} WHERE id = ?`, params) - // 同步 department 到关联的 users 表 - if (req.body.department !== undefined) { - await pool.query('UPDATE users SET department = ? WHERE employee_id = ?', [req.body.department, id]) - } - const [rows] = await pool.query('SELECT * FROM employees WHERE id = ?', [id]) res.json({ code: 0, message: 'ok', data: rows[0] }) } catch (e) { @@ -215,14 +211,10 @@ async function remove(req, res) { const { id } = req.params try { let checkSql = 'SELECT id FROM employees WHERE id = ?' - const checkParams = [id] - const scope = getDataScope(req.user, 'employees') - if (scope.deny) { - return res.status(403).json({ code: 403, message: '无权访问此资源' }) - } - if (scope.where) { - checkSql += ' AND ' + scope.where - checkParams.push(...scope.values) + let checkParams = [id] + if (req.scope && req.scope.sql) { + checkSql += ' ' + req.scope.sql + checkParams.push(...req.scope.params) } const [existing] = await pool.query(checkSql, checkParams) if (existing.length === 0) { diff --git a/routes/suppliers.js b/routes/suppliers.js index 7de150f..aaf0130 100644 --- a/routes/suppliers.js +++ b/routes/suppliers.js @@ -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 } diff --git a/routes/users.js b/routes/users.js index f4c5fcc..b54f5a8 100644 --- a/routes/users.js +++ b/routes/users.js @@ -3,14 +3,14 @@ const jwt = require('jsonwebtoken') const bcrypt = require('bcryptjs') const { pool } = require('../db') -// 安全字段:关联查询 roles 和 employees +// 安全字段:关联查询 departments 和 employees const USER_LIST_SQL = ` - SELECT u.id, u.username, u.is_active, u.role_id, u.employee_id, u.department, + SELECT u.id, u.username, u.is_active, u.department_id, u.employee_id, u.created_at, u.updated_at, - r.name AS role_name, r.description AS role_description, + d.name AS dept_name, d.description AS dept_desc, e.name AS real_name FROM users u - LEFT JOIN roles r ON u.role_id = r.id + LEFT JOIN departments d ON u.department_id = d.id LEFT JOIN employees e ON u.employee_id = e.id ` @@ -32,10 +32,10 @@ async function login(req, res) { try { const [rows] = await pool.query( - `SELECT u.id, u.username, u.password, u.is_active, u.role_id, u.department, u.employee_id, - r.name AS role_name, e.name AS real_name + `SELECT u.id, u.username, u.password, u.is_active, u.department_id, u.employee_id, + d.name AS dept_name, d.description AS dept_desc, e.name AS real_name FROM users u - LEFT JOIN roles r ON u.role_id = r.id + LEFT JOIN departments d ON u.department_id = d.id LEFT JOIN employees e ON u.employee_id = e.id WHERE u.username = ?`, [username] @@ -56,12 +56,12 @@ async function login(req, res) { return res.status(400).json({ code: 400, message: '账号或密码错误' }) } - // 查询该角色的所有权限标识 + // 查询该部门的所有权限标识 const [perms] = await pool.query( `SELECT p.name FROM permissions p - JOIN role_permissions rp ON p.id = rp.permission_id - WHERE rp.role_id = ?`, - [user.role_id] + JOIN department_permissions dp ON p.id = dp.permission_id + WHERE dp.department_id = ?`, + [user.department_id] ) const permissions = perms.map(p => p.name) @@ -70,9 +70,9 @@ async function login(req, res) { id: user.id, username: user.username, name: user.real_name || user.username, - role_id: user.role_id, - roleName: user.role_name, - department: user.department, + department_id: user.department_id, + departmentName: user.dept_name, + departmentDesc: user.dept_desc, permissions, }, process.env.JWT_SECRET, @@ -88,9 +88,9 @@ async function login(req, res) { id: user.id, username: user.username, name: user.real_name || user.username, - role_id: user.role_id, - roleName: user.role_name, - department: user.department, + department_id: user.department_id, + departmentName: user.dept_name, + departmentDesc: user.dept_desc, permissions, }, }, @@ -177,7 +177,7 @@ async function changePassword(req, res) { async function list(req, res) { try { const { page, pageSize, offset } = pagination(req.query) - const { username, real_name, id, role_id, is_active } = req.query + const { username, real_name, id, department_id, is_active } = req.query let where = 'WHERE 1=1' const params = [] @@ -194,9 +194,9 @@ async function list(req, res) { where += ' AND u.id = ?' params.push(Number(id)) } - if (role_id) { - where += ' AND u.role_id = ?' - params.push(Number(role_id)) + if (department_id) { + where += ' AND u.department_id = ?' + params.push(Number(department_id)) } if (is_active !== undefined && is_active !== '') { where += ' AND u.is_active = ?' @@ -251,7 +251,7 @@ async function detail(req, res) { // POST /api/users —— 创建用户 async function create(req, res) { - const { username, password, role_id, employee_id, department, is_active = 1 } = req.body || {} + const { username, password, department_id, employee_id, is_active = 1 } = req.body || {} if (!username || !password) { 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 位' }) } - // 校验 role_id 是否存在 - if (role_id) { - const [role] = await pool.query('SELECT id FROM roles WHERE id = ?', [role_id]) - if (role.length === 0) { - return res.status(400).json({ code: 400, message: '指定的角色不存在' }) + // 校验 department_id 是否存在 + if (department_id) { + const [dept] = await pool.query('SELECT id FROM departments WHERE id = ?', [department_id]) + if (dept.length === 0) { + return res.status(400).json({ code: 400, message: '指定的部门不存在' }) } } // 校验 employee_id 是否存在 if (employee_id) { - const [emp] = await pool.query('SELECT id, department FROM employees WHERE id = ?', [employee_id]) + const [emp] = await pool.query('SELECT id FROM employees WHERE id = ?', [employee_id]) if (emp.length === 0) { return res.status(400).json({ code: 400, message: '指定的员工不存在' }) } - // 如果没传 department,自动从员工表同步 - if (!department) { - req.body.department = emp[0].department - } } try { const hash = await bcrypt.hash(password, 10) const [result] = await pool.query( - 'INSERT INTO users (username, password, is_active, role_id, employee_id, department) VALUES (?, ?, ?, ?, ?, ?)', - [username, hash, is_active, role_id || null, employee_id || null, req.body.department || department || null] + 'INSERT INTO users (username, password, is_active, department_id, employee_id) VALUES (?, ?, ?, ?, ?)', + [username, hash, is_active, department_id || null, employee_id || null] ) const [rows] = await pool.query( @@ -307,42 +303,30 @@ async function create(req, res) { // PUT /api/users/:id —— 更新用户 async function update(req, res) { const { id } = req.params - const fields = ['username', 'role_id', 'employee_id', 'department', 'is_active'] + const fields = ['username', 'department_id', 'employee_id', 'is_active'] 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) { return res.status(404).json({ code: 404, message: '用户不存在' }) } - // 不允许修改自己的角色或禁用自己 - if (Number(id) === req.user.id) { - if (req.body.role_id !== undefined && req.body.role_id !== req.user.role_id) { - return res.status(400).json({ code: 400, message: '不能修改自己的角色' }) - } - if (req.body.is_active === 0) { - return res.status(400).json({ code: 400, message: '不能禁用自己' }) - } - } + // 不允许修改自己的部门或禁用自己(此检查已移至中间件 protectSelfUpdate) - // 校验 role_id - if (req.body.role_id) { - const [role] = await pool.query('SELECT id FROM roles WHERE id = ?', [req.body.role_id]) - if (role.length === 0) { - return res.status(400).json({ code: 400, message: '指定的角色不存在' }) + // 校验 department_id + if (req.body.department_id) { + const [dept] = await pool.query('SELECT id FROM departments WHERE id = ?', [req.body.department_id]) + if (dept.length === 0) { + return res.status(400).json({ code: 400, message: '指定的部门不存在' }) } } // 校验 employee_id if (req.body.employee_id) { - const [emp] = await pool.query('SELECT id, department FROM employees WHERE id = ?', [req.body.employee_id]) + const [emp] = await pool.query('SELECT id FROM employees WHERE id = ?', [req.body.employee_id]) if (emp.length === 0) { return res.status(400).json({ code: 400, message: '指定的员工不存在' }) } - // 自动同步 department - if (req.body.department === undefined) { - req.body.department = emp[0].department - } } const sets = [] @@ -390,24 +374,12 @@ async function update(req, res) { async function remove(req, res) { const { id } = req.params try { - if (Number(id) === req.user.id) { - return res.status(400).json({ code: 400, message: '不能删除自己' }) - } - - const [existing] = await pool.query('SELECT id, role_id FROM users WHERE id = ?', [id]) + // 不能删除自己/最后一个管理员(此检查已移至中间件 protectUserDelete) + const [existing] = await pool.query('SELECT id FROM users WHERE id = ?', [id]) if (existing.length === 0) { return res.status(404).json({ code: 404, message: '用户不存在' }) } - // 禁止删除最后一个管理员 - const [adminRole] = await pool.query('SELECT id FROM roles WHERE name = ?', ['admin']) - if (adminRole.length > 0 && existing[0].role_id === adminRole[0].id) { - const [[{ cnt }]] = await pool.query('SELECT COUNT(*) AS cnt FROM users WHERE role_id = ?', [adminRole[0].id]) - if (cnt <= 1) { - return res.status(400).json({ code: 400, message: '不能删除最后一个管理员' }) - } - } - await pool.query('DELETE FROM users WHERE id = ?', [id]) res.json({ code: 0, message: 'ok' }) } catch (e) { diff --git a/server.js b/server.js index 3c52322..646760c 100644 --- a/server.js +++ b/server.js @@ -11,7 +11,11 @@ const contracts = require('./routes/contracts') const afterSales = require('./routes/afterSales') const products = require('./routes/products') const suppliers = require('./routes/suppliers') -const { checkPermission } = require('./middleware/permissions') + +// 导入中间件 +const { checkPermission, dataScope, validateContractType } = require('./middleware/permissions') +const { protectSelfUpdate, protectUserDelete } = require('./middleware/users') +const { allowUserCreation } = require('./middleware/employees') const app = express() app.use(express.json()) // 解析 application/json 请求体 @@ -35,7 +39,7 @@ function auth(req, res, next) { // ============ 登录/登出/个人信息 ============ app.post('/api/user/login', users.login) app.get('/api/user/info', auth, users.info) -app.get('/api/user/list', auth, checkPermission('user', 'manage'), users.simpleList) +app.get('/api/user/list', auth, users.simpleList) app.post('/api/user/logout', auth, users.logout) app.put('/api/user/password', auth, users.changePassword) @@ -43,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/:id', auth, checkPermission('user', 'manage'), users.detail) app.post('/api/users', auth, checkPermission('user', 'manage'), users.create) -app.put('/api/users/:id', auth, checkPermission('user', 'manage'), users.update) -app.delete('/api/users/:id', auth, checkPermission('user', 'manage'), users.remove) +app.put('/api/users/:id', auth, checkPermission('user', 'manage'), protectSelfUpdate, users.update) +app.delete('/api/users/:id', auth, checkPermission('user', 'manage'), protectUserDelete, users.remove) // ============ 加盟商管理 /api/customers ============ -app.get('/api/customers', auth, checkPermission('customer', 'read'), customers.list) -app.get('/api/customers/:id', auth, checkPermission('customer', 'read'), customers.detail) -app.post('/api/customers', auth, checkPermission('customer', 'create'), customers.create) -app.put('/api/customers/:id', auth, checkPermission('customer', 'update'), customers.update) -app.delete('/api/customers/:id', auth, checkPermission('customer', 'delete'), customers.remove) +app.get('/api/customers/simple', auth, checkPermission('customer', 'read'), customers.simpleList) +app.get('/api/customers', auth, checkPermission('customer', 'read'), dataScope('customers'), customers.list) +app.get('/api/customers/:id', auth, checkPermission('customer', 'read'), dataScope('customers'), customers.detail) +app.post('/api/customers', auth, checkPermission('customer', 'create'), customers.create) +app.put('/api/customers/:id', auth, checkPermission('customer', 'update'),dataScope('customers'), customers.update) +app.delete('/api/customers/:id', auth, checkPermission('customer', 'delete'),dataScope('customers'), customers.remove) // ============ 员工管理 /api/employees ============ -app.get('/api/employees/simple', auth, employees.simpleList) -app.get('/api/employees', auth, checkPermission('employee', 'read'), employees.list) -app.get('/api/employees/:id', auth, checkPermission('employee', 'read'), employees.detail) -app.post('/api/employees', auth, checkPermission('employee', 'create'), employees.create) -app.put('/api/employees/:id', auth, checkPermission('employee', 'update'), employees.update) -app.delete('/api/employees/:id', auth, checkPermission('employee', 'delete'), employees.remove) +app.get('/api/employees/simple', auth, employees.simpleList) +app.get('/api/employees', auth, checkPermission('employee', 'read'), dataScope('employees'), employees.list) +app.get('/api/employees/:id', auth, checkPermission('employee', 'read'), dataScope('employees'), employees.detail) +app.post('/api/employees', auth, checkPermission('employee', 'create'),allowUserCreation, employees.create) +app.put('/api/employees/:id', auth, checkPermission('employee', 'update'),dataScope('employees'), employees.update) +app.delete('/api/employees/:id', auth, checkPermission('employee', 'delete'),dataScope('employees'), employees.remove) // ============ 合同管理 /api/contracts ============ -app.get('/api/contracts', auth, checkPermission('contract', 'read'), contracts.list) -app.get('/api/contracts/:id', auth, checkPermission('contract', 'read'), contracts.detail) -app.post('/api/contracts', auth, checkPermission('contract', 'create'), contracts.create) -app.put('/api/contracts/:id', auth, checkPermission('contract', 'update'), contracts.update) -app.delete('/api/contracts/:id', auth, checkPermission('contract', 'delete'), contracts.remove) +app.get('/api/contracts', auth, checkPermission('contract', 'read'), dataScope('contracts'), contracts.list) +app.get('/api/contracts/:id', auth, checkPermission('contract', 'read'), dataScope('contracts'), contracts.detail) +app.post('/api/contracts', auth, checkPermission('contract', 'create'),validateContractType, contracts.create) +app.put('/api/contracts/:id', auth, checkPermission('contract', 'update'),validateContractType,dataScope('contracts'),contracts.update) +app.delete('/api/contracts/:id', auth, checkPermission('contract', 'delete'),dataScope('contracts'), contracts.remove) // ============ 售后管理 /api/after-sales ============ -app.get('/api/after-sales', auth, checkPermission('after_sale', 'read'), afterSales.list) -app.get('/api/after-sales/:id', auth, checkPermission('after_sale', 'read'), afterSales.detail) -app.post('/api/after-sales', auth, checkPermission('after_sale', 'create'), afterSales.create) -app.put('/api/after-sales/:id', auth, checkPermission('after_sale', 'update'), afterSales.update) -app.delete('/api/after-sales/:id', auth, checkPermission('after_sale', 'delete'), afterSales.remove) +app.get('/api/after-sales', auth, checkPermission('after_sale', 'read'), dataScope('after_sales'), afterSales.list) +app.get('/api/after-sales/:id', auth, checkPermission('after_sale', 'read'), dataScope('after_sales'), afterSales.detail) +app.post('/api/after-sales', auth, checkPermission('after_sale', 'create'), afterSales.create) +app.put('/api/after-sales/:id', auth, checkPermission('after_sale', 'update'),dataScope('after_sales'),afterSales.update) +app.delete('/api/after-sales/:id', auth, checkPermission('after_sale', 'delete'),dataScope('after_sales'),afterSales.remove) // ============ 产品管理 /api/products ============ app.get('/api/products', auth, checkPermission('product', 'read'), products.list) @@ -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) // ============ 供应商管理 /api/suppliers ============ +app.get('/api/suppliers/simple', auth, checkPermission('supplier', 'read'), suppliers.simpleList) app.get('/api/suppliers', auth, checkPermission('supplier', 'read'), suppliers.list) app.get('/api/suppliers/:id', auth, checkPermission('supplier', 'read'), suppliers.detail) app.post('/api/suppliers', auth, checkPermission('supplier', 'create'), suppliers.create) diff --git a/test-all.js b/test-all.js index 9e1e7cd..91174a2 100644 --- a/test-all.js +++ b/test-all.js @@ -52,7 +52,8 @@ function check(label, res, expectStatus) { } else { failCount++ console.log(` ❌ ${label} 预期 HTTP ${expectStatus}, 实际 HTTP ${res.status}`) - console.log(` 响应: ${JSON.stringify(res.body).substring(0, 300)}`) + const preview = JSON.stringify(res.body).substring(0, 300) + if (preview) console.log(` 响应: ${preview}`) } return res } @@ -70,7 +71,7 @@ function checkValue(label, actual, expected) { // ========== 主流程 ========== async function main() { console.log('╔══════════════════════════════════════════════════╗') - console.log('║ 蜜雪冰城企业管理系统 — 全量接口测试 ║') + console.log('║ 蜜雪冰城企业管理系统 — 全量接口测试 V2 ║') console.log('╚══════════════════════════════════════════════════╝') console.log(`服务地址: ${BASE}\n`) @@ -82,12 +83,12 @@ async function main() { console.log('━'.repeat(55)) const accounts = [ - { username: 'admin', password: '123456', label: '系统管理员' }, - { username: 'liming', password: '123456', label: '招商经理' }, - { username: 'wangli', password: '123456', label: '运营经理' }, - { username: 'zhaoqiang', password: '123456', label: '采购经理' }, - { username: 'chenfang', password: '123456', label: '财务人员' }, - { username: 'zhangchao', password: '123456', label: '总经理' }, + { username: 'admin', password: '123456', label: '信息技术部' }, + { username: 'zhangchao', password: '123456', label: '总经理办公室' }, + { username: 'liming', password: '123456', label: '招商部' }, + { username: 'wangli', password: '123456', label: '运营部' }, + { username: 'zhaoqiang', password: '123456', label: '采购部' }, + { username: 'chenfang', password: '123456', label: '财务部' }, ] for (const acct of accounts) { @@ -98,49 +99,56 @@ async function main() { } } - // 错误密码 let r = await req('POST', '/api/user/login', { username: 'admin', password: 'wrong' }) - check('POST /api/user/login (错误密码)', r, 400) + check('POST /api/user/login (错误密码 → 400)', r, 400) r = await req('POST', '/api/user/login', { username: '', password: '' }) - check('POST /api/user/login (空参数)', r, 400) + check('POST /api/user/login (空参数 → 400)', r, 400) // ══════════════════════════════════════════ - // 【2】 JWT payload 验证 + // 【2】 JWT payload 验证(新字段名) // ══════════════════════════════════════════ console.log('\n' + '━'.repeat(55)) - console.log('【2】 JWT payload 验证(新字段)') + console.log('【2】 JWT payload 验证(departmentName / departmentDesc / permissions)') console.log('━'.repeat(55)) - // 验证 admin 登录返回的 userInfo 结构 const adminLogin = await req('POST', '/api/user/login', { username: 'admin', password: '123456' }) const adminInfo = adminLogin.body?.data?.userInfo if (adminInfo) { - checkValue('admin userInfo.name', adminInfo.name, '系统管理员') - checkValue('admin userInfo.roleName', adminInfo.roleName, 'admin') - checkValue('admin userInfo.department', adminInfo.department, '信息技术部') - check('admin userInfo.permissions 是数组', { status: Array.isArray(adminInfo.permissions) ? 200 : 500 }, 200) + checkValue('admin name', adminInfo.name, '系统管理员') + checkValue('admin departmentName', adminInfo.departmentName, 'admin') + checkValue('admin departmentDesc', adminInfo.departmentDesc, '信息技术部') + check('admin permissions 是数组', { status: Array.isArray(adminInfo.permissions) ? 200 : 500 }, 200) checkValue('admin permissions 数量', adminInfo.permissions?.length, 25) } - // 验证 liming(招商经理)的权限 const limingLogin = await req('POST', '/api/user/login', { username: 'liming', password: '123456' }) const limingInfo = limingLogin.body?.data?.userInfo if (limingInfo) { - checkValue('liming userInfo.name', limingInfo.name, '李明') - checkValue('liming userInfo.roleName', limingInfo.roleName, 'franchise_manager') - checkValue('liming userInfo.department', limingInfo.department, '招商部') + checkValue('liming name', limingInfo.name, '李明') + checkValue('liming departmentName', limingInfo.departmentName, 'franchise_manager') + checkValue('liming departmentDesc', limingInfo.departmentDesc, '招商部') check('liming 有 customer:read', { status: limingInfo.permissions?.includes('customer:read') ? 200 : 500 }, 200) + check('liming 有 customer:create', { status: limingInfo.permissions?.includes('customer:create') ? 200 : 500 }, 200) check('liming 无 product:read', { status: !limingInfo.permissions?.includes('product:read') ? 200 : 500 }, 200) check('liming 无 supplier:read', { status: !limingInfo.permissions?.includes('supplier:read') ? 200 : 500 }, 200) + check('liming 无 after_sale:read', { status: !limingInfo.permissions?.includes('after_sale:read') ? 200 : 500 }, 200) } - // 验证 /api/user/info 返回新字段 + const zhaoqiangLogin = await req('POST', '/api/user/login', { username: 'zhaoqiang', password: '123456' }) + const zhaoqiangInfo = zhaoqiangLogin.body?.data?.userInfo + if (zhaoqiangInfo) { + checkValue('zhaoqiang name', zhaoqiangInfo.name, '赵强') + check('zhaoqiang 有 customer:read(新增权限)', { status: zhaoqiangInfo.permissions?.includes('customer:read') ? 200 : 500 }, 200) + check('zhaoqiang 有 supplier:create', { status: zhaoqiangInfo.permissions?.includes('supplier:create') ? 200 : 500 }, 200) + } + + // GET /api/user/info 返回新字段 r = await req('GET', '/api/user/info', null, tokens.admin) check('GET /api/user/info (admin)', r, 200) if (r.body?.data) { - check('info 包含 role_name 字段', { status: r.body.data.role_name ? 200 : 500 }, 200) - check('info 包含 department 字段', { status: r.body.data.department ? 200 : 500 }, 200) + check('info 有 dept_name', { status: r.body.data.dept_name ? 200 : 500 }, 200) + check('info 有 dept_desc', { status: r.body.data.dept_desc ? 200 : 500 }, 200) } // ══════════════════════════════════════════ @@ -150,222 +158,214 @@ async function main() { console.log('【3】 权限隔离测试(无权限应返回 403)') console.log('━'.repeat(55)) - // 招商经理 → products(无权限) + // 招商部 → products(无权限) r = await req('GET', '/api/products', null, tokens.liming) check('liming → GET /api/products → 403', r, 403) - r = await req('POST', '/api/products', { name: 'x' }, tokens.liming) check('liming → POST /api/products → 403', r, 403) - // 招商经理 → suppliers(无权限) + // 招商部 → suppliers(无权限) r = await req('GET', '/api/suppliers', null, tokens.liming) check('liming → GET /api/suppliers → 403', r, 403) - // 招商经理 → after-sales(无权限) + // 招商部 → after-sales(无权限) r = await req('GET', '/api/after-sales', null, tokens.liming) check('liming → GET /api/after-sales → 403', r, 403) - // 采购经理 → customers(无权限) - r = await req('GET', '/api/customers', null, tokens.zhaoqiang) - check('zhaoqiang → GET /api/customers → 403', r, 403) + // 运营部 → products(无权限) + r = await req('GET', '/api/products', null, tokens.wangli) + check('wangli → GET /api/products → 403', r, 403) - // 采购经理 → after-sales(无权限) + // 运营部 → suppliers(无权限) + r = await req('GET', '/api/suppliers', null, tokens.wangli) + check('wangli → GET /api/suppliers → 403', r, 403) + + // 运营部 → contracts(无权限) + r = await req('GET', '/api/contracts', null, tokens.wangli) + check('wangli → GET /api/contracts → 403 (运营部无合同权限)', r, 403) + + // 采购部 → after-sales(无权限) r = await req('GET', '/api/after-sales', null, tokens.zhaoqiang) check('zhaoqiang → GET /api/after-sales → 403', r, 403) - // 财务人员 → products(无权限) - r = await req('GET', '/api/products', null, tokens.chenfang) - check('chenfang → GET /api/products → 403', r, 403) - - // 财务人员 → suppliers(无权限) - r = await req('GET', '/api/suppliers', null, tokens.chenfang) - check('chenfang → GET /api/suppliers → 403', r, 403) - - // 财务人员 → 创建 customers(只有 read 权限,无 create) + // 财务部 → POST customers(只有 read) r = await req('POST', '/api/customers', { name: '财务创建' }, tokens.chenfang) - check('chenfang → POST /api/customers → 403', r, 403) + check('chenfang → POST /api/customers → 403 (仅可读)', r, 403) - // 财务人员 → 创建 employees(只有 read,无 create) + // 财务部 → POST employees(只有 read) r = await req('POST', '/api/employees', { name: '财务创建' }, tokens.chenfang) - check('chenfang → POST /api/employees → 403', r, 403) + check('chenfang → POST /api/employees → 403 (仅可读)', r, 403) - // 总经理 → 创建 customers(只有 read,无 create) + // 总经理 → POST customers(只有 read) r = await req('POST', '/api/customers', { name: '总经理创建' }, tokens.zhangchao) - check('zhangchao → POST /api/customers → 403', r, 403) + check('zhangchao → POST /api/customers → 403 (仅可读)', r, 403) - // 总经理 → 创建 contracts(只有 read,无 create) + // 总经理 → POST contracts(只有 read) r = await req('POST', '/api/contracts', { contract_name: 'x', customer_id: 1 }, tokens.zhangchao) - check('zhangchao → POST /api/contracts → 403', r, 403) + check('zhangchao → POST /api/contracts → 403 (仅可读)', r, 403) // 非管理员 → 用户管理 r = await req('GET', '/api/users', null, tokens.liming) - check('liming → GET /api/users → 403', r, 403) + check('liming → GET /api/users → 403 (非管理员)', r, 403) // 无 token r = await req('GET', '/api/customers', null, null) check('无 token → GET /api/customers → 401', r, 401) // ══════════════════════════════════════════ - // 【4】 数据范围隔离测试 + // 【4】 简易列表接口 // ══════════════════════════════════════════ console.log('\n' + '━'.repeat(55)) - console.log('【4】 数据范围隔离测试') + console.log('【4】 简易列表接口(下拉选择用)') console.log('━'.repeat(55)) - // 4.1 加盟商范围隔离:admin 创建 2 个加盟商,分别分配给 liming 和 wangli - // 获取 liming 和 wangli 的 user ID - const allUsers = await req('GET', '/api/users', null, tokens.admin) - const limingUserId = allUsers.body?.data?.list?.find(u => u.username === 'liming')?.id - const wangliUserId = allUsers.body?.data?.list?.find(u => u.username === 'wangli')?.id - - r = await req('POST', '/api/customers', { name: '加盟商A-归属liming', phone: '13811111111', responsible_user_id: limingUserId }, tokens.admin) - check('admin 创建加盟商A (归属liming)', r, 200) - const custAId = r.body?.data?.id - if (custAId) created.customers.push(custAId) - - r = await req('POST', '/api/customers', { name: '加盟商B-归属wangli', phone: '13822222222', responsible_user_id: wangliUserId }, tokens.admin) - check('admin 创建加盟商B (归属wangli)', r, 200) - const custBId = r.body?.data?.id - if (custBId) created.customers.push(custBId) - - // liming 只能看到自己负责的 - r = await req('GET', '/api/customers', null, tokens.liming) - check('liming GET /api/customers (应只看到自己的)', r, 200) + r = await req('GET', '/api/user/list', null, tokens.admin) + check('GET /api/user/list (admin)', r, 200) if (r.body?.data) { - const limingCusts = r.body.data.list || [] - const allBelongToLiming = limingCusts.every(c => c.responsible_user_id === limingUserId) - checkValue('liming 只看到自己的加盟商', allBelongToLiming ? 'YES' : 'NO', 'YES') - checkValue('liming 加盟商数量 >= 1', limingCusts.length >= 1 ? 'YES' : 'NO', 'YES') + checkValue('user/list 数量 >= 6', r.body.data.length >= 6 ? 'YES' : 'NO', 'YES') } - // wangli 只能看到自己负责的 - r = await req('GET', '/api/customers', null, tokens.wangli) - check('wangli GET /api/customers (应只看到自己的)', r, 200) + // 放宽为 auth 后,非管理员也能调 + r = await req('GET', '/api/user/list', null, tokens.liming) + check('GET /api/user/list (liming auth即可)', r, 200) + + r = await req('GET', '/api/employees/simple', null, tokens.liming) + check('GET /api/employees/simple (liming)', r, 200) + + r = await req('GET', '/api/customers/simple', null, tokens.liming) + check('GET /api/customers/simple (liming)', r, 200) if (r.body?.data) { - const wangliCusts = r.body.data.list || [] - const allBelongToWangli = wangliCusts.every(c => c.responsible_user_id === wangliUserId) - checkValue('wangli 只看到自己的加盟商', allBelongToWangli ? 'YES' : 'NO', 'YES') + check('customers/simple 有 id+name', { status: r.body.data[0]?.id && r.body.data[0]?.name ? 200 : 500 }, 200) } - // admin 能看到全部 + r = await req('GET', '/api/suppliers/simple', null, tokens.zhaoqiang) + check('GET /api/suppliers/simple (zhaoqiang)', r, 200) + + // 无 supplier:read 的看不到供应商简易列表 + r = await req('GET', '/api/suppliers/simple', null, tokens.liming) + check('GET /api/suppliers/simple (liming 无权限 → 403)', r, 403) + + // ══════════════════════════════════════════ + // 【5】 数据范围隔离测试(过渡期规则) + // ══════════════════════════════════════════ + console.log('\n' + '━'.repeat(55)) + console.log('【5】 数据范围隔离测试') + console.log('━'.repeat(55)) + + // 5.1 加盟商:招商部看全量(过渡期) r = await req('GET', '/api/customers', null, tokens.admin) - check('admin GET /api/customers (应看到全部)', r, 200) + check('admin GET /api/customers (全量)', r, 200) + const adminCustCount = r.body?.data?.total || 0 + + r = await req('GET', '/api/customers', null, tokens.liming) + check('liming GET /api/customers (全量-过渡期)', r, 200) if (r.body?.data) { - checkValue('admin 加盟商总数 >= 2', r.body.data.total >= 2 ? 'YES' : 'NO', 'YES') + checkValue('liming 看到加盟商数 = admin', r.body.data.total >= adminCustCount ? 'YES' : 'NO', 'YES') } - // liming 不能看 wangli 的加盟商详情 - if (custBId) { - r = await req('GET', '/api/customers/' + custBId, null, tokens.liming) - check('liming GET wangli的加盟商详情 → 404', r, 404) + r = await req('GET', '/api/customers', null, tokens.wangli) + check('wangli GET /api/customers (全量-过渡期)', r, 200) + if (r.body?.data) { + checkValue('wangli 看到加盟商数 = admin', r.body.data.total >= adminCustCount ? 'YES' : 'NO', 'YES') } - // liming 不能修改 wangli 的加盟商 - if (custBId) { - r = await req('PUT', '/api/customers/' + custBId, { name: '越权修改' }, tokens.liming) - check('liming PUT wangli的加盟商 → 404', r, 404) + // 采购部现在也能看加盟商 + r = await req('GET', '/api/customers', null, tokens.zhaoqiang) + check('zhaoqiang GET /api/customers (采购部可看)', r, 200) + + // 5.2 合同类型隔离 + // 全量看 + r = await req('GET', '/api/contracts', null, tokens.admin) + check('admin GET /api/contracts (全量)', r, 200) + + // 招商部只看 franchise + r = await req('GET', '/api/contracts', null, tokens.liming) + check('liming GET /api/contracts (只看加盟合同)', r, 200) + if (r.body?.data) { + const allFranchise = (r.body.data.list || []).every(c => c.type === 'franchise') + checkValue('liming 合同全是 franchise', allFranchise ? 'YES' : 'NO', 'YES') + check('liming 有加盟合同', { status: r.body.data.total > 0 ? 200 : 500 }, 200) } - // 4.2 员工部门隔离:admin 创建 2 个不同部门的员工 - r = await req('POST', '/api/employees', { name: '招商部测试员工', department: '招商部', position: '专员', status: 1 }, tokens.admin) - check('admin 创建招商部员工', r, 200) - const empLmId = r.body?.data?.id - if (empLmId) created.employees.push(empLmId) + // 采购部只看 supply + r = await req('GET', '/api/contracts', null, tokens.zhaoqiang) + check('zhaoqiang GET /api/contracts (只看采购合同)', r, 200) + if (r.body?.data) { + const allSupply = (r.body.data.list || []).every(c => c.type === 'supply') + checkValue('zhaoqiang 合同全是 supply', allSupply ? 'YES' : 'NO', 'YES') + } - r = await req('POST', '/api/employees', { name: '运营部测试员工', department: '运营部', position: '专员', status: 1 }, tokens.admin) - check('admin 创建运营部员工', r, 200) - const empWlId = r.body?.data?.id - if (empWlId) created.employees.push(empWlId) + // 运营部无合同权限(走不到 dataScope) + r = await req('GET', '/api/contracts', null, tokens.wangli) + check('wangli → GET /api/contracts → 403', r, 403) - // liming 只能看到招商部员工 + // 5.3 售后:运营部看全量(过渡期) + r = await req('GET', '/api/after-sales', null, tokens.wangli) + check('wangli GET /api/after-sales (全量-过渡期)', r, 200) + if (r.body?.data) { + check('wangli 看到售后数据', { status: r.body.data.total > 0 ? 200 : 500 }, 200) + } + + r = await req('GET', '/api/after-sales', null, tokens.chenfang) + check('chenfang GET /api/after-sales (财务全量)', r, 200) + + // 5.4 员工部门隔离 r = await req('GET', '/api/employees', null, tokens.liming) check('liming GET /api/employees (部门隔离)', r, 200) if (r.body?.data) { - const limingEmps = r.body.data.list || [] - const allInDept = limingEmps.every(e => e.department === '招商部') - checkValue('liming 只看到招商部员工', allInDept ? 'YES' : 'NO', 'YES') + const allInDept = (r.body.data.list || []).every(e => e.department === '招商部') + checkValue('liming 只看招商部员工', allInDept ? 'YES' : 'NO', 'YES') } - // wangli 只能看到运营部员工 r = await req('GET', '/api/employees', null, tokens.wangli) check('wangli GET /api/employees (部门隔离)', r, 200) if (r.body?.data) { - const wangliEmps = r.body.data.list || [] - const allInDept = wangliEmps.every(e => e.department === '运营部') - checkValue('wangli 只看到运营部员工', allInDept ? 'YES' : 'NO', 'YES') + const allInDept = (r.body.data.list || []).every(e => e.department === '运营部') + checkValue('wangli 只看运营部员工', allInDept ? 'YES' : 'NO', 'YES') } - // admin 看到全部 r = await req('GET', '/api/employees', null, tokens.admin) - check('admin GET /api/employees (应看到全部)', r, 200) - if (r.body?.data) { - checkValue('admin 员工总数 >= 2', r.body.data.total >= 2 ? 'YES' : 'NO', 'YES') - } - - // 4.3 合同范围隔离 - // admin 创建加盟合同(归属 liming)和采购合同 - r = await req('POST', '/api/contracts', { - customer_id: custAId, contract_name: '加盟合同A', amount: 100000, - effective_date: '2026-01-01', expiry_date: '2027-01-01', status: '生效', - type: 'franchise', - }, tokens.admin) - check('admin 创建加盟合同 (归属liming的客户)', r, 200) - const franchiseConId = r.body?.data?.id - if (franchiseConId) created.contracts.push(franchiseConId) - - r = await req('POST', '/api/contracts', { - customer_id: custAId, contract_name: '采购合同X', amount: 200000, - effective_date: '2026-01-01', expiry_date: '2027-01-01', status: '生效', - type: 'supply', - }, tokens.admin) - check('admin 创建采购合同', r, 200) - const supplyConId = r.body?.data?.id - if (supplyConId) created.contracts.push(supplyConId) - - // 采购经理只看 supply 合同 - r = await req('GET', '/api/contracts', null, tokens.zhaoqiang) - check('zhaoqiang GET /api/contracts (只看supply)', r, 200) - if (r.body?.data) { - const zhaoContracts = r.body.data.list || [] - const allSupply = zhaoContracts.every(c => c.type === 'supply') - checkValue('zhaoqiang 只看到supply合同', allSupply ? 'YES' : 'NO', 'YES') - } - - // 4.4 after_sales 范围隔离:admin 创建售后记录 - r = await req('POST', '/api/after-sales', { - customer_id: custAId, feedback: '设备故障,归属wangli处理', - responsible_user_id: wangliUserId, handle_status: '待处理', - }, tokens.admin) - check('admin 创建售后记录 (归属wangli)', r, 200) - const asScopeId = r.body?.data?.id - if (asScopeId) created.afterSales.push(asScopeId) - - r = await req('POST', '/api/after-sales', { - customer_id: custBId, feedback: '原料质量问题,归属当前用户处理', - handle_status: '待处理', - }, tokens.wangli) - check('wangli 创建售后记录 (默认归属自己)', r, 200) - const asScopeId2 = r.body?.data?.id - if (asScopeId2) created.afterSales.push(asScopeId2) - - // wangli 应该能看到 2 条售后(都是自己负责的) - r = await req('GET', '/api/after-sales', null, tokens.wangli) - check('wangli GET /api/after-sales (应看到自己负责的)', r, 200) - if (r.body?.data) { - const wangliAS = r.body.data.list || [] - const allBelong = wangliAS.every(a => a.responsible_user_id === wangliUserId) - checkValue('wangli 只看到自己的售后', allBelong ? 'YES' : 'NO', 'YES') - } - - // liming 没有 after_sale:read 权限,直接 403 - r = await req('GET', '/api/after-sales', null, tokens.liming) - check('liming GET /api/after-sales → 403 (无权限)', r, 403) + check('admin GET /api/employees (全量)', r, 200) // ══════════════════════════════════════════ - // 【5】 suppliers CRUD 测试 + // 【6】 合同类型校验中间件 // ══════════════════════════════════════════ console.log('\n' + '━'.repeat(55)) - console.log('【5】 suppliers CRUD(采购经理操作)') + console.log('【6】 validateContractType 中间件测试') + console.log('━'.repeat(55)) + + // 获取一个加盟商 ID 用于测试 + const custList = await req('GET', '/api/customers?pageSize=1', null, tokens.admin) + const testCustId = custList.body?.data?.list?.[0]?.id + + // 招商部不能创建采购合同 + r = await req('POST', '/api/contracts', { + customer_id: testCustId, contract_name: '越权采购合同', + type: 'supply', effective_date: '2026-01-01', expiry_date: '2027-01-01', + }, tokens.liming) + check('liming → POST supply合同 → 403', r, 403) + + // 采购部不能创建加盟合同 + r = await req('POST', '/api/contracts', { + customer_id: testCustId, contract_name: '越权加盟合同', + type: 'franchise', effective_date: '2026-01-01', expiry_date: '2027-01-01', + }, tokens.zhaoqiang) + check('zhaoqiang → POST franchise合同 → 403', r, 403) + + // admin 可以创建任意类型 + r = await req('POST', '/api/contracts', { + customer_id: testCustId, contract_name: 'admin测试合同', + amount: 100000, effective_date: '2026-01-01', expiry_date: '2027-01-01', + type: 'franchise', status: '生效', + }, tokens.admin) + check('admin → POST franchise合同 → 200', r, 200) + if (r.body?.data?.id) created.contracts.push(r.body.data.id) + + // ══════════════════════════════════════════ + // 【7】 suppliers CRUD + // ══════════════════════════════════════════ + console.log('\n' + '━'.repeat(55)) + console.log('【7】 suppliers CRUD(采购部操作)') console.log('━'.repeat(55)) r = await req('POST', '/api/suppliers', { @@ -377,53 +377,56 @@ async function main() { if (supId) created.suppliers.push(supId) r = await req('POST', '/api/suppliers', { name: '' }, tokens.zhaoqiang) - check('zhaoqiang POST /api/suppliers (缺名称)', r, 400) + check('zhaoqiang POST /api/suppliers (缺名 → 400)', r, 400) if (supId) { r = await req('GET', '/api/suppliers/' + supId, null, tokens.zhaoqiang) - check('zhaoqiang GET /api/suppliers/:id (详情)', r, 200) - if (r.body?.data) { - checkValue('供应商名称', r.body.data.name, '柠檬供应商-测试') - } + check('GET /api/suppliers/:id (详情)', r, 200) + if (r.body?.data) checkValue('供应商名称', r.body.data.name, '柠檬供应商-测试') - r = await req('PUT', '/api/suppliers/' + supId, { phone: '13800008888', remark: '更新联系方式' }, tokens.zhaoqiang) - check('zhaoqiang PUT /api/suppliers/:id (更新)', r, 200) - - r = await req('GET', '/api/suppliers', null, tokens.zhaoqiang) - check('zhaoqiang GET /api/suppliers (列表)', r, 200) - if (r.body?.data) console.log(` ↳ 供应商总数: ${r.body.data.total}`) + r = await req('PUT', '/api/suppliers/' + supId, { phone: '13800008888' }, tokens.zhaoqiang) + check('PUT /api/suppliers/:id (更新)', r, 200) r = await req('GET', '/api/suppliers?name=柠檬', null, tokens.zhaoqiang) - check('zhaoqiang GET /api/suppliers?name=柠檬 (搜索)', r, 200) + check('GET /api/suppliers?name=柠檬 (搜索)', r, 200) } // ══════════════════════════════════════════ - // 【6】 users CRUD 测试(适配新字段) + // 【8】 users CRUD(新字段 department_id) // ══════════════════════════════════════════ console.log('\n' + '━'.repeat(55)) - console.log('【6】 users CRUD(管理员操作)') + console.log('【8】 users CRUD(管理员操作,新字段 department_id)') console.log('━'.repeat(55)) r = await req('GET', '/api/users', null, tokens.admin) check('admin GET /api/users (列表)', r, 200) - if (r.body?.data) console.log(` ↳ 用户总数: ${r.body.data.total}`) + if (r.body?.data) { + console.log(` ↳ 用户总数: ${r.body.data.total}`) + // 验证新字段 + const sample = r.body.data.list[0] + check('用户有 dept_name', { status: sample?.dept_name ? 200 : 500 }, 200) + check('用户有 dept_desc', { status: sample?.dept_desc ? 200 : 500 }, 200) + check('用户有 real_name (employees联查)', { status: sample?.real_name ? 200 : 500 }, 200) + } - // 创建新用户,关联到已有员工 + // 管理员查自己 ID + const allUsers = await req('GET', '/api/users', null, tokens.admin) + const adminUser = allUsers.body?.data?.list?.find(u => u.username === 'admin') + const adminUserId = adminUser?.id + + // 创建新用户(用新字段 department_id) r = await req('POST', '/api/users', { username: 'testuser_' + Date.now(), password: 'pass123456', - role_id: 3, // franchise_manager - employee_id: empLmId, - department: '招商部', + department_id: 3, // franchise_manager 的 ID }, tokens.admin) - check('admin POST /api/users (创建带role_id+employee_id)', r, 200) + check('admin POST /api/users (创建,新字段)', r, 200) const newUserId = r.body?.data?.id if (newUserId) { created.users.push(newUserId) if (r.body?.data) { - check('新用户有 role_name', { status: r.body.data.role_name ? 200 : 500 }, 200) - check('新用户有 real_name (从employees)', { status: r.body.data.real_name ? 200 : 500 }, 200) - console.log(` ↳ 新用户: ${r.body.data.username}, 角色: ${r.body.data.role_name}, 姓名: ${r.body.data.real_name}`) + check('新用户有 dept_name', { status: r.body.data.dept_name ? 200 : 500 }, 200) + check('新用户有 dept_desc', { status: r.body.data.dept_desc ? 200 : 500 }, 200) } r = await req('GET', '/api/users/' + newUserId, null, tokens.admin) @@ -431,28 +434,71 @@ async function main() { r = await req('PUT', '/api/users/' + newUserId, { is_active: 0 }, tokens.admin) check('admin PUT /api/users/:id (禁用)', r, 200) - if (r.body?.data) checkValue('is_active 已禁用', r.body.data.is_active, 0) r = await req('PUT', '/api/users/' + newUserId, { is_active: 1, password: 'newpass123' }, tokens.admin) - check('admin PUT /api/users/:id (启用+重置密码)', r, 200) + check('admin PUT /api/users/:id (启用+改密)', r, 200) } - // admin 不能删除自己(先获取 admin 的真实 ID) - const adminUser = allUsers.body?.data?.list?.find(u => u.username === 'admin') - const adminUserId = adminUser?.id - + // admin 不能删除自己 r = await req('DELETE', '/api/users/' + adminUserId, null, tokens.admin) - check('admin DELETE /api/users/' + adminUserId + ' (删自己) → 400', r, 400) + check('admin DELETE 自己 → 400', r, 400) // admin 不能禁用自己 r = await req('PUT', '/api/users/' + adminUserId, { is_active: 0 }, tokens.admin) - check('admin PUT /api/users/' + adminUserId + ' (禁用自己) → 400', r, 400) + check('admin PUT 禁用自己 → 400', r, 400) + + // admin 不能改自己部门 + r = await req('PUT', '/api/users/' + adminUserId, { department_id: 3 }, tokens.admin) + check('admin PUT 改自己部门 → 400', r, 400) + + // 按部门筛选用户 + r = await req('GET', '/api/users?department_id=1', null, tokens.admin) + check('GET /api/users?department_id=1 (筛选admin部门)', r, 200) // ══════════════════════════════════════════ - // 【7】 customers CRUD 测试 + // 【9】 allowUserCreation:创建员工同时建用户 // ══════════════════════════════════════════ console.log('\n' + '━'.repeat(55)) - console.log('【7】 customers CRUD') + console.log('【9】 allowUserCreation 中间件测试') + console.log('━'.repeat(55)) + + // 管理员创建员工同时建账号 + r = await req('POST', '/api/employees', { + name: '测试员工-带账号', gender: '男', department: '招商部', + position: '专员', status: 1, + username: 'testemp_' + Date.now(), + password: 'pass123456', + department_id: 3, // franchise_manager + }, tokens.admin) + check('admin POST /api/employees (同步建用户)', r, 200) + if (r.body?.data?.id) created.employees.push(r.body.data.id) + + // 非管理员创建员工不能带用户字段 + r = await req('POST', '/api/employees', { + name: '测试员工-越权', department: '招商部', + username: 'hackuser', password: 'pass123', department_id: 3, + }, tokens.liming) + check('liming POST /api/employees 带 user 字段 → 403', r, 403) + + // 管理员不带用户字段 → 只创建员工 + r = await req('POST', '/api/employees', { + name: '测试员工-纯员工', department: '招商部', position: '专员', status: 1, + }, tokens.admin) + check('admin POST /api/employees (不建用户)', r, 200) + if (r.body?.data?.id) created.employees.push(r.body.data.id) + + // 管理员建用户但缺字段 + r = await req('POST', '/api/employees', { + name: '测试员工-缺字段', department: '招商部', + username: 'testuser2', // 缺 password 和 department_id + }, tokens.admin) + check('admin POST /api/employees (建用户缺字段 → 400)', r, 400) + + // ══════════════════════════════════════════ + // 【10】 customers CRUD + // ══════════════════════════════════════════ + console.log('\n' + '━'.repeat(55)) + console.log('【10】 customers CRUD') console.log('━'.repeat(55)) r = await req('POST', '/api/customers', { @@ -463,17 +509,18 @@ async function main() { const custCrudId = r.body?.data?.id if (custCrudId) { created.customers.push(custCrudId) - checkValue('默认 responsible_user_id = admin', r.body.data.responsible_user_id, adminUserId) + check('客户创建成功', { status: r.body.data.id ? 200 : 500 }, 200) } r = await req('POST', '/api/customers', { name: '' }, tokens.admin) - check('POST /api/customers (缺名称)', r, 400) + check('POST /api/customers (缺名 → 400)', r, 400) if (custCrudId) { r = await req('GET', '/api/customers/' + custCrudId, null, tokens.admin) check('GET /api/customers/:id (详情)', r, 200) + if (r.body?.data) checkValue('加盟商名', r.body.data.name, 'CRUD测试加盟商') - r = await req('PUT', '/api/customers/' + custCrudId, { phone: '13900006666', remark: '更新备注' }, tokens.admin) + r = await req('PUT', '/api/customers/' + custCrudId, { phone: '13900006666', remark: '更新' }, tokens.admin) check('PUT /api/customers/:id (更新)', r, 200) r = await req('GET', '/api/customers?name=CRUD', null, tokens.admin) @@ -481,13 +528,13 @@ async function main() { } r = await req('GET', '/api/customers/99999', null, tokens.admin) - check('GET /api/customers/99999 (不存在)', r, 404) + check('GET /api/customers/99999 (不存在 → 404)', r, 404) // ══════════════════════════════════════════ - // 【8】 employees CRUD 测试 + // 【11】 employees CRUD // ══════════════════════════════════════════ console.log('\n' + '━'.repeat(55)) - console.log('【8】 employees CRUD') + console.log('【11】 employees CRUD') console.log('━'.repeat(55)) r = await req('POST', '/api/employees', { @@ -503,21 +550,18 @@ async function main() { r = await req('GET', '/api/employees/' + empCrudId, null, tokens.admin) check('GET /api/employees/:id (详情)', r, 200) - r = await req('PUT', '/api/employees/' + empCrudId, { salary: 15000, position: '高级品控专员' }, tokens.admin) + r = await req('PUT', '/api/employees/' + empCrudId, { salary: 15000, position: '高级专员' }, tokens.admin) check('PUT /api/employees/:id (更新)', r, 200) - r = await req('GET', '/api/employees?status=1', null, tokens.admin) - check('GET /api/employees?status=1 (在职筛选)', r, 200) - r = await req('GET', '/api/employees?department=品控部', null, tokens.admin) - check('GET /api/employees?department=品控部 (部门筛选)', r, 200) + check('GET /api/employees?department=品控部 (筛选)', r, 200) } // ══════════════════════════════════════════ - // 【9】 products CRUD 测试 + // 【12】 products CRUD // ══════════════════════════════════════════ console.log('\n' + '━'.repeat(55)) - console.log('【9】 products CRUD(采购经理操作)') + console.log('【12】 products CRUD(采购部操作)') console.log('━'.repeat(55)) r = await req('POST', '/api/products', { @@ -529,7 +573,7 @@ async function main() { if (prodId) created.products.push(prodId) r = await req('POST', '/api/products', { name: '' }, tokens.zhaoqiang) - check('POST /api/products (缺名称)', r, 400) + check('POST /api/products (缺名 → 400)', r, 400) if (prodId) { r = await req('GET', '/api/products/' + prodId, null, tokens.zhaoqiang) @@ -540,72 +584,66 @@ async function main() { r = await req('GET', '/api/products?name=柠檬', null, tokens.zhaoqiang) check('GET /api/products?name=柠檬 (搜索)', r, 200) - - r = await req('GET', '/api/products?type=原材料', null, tokens.zhaoqiang) - check('GET /api/products?type=原材料 (筛选)', r, 200) } // ══════════════════════════════════════════ - // 【10】 contracts CRUD 测试 + // 【13】 contracts CRUD // ══════════════════════════════════════════ console.log('\n' + '━'.repeat(55)) - console.log('【10】 contracts CRUD(含 type 字段)') + console.log('【13】 contracts CRUD') console.log('━'.repeat(55)) - // 采购经理创建合同 → 自动 type='supply' + // 招商部正确创建加盟合同 r = await req('POST', '/api/contracts', { - customer_id: custAId, contract_name: '原材料采购合同', amount: 500000, + customer_id: custCrudId, contract_name: '加盟协议-测试', amount: 300000, effective_date: '2026-06-01', expiry_date: '2027-05-31', status: '生效', - }, tokens.zhaoqiang) - check('zhaoqiang POST /api/contracts (自动type=supply)', r, 200) + }, tokens.liming) + check('liming POST /api/contracts (加盟合同)', r, 200) if (r.body?.data) { - checkValue('合同type自动设为supply', r.body.data.type, 'supply') - created.contracts.push(r.body.data.id) - } - - // 管理员创建合同 → 默认 type='franchise' - r = await req('POST', '/api/contracts', { - customer_id: custAId, contract_name: '加盟协议-测试', amount: 300000, - effective_date: '2026-06-01', expiry_date: '2027-05-31', status: '生效', - employee_id: empLmId, - }, tokens.admin) - check('admin POST /api/contracts (默认type=franchise)', r, 200) - if (r.body?.data) { - checkValue('合同type默认franchise', r.body.data.type, 'franchise') + checkValue('合同type自动=franchise', r.body.data.type, 'franchise') check('合同有 customer_name 联查', { status: r.body.data.customer_name ? 200 : 500 }, 200) created.contracts.push(r.body.data.id) } - r = await req('POST', '/api/contracts', { customer_id: 99999, contract_name: '无效' }, tokens.admin) - check('POST /api/contracts (客户不存在)', r, 400) + // 采购部正确创建采购合同(需要 supplier_id) + r = await req('POST', '/api/contracts', { + customer_id: custCrudId, contract_name: '采购合同-测试', amount: 200000, + effective_date: '2026-06-01', expiry_date: '2027-05-31', + type: 'supply', status: '生效', supplier_id: 1, + }, tokens.zhaoqiang) + check('zhaoqiang POST /api/contracts (采购合同)', r, 200) + if (r.body?.data) { + checkValue('合同type=supply', r.body.data.type, 'supply') + created.contracts.push(r.body.data.id) + } - r = await req('GET', '/api/contracts', null, tokens.admin) - check('GET /api/contracts (列表)', r, 200) + r = await req('POST', '/api/contracts', { customer_id: 99999, contract_name: '无效' }, tokens.admin) + check('POST /api/contracts (客户不存在 → 400)', r, 400) // ══════════════════════════════════════════ - // 【11】 after-sales CRUD 测试 + // 【14】 after-sales CRUD // ══════════════════════════════════════════ console.log('\n' + '━'.repeat(55)) - console.log('【11】 after-sales CRUD') + console.log('【14】 after-sales CRUD') console.log('━'.repeat(55)) r = await req('POST', '/api/after-sales', { - customer_id: custAId, feedback: '冰淇淋机不出料', - employee_id: empWlId, handle_method: '派工程师上门检修', - handle_status: '处理中', service_date: '2026-06-20', + customer_id: custCrudId, feedback: '冰淇淋机不出料', + handle_method: '派工程师上门', handle_status: '处理中', + service_date: '2026-06-20', }, tokens.wangli) check('wangli POST /api/after-sales (创建)', r, 200) const asId = r.body?.data?.id if (asId) { created.afterSales.push(asId) - check('售后有 customer_name 联查', { status: r.body.data.customer_name ? 200 : 500 }, 200) + check('售后有 customer_name', { status: r.body.data.customer_name ? 200 : 500 }, 200) } r = await req('POST', '/api/after-sales', { customer_id: 99999, feedback: '测试' }, tokens.wangli) - check('POST /api/after-sales (客户不存在)', r, 400) + check('POST /api/after-sales (客户不存在 → 400)', r, 400) - r = await req('POST', '/api/after-sales', { customer_id: custAId }, tokens.wangli) - check('POST /api/after-sales (缺feedback)', r, 400) + r = await req('POST', '/api/after-sales', { customer_id: custCrudId }, tokens.wangli) + check('POST /api/after-sales (缺feedback → 400)', r, 400) if (asId) { r = await req('GET', '/api/after-sales/' + asId, null, tokens.wangli) @@ -619,32 +657,31 @@ async function main() { } // ══════════════════════════════════════════ - // 【12】 个人信息 & 改密 + // 【15】 个人信息 & 改密 // ══════════════════════════════════════════ console.log('\n' + '━'.repeat(55)) - console.log('【12】 个人信息 & 改密') + console.log('【15】 个人信息 & 改密') console.log('━'.repeat(55)) r = await req('POST', '/api/user/logout', null, tokens.admin) check('POST /api/user/logout', r, 200) r = await req('PUT', '/api/user/password', { oldPassword: 'wrong', newPassword: 'newpwd999' }, tokens.admin) - check('PUT /api/user/password (旧密码错)', r, 400) + check('PUT /api/user/password (旧密码错 → 400)', r, 400) r = await req('PUT', '/api/user/password', { oldPassword: '123456', newPassword: '123456' }, tokens.admin) - check('PUT /api/user/password (新旧相同)', r, 400) + check('PUT /api/user/password (新旧相同 → 400)', r, 400) r = await req('PUT', '/api/user/password', { oldPassword: '123456', newPassword: '123' }, tokens.admin) - check('PUT /api/user/password (新密码太短)', r, 400) + check('PUT /api/user/password (新密码太短 → 400)', r, 400) // ══════════════════════════════════════════ - // 【13】 数据清理 + // 【16】 数据清理 // ══════════════════════════════════════════ console.log('\n' + '━'.repeat(55)) - console.log('【13】 测试数据清理') + console.log('【16】 测试数据清理') console.log('━'.repeat(55)) - // 按依赖顺序删除 for (const id of created.afterSales) { await req('DELETE', '/api/after-sales/' + id, null, tokens.admin) }