diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..99a157b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,44 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Enterprise information management system backend (企业信息管理系统后端服务). Node.js REST API built on Express 5 with raw MySQL queries (no ORM). Provides CRUD APIs for users, customers (franchisees), employees, contracts, after-sales services, and products/inventory. + +## Commands + +- `npm run dev` — start dev server with auto-restart (`node --watch server.js`) +- `npm start` — start production server (`node server.js`) +- `node test-all.js` — run integration tests (server must be running on port 3000 first; uses Node's built-in `http` module, no test framework) + +No build step, no linter, no formatter configured. + +## Architecture + +**Flat structure** — no controllers/services/layers. Entry point `server.js` directly imports route handlers and registers them on the Express app. + +- `server.js` — Express app setup, JWT `auth` middleware, `requireAdmin` middleware, all route registration, server startup +- `db.js` — MySQL connection pool (`mysql2/promise`), auto-initialization on startup (creates database, tables, seeds default admin `admin`/`123456`) +- `routes/*.js` — each exports `{ list, detail, create, update, remove }` functions for a single resource + +**Auth flow**: JWT in `Authorization: Bearer ` header. Two middleware layers in `server.js`: `auth` (verifies token, attaches `req.user`) and `requireAdmin` (checks `req.user.role === 'admin'`). + +## Database + +MySQL 5.7+ / MariaDB 10.2+. Raw SQL with `pool.query()` and `?` parameterized placeholders. No foreign key constraints — referential integrity is enforced in application code by checking entity existence before inserts/updates. + +Tables: `users`, `customers`, `employees`, `products`, `contracts`, `after_sales` + +Relationships: contracts and after_sales reference customers and employees by ID, with `LEFT JOIN` in list/detail queries to include related names. + +## Conventions + +- **API response format**: `{ "code": 0, "message": "ok", "data": { ... } }` (errors use non-zero `code` and descriptive `message`) +- **List endpoints** return `{ list, total, page, pageSize, totalPages }` with pagination (`page`, `pageSize` params, max 100) +- **Route modules** use dynamic `WHERE 1=1` clause building for search/filter, and dynamic `SET` clause building for partial updates +- **Environment config**: dotenv via `.env` file (see `.env.example` for required variables: `PORT`, `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `JWT_SECRET`, `JWT_EXPIRES_IN`) + +## Dependencies + +express, mysql2, jsonwebtoken, bcryptjs, dotenv diff --git a/db.js b/db.js index 7b3cfff..1730d87 100644 --- a/db.js +++ b/db.js @@ -39,9 +39,10 @@ async function initDB() { id INT NOT NULL AUTO_INCREMENT COMMENT '用户ID (主键)', username VARCHAR(50) NOT NULL COMMENT '用户名 (登录账号)', password VARCHAR(255) NOT NULL COMMENT '登录密码 (应存储加密后的值)', - real_name VARCHAR(50) DEFAULT NULL COMMENT '真实姓名', - role VARCHAR(20) NOT NULL DEFAULT 'user' COMMENT '角色: admin-管理员, user-普通用户', - status TINYINT(1) NOT NULL DEFAULT 1 COMMENT '账号状态: 0-禁用, 1-启用', + is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '账号状态: 0-禁用, 1-启用', + role_id INT DEFAULT NULL COMMENT '角色ID (关联roles表)', + 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), @@ -152,20 +153,228 @@ async function initDB() { `) console.log('[init] 数据表初始化完成') - // 2.3 插 admin(仅当不存在时) - const [rows] = await pool.query( - 'SELECT id FROM users WHERE username = ?', - ['admin'] - ) - if (rows.length === 0) { - const hash = await bcrypt.hash('123456', 10) + // ============ 2.3 RBAC 权限相关表 ============ + 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 '角色描述 (中文)', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_roles_name (name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表' + `) + + await pool.query(` + CREATE TABLE IF NOT EXISTS permissions ( + id INT NOT NULL AUTO_INCREMENT COMMENT '权限ID (主键)', + name VARCHAR(100) NOT NULL COMMENT '权限标识 (resource:action)', + description VARCHAR(200) DEFAULT NULL COMMENT '权限描述 (中文)', + resource VARCHAR(50) NOT NULL COMMENT '资源名称', + action VARCHAR(50) NOT NULL COMMENT '操作类型: read/create/update/delete', + PRIMARY KEY (id), + UNIQUE KEY uk_permissions_name (name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限表' + `) + + await pool.query(` + CREATE TABLE IF NOT EXISTS role_permissions ( + role_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='角色-权限关联表' + `) + + await pool.query(` + CREATE TABLE IF NOT EXISTS suppliers ( + id INT NOT NULL AUTO_INCREMENT COMMENT '供应商ID (主键)', + name VARCHAR(200) NOT NULL COMMENT '供应商名称', + contact VARCHAR(100) DEFAULT NULL COMMENT '联系人', + phone VARCHAR(50) DEFAULT NULL COMMENT '联系电话', + address VARCHAR(500) DEFAULT NULL COMMENT '地址', + type VARCHAR(50) DEFAULT NULL COMMENT '类型: 原材料/包装/设备', + content TEXT DEFAULT NULL COMMENT '备注说明', + status TINYINT(1) NOT NULL DEFAULT 1 COMMENT '状态: 0-停用, 1-正常', + 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), + KEY idx_suppliers_name (name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商信息表' + `) + + // ============ 2.4 旧表迁移(兼容已有数据库) ============ + // users 表:删除 real_name、role,新增 is_active、role_id、employee_id、department + try { await pool.query(`ALTER TABLE users DROP COLUMN real_name`) } catch {} + try { await pool.query(`ALTER TABLE users DROP COLUMN role`) } catch {} + try { await pool.query(`ALTER TABLE users CHANGE COLUMN status is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '账号状态: 0-禁用, 1-启用'`) } catch {} + try { await pool.query(`ALTER TABLE users ADD COLUMN role_id INT DEFAULT NULL COMMENT '角色ID' AFTER is_active`) } catch {} + try { await pool.query(`ALTER TABLE users ADD COLUMN employee_id INT DEFAULT NULL COMMENT '员工ID' AFTER role_id`) } catch {} + try { await pool.query(`ALTER TABLE users ADD COLUMN department VARCHAR(100) DEFAULT NULL COMMENT '所属部门' AFTER employee_id`) } catch {} + + // customers 表:新增 responsible_user_id + try { await pool.query(`ALTER TABLE customers ADD COLUMN responsible_user_id INT DEFAULT NULL COMMENT '负责人用户ID'`) } catch {} + + // contracts 表:新增 type + try { await pool.query(`ALTER TABLE contracts ADD COLUMN type VARCHAR(20) DEFAULT 'franchise' COMMENT '合同类型: franchise-加盟合同, supply-采购合同'`) } catch {} + // contracts 表:新增 responsible_user_id(用于数据范围隔离) + try { await pool.query(`ALTER TABLE contracts ADD COLUMN responsible_user_id INT DEFAULT NULL COMMENT '负责人用户ID'`) } catch {} + + // after_sales 表:新增 responsible_user_id(用于数据范围隔离) + try { await pool.query(`ALTER TABLE after_sales ADD COLUMN responsible_user_id INT DEFAULT NULL COMMENT '负责人用户ID'`) } catch {} + + // ============ 2.5 种子数据 ============ + await seedRolesAndPermissions() + await seedDefaultUsers() +} + +// ============ 3. 种子数据:角色与权限 ============ +async function seedRolesAndPermissions() { + const [roles] = await pool.query('SELECT id FROM roles LIMIT 1') + if (roles.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: '财务人员' }, + ] + for (const r of roleList) { + await pool.query('INSERT INTO roles (name, description) VALUES (?, ?)', [r.name, r.description]) + } + console.log('[init] 已初始化 6 个角色') + + const permissionList = [ + { name: 'customer:read', description: '查看加盟商', resource: 'customer', action: 'read' }, + { name: 'customer:create', description: '新增加盟商', resource: 'customer', action: 'create' }, + { name: 'customer:update', description: '修改加盟商', resource: 'customer', action: 'update' }, + { name: 'customer:delete', description: '删除加盟商', resource: 'customer', action: 'delete' }, + { name: 'contract:read', description: '查看合同', resource: 'contract', action: 'read' }, + { name: 'contract:create', description: '新增合同', resource: 'contract', action: 'create' }, + { name: 'contract:update', description: '修改合同', resource: 'contract', action: 'update' }, + { name: 'contract:delete', description: '删除合同', resource: 'contract', action: 'delete' }, + { name: 'after_sale:read', description: '查看售后', resource: 'after_sale', action: 'read' }, + { name: 'after_sale:create',description: '新增售后', resource: 'after_sale', action: 'create' }, + { name: 'after_sale:update',description: '修改售后', resource: 'after_sale', action: 'update' }, + { name: 'after_sale:delete',description: '删除售后', resource: 'after_sale', action: 'delete' }, + { name: 'product:read', description: '查看产品原料', resource: 'product', action: 'read' }, + { name: 'product:create', description: '新增产品原料', resource: 'product', action: 'create' }, + { name: 'product:update', description: '修改产品原料', resource: 'product', action: 'update' }, + { name: 'product:delete', description: '删除产品原料', resource: 'product', action: 'delete' }, + { name: 'supplier:read', description: '查看供应商', resource: 'supplier', action: 'read' }, + { name: 'supplier:create', description: '新增供应商', resource: 'supplier', action: 'create' }, + { name: 'supplier:update', description: '修改供应商', resource: 'supplier', action: 'update' }, + { name: 'supplier:delete', description: '删除供应商', resource: 'supplier', action: 'delete' }, + { name: 'employee:read', description: '查看员工', resource: 'employee', action: 'read' }, + { name: 'employee:create', description: '新增员工', resource: 'employee', action: 'create' }, + { name: 'employee:update', description: '修改员工', resource: 'employee', action: 'update' }, + { name: 'employee:delete', description: '删除员工', resource: 'employee', action: 'delete' }, + { name: 'user:manage', description: '管理用户账号', resource: 'user', action: 'manage' }, + ] + for (const p of permissionList) { await pool.query( - 'INSERT INTO users (username, password, real_name, role) VALUES (?, ?, ?, ?)', - ['admin', hash, '超级管理员', 'admin'] + 'INSERT INTO permissions (name, description, resource, action) VALUES (?, ?, ?, ?)', + [p.name, p.description, p.resource, p.action] ) - console.log('[init] 已创建默认账号 admin / 123456') - } else { - console.log('[init] admin 已存在,跳过') + } + console.log('[init] 已初始化 25 个权限') + + // 角色-权限映射 + const allPerms = permissionList.map(p => p.name) + const rolePermMap = { + admin: allPerms, // 管理员拥有所有权限 + general_manager: [ + 'customer:read', 'contract:read', 'after_sale:read', + 'product:read', 'supplier:read', 'employee:read', + ], + franchise_manager: [ + 'customer:read', 'customer:create', 'customer:update', 'customer:delete', + 'contract:read', 'contract:create', 'contract:update', 'contract:delete', + 'employee:read', + ], + operations_manager: [ + 'customer:read', 'customer:update', + 'contract:read', + 'after_sale:read', 'after_sale:create', 'after_sale:update', 'after_sale:delete', + 'employee:read', + ], + procurement_manager: [ + 'contract:read', 'contract:create', 'contract:update', 'contract:delete', + 'product:read', 'product:create', 'product:update', 'product:delete', + 'supplier:read', 'supplier:create', 'supplier:update', 'supplier:delete', + 'employee:read', + ], + finance: [ + 'customer:read', 'contract:read', 'after_sale:read', 'employee:read', + ], + } + + const [roleRows] = await pool.query('SELECT id, name FROM roles') + const [permRows] = await pool.query('SELECT id, name FROM permissions') + const roleIdMap = Object.fromEntries(roleRows.map(r => [r.name, r.id])) + const permIdMap = Object.fromEntries(permRows.map(p => [p.name, p.id])) + + for (const [roleName, permNames] of Object.entries(rolePermMap)) { + for (const permName of permNames) { + await pool.query( + 'INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)', + [roleIdMap[roleName], permIdMap[permName]] + ) + } + } + 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 seedEmployees = [ + { name: '张超', gender: '男', age: 42, education: '硕士', department: '总经理办公室', entry_date: '2015-03-01', position: '总经理', salary: 50000, phone: '13800001001', email: 'zhangchao@mixue.com' }, + { name: '李明', gender: '男', age: 35, education: '本科', department: '招商部', entry_date: '2018-06-15', position: '招商经理', salary: 18000, phone: '13800001002', email: 'liming@mixue.com' }, + { name: '王丽', gender: '女', age: 33, education: '本科', department: '运营部', entry_date: '2019-01-10', position: '运营经理', salary: 18000, phone: '13800001003', email: 'wangli@mixue.com' }, + { name: '赵强', gender: '男', age: 36, education: '本科', department: '采购部', entry_date: '2017-09-20', position: '采购经理', salary: 18000, phone: '13800001004', email: 'zhaoqiang@mixue.com' }, + { name: '陈芳', gender: '女', age: 30, education: '本科', department: '财务部', entry_date: '2020-03-05', position: '财务主管', salary: 15000, phone: '13800001005', email: 'chenfang@mixue.com' }, + { name: '系统管理员', gender: '男', age: 28, education: '本科', department: '信息技术部', entry_date: '2022-07-01', position: '系统管理员', salary: 12000, phone: '13800001006', email: 'admin@mixue.com' }, + ] + + 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' }, + ] + + for (let i = 0; i < seedUsers.length; i++) { + const emp = seedEmployees[i] + const usr = seedUsers[i] + + // 逐个检查用户是否存在(不依赖全局检查,避免部分用户被误删后跳过全部重建) + const [userCheck] = await pool.query('SELECT id FROM users WHERE username = ?', [usr.username]) + if (userCheck.length > 0) continue + + // 先插入 employees + const [empResult] = await pool.query( + `INSERT INTO employees (name, gender, age, education, department, entry_date, position, salary, phone, email, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`, + [emp.name, emp.gender, emp.age, emp.education, emp.department, emp.entry_date, emp.position, emp.salary, emp.phone, emp.email] + ) + const empId = empResult.insertId + + // 再插入 users,关联 employee 和 role + await pool.query( + `INSERT INTO users (username, password, is_active, role_id, employee_id, department) + VALUES (?, ?, 1, ?, ?, ?)`, + [usr.username, hash, roleIdMap[usr.roleName], empId, emp.department] + ) + console.log(`[init] 已创建用户 ${usr.username} (${emp.name} - ${emp.department})`) } } diff --git a/middleware/permissions.js b/middleware/permissions.js new file mode 100644 index 0000000..ebcce0b --- /dev/null +++ b/middleware/permissions.js @@ -0,0 +1,73 @@ +// middleware/permissions.js —— RBAC 权限检查 + 数据范围过滤 + +/** + * 检查当前用户是否拥有指定权限 + * @param {string} resource - 资源名,如 'customer' + * @param {string} action - 操作名,如 'read' + */ +function checkPermission(resource, action) { + return (req, res, next) => { + const permName = `${resource}:${action}` + if (!req.user || !req.user.permissions || !req.user.permissions.includes(permName)) { + return res.status(403).json({ code: 403, message: '权限不足' }) + } + next() + } +} + +/** + * 根据当前用户角色,返回该资源的数据范围过滤条件 + * @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 表示该角色无权访问此资源 + */ +function getDataScope(user, resource) { + const { roleName, id: userId, department } = user + + // admin 和 general_manager(总经理):全量数据,无限制 + if (roleName === 'admin' || roleName === 'general_manager') { + return {} + } + + switch (resource) { + case 'customers': + if (roleName === 'finance') return {} + return { where: 'responsible_user_id = ?', values: [userId] } + + case 'contracts': + if (roleName === 'finance') return {} + if (roleName === 'procurement_manager') { + return { where: 'c.type = ?', values: ['supply'] } + } + if (roleName === 'franchise_manager' || roleName === 'operations_manager') { + return { where: 'c.responsible_user_id = ? AND c.type = ?', values: [userId, 'franchise'] } + } + return {} + + 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 'employees': + if (roleName === 'finance') return {} + // 所有非管理员/总经理角色:只能看自己部门的员工 + return { where: 'department = ?', values: [department] } + + case 'products': + case 'suppliers': + case 'users': + // 无数据范围过滤,仅靠 checkPermission 控制访问 + return {} + + default: + return {} + } +} + +module.exports = { checkPermission, getDataScope } diff --git a/routes/afterSales.js b/routes/afterSales.js index 7998bca..99f17c4 100644 --- a/routes/afterSales.js +++ b/routes/afterSales.js @@ -1,5 +1,6 @@ // routes/afterSales.js —— 售后管理 CRUD const { pool } = require('../db') +const { getDataScope } = require('../middleware/permissions') function pagination(query) { const page = Math.max(Number(query.page) || 1, 1) @@ -27,6 +28,18 @@ 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('responsible_user_id', `${scope.tableAlias}.responsible_user_id`) : scope.where + where += ' AND ' + scopeCol + params.push(...scope.values) + } + if (handle_status) { where += ' AND a.handle_status = ?' params.push(handle_status) @@ -69,10 +82,20 @@ async function list(req, res) { // GET /api/after-sales/:id —— 详情 async function detail(req, res) { try { - const [rows] = await pool.query( - `${LIST_SELECT} WHERE a.id = ?`, - [req.params.id] - ) + 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) + } + + const [rows] = await pool.query(sql, params) if (rows.length === 0) { return res.status(404).json({ code: 404, message: '售后记录不存在' }) } @@ -87,7 +110,7 @@ async function detail(req, res) { async function create(req, res) { const { customer_id, feedback, employee_id, handle_method, - handle_status, service_date, remark, + handle_status, service_date, remark, responsible_user_id, } = req.body || {} if (!customer_id) { @@ -111,16 +134,20 @@ async function create(req, res) { } } + // 如果没指定负责人,默认设为当前用户 + 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) - VALUES (?, ?, ?, ?, ?, ?, ?)`, + (customer_id, feedback, employee_id, handle_method, handle_status, service_date, remark, responsible_user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [customer_id, feedback, employee_id || null, handle_method || null, handle_status || '待处理', service_date || null, - remark || null] + remark || null, + ownerId] ) const [rows] = await pool.query(`${LIST_SELECT} WHERE a.id = ?`, [result.insertId]) @@ -136,13 +163,24 @@ async function update(req, res) { const { id } = req.params const fields = [ 'customer_id', 'feedback', 'employee_id', 'handle_method', - 'handle_status', 'service_date', 'remark', + 'handle_status', 'service_date', 'remark', 'responsible_user_id', ] try { - const [existing] = await pool.query('SELECT id FROM after_sales WHERE id = ?', [id]) + // 确认记录存在且在数据范围内 + 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) + } + const [existing] = await pool.query(checkSql, checkParams) if (existing.length === 0) { - return res.status(404).json({ code: 404, message: '售后记录不存在' }) + return res.status(404).json({ code: 404, message: '售后记录不存在或无权操作' }) } if (req.body.customer_id) { @@ -185,9 +223,19 @@ async function update(req, res) { async function remove(req, res) { const { id } = req.params try { - const [existing] = await pool.query('SELECT id FROM after_sales WHERE id = ?', [id]) + 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) + } + const [existing] = await pool.query(checkSql, checkParams) if (existing.length === 0) { - return res.status(404).json({ code: 404, message: '售后记录不存在' }) + return res.status(404).json({ code: 404, message: '售后记录不存在或无权操作' }) } await pool.query('DELETE FROM after_sales WHERE id = ?', [id]) res.json({ code: 0, message: 'ok' }) diff --git a/routes/contracts.js b/routes/contracts.js index f5cce59..0210ccd 100644 --- a/routes/contracts.js +++ b/routes/contracts.js @@ -1,5 +1,6 @@ // routes/contracts.js —— 合同管理 CRUD const { pool } = require('../db') +const { getDataScope } = require('../middleware/permissions') function pagination(query) { const page = Math.max(Number(query.page) || 1, 1) @@ -31,6 +32,16 @@ async function list(req, res) { let where = 'WHERE 1=1' const params = [] + // 数据范围过滤 + const scope = getDataScope(req.user, 'contracts') + if (scope.deny) { + return res.status(403).json({ code: 403, message: '无权访问此资源' }) + } + if (scope.where) { + where += ' AND ' + scope.where + params.push(...scope.values) + } + if (status) { where += ' AND c.status = ?' params.push(status) @@ -81,10 +92,19 @@ async function list(req, res) { // GET /api/contracts/:id —— 详情 async function detail(req, res) { try { - const [rows] = await pool.query( - `${DETAIL_SELECT} WHERE c.id = ?`, - [req.params.id] - ) + 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) { + sql += ' AND ' + scope.where + params.push(...scope.values) + } + + const [rows] = await pool.query(sql, params) if (rows.length === 0) { return res.status(404).json({ code: 404, message: '合同不存在' }) } @@ -125,8 +145,8 @@ async function create(req, res) { const [result] = await pool.query( `INSERT INTO contracts - (customer_id, contract_name, contract_no, contract_content, amount, effective_date, expiry_date, employee_id, status, remark) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (customer_id, contract_name, contract_no, contract_content, amount, effective_date, expiry_date, employee_id, status, remark, type, responsible_user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [customer_id, contract_name, contract_no || null, contract_content || null, @@ -135,7 +155,9 @@ async function create(req, res) { expiry_date || null, employee_id || null, status || '生效', - remark || null] + remark || null, + req.body.type || (req.user.roleName === 'procurement_manager' ? 'supply' : 'franchise'), + req.body.responsible_user_id || req.user.id] ) const [rows] = await pool.query(`${DETAIL_SELECT} WHERE c.id = ?`, [result.insertId]) @@ -151,13 +173,24 @@ async function update(req, res) { const { id } = req.params const fields = [ 'customer_id', 'contract_name', 'contract_no', 'contract_content', - 'amount', 'effective_date', 'expiry_date', 'employee_id', 'status', 'remark', + 'amount', 'effective_date', 'expiry_date', 'employee_id', 'status', 'remark', 'type', 'responsible_user_id', ] try { - const [existing] = await pool.query('SELECT id FROM contracts WHERE id = ?', [id]) + // 确认记录存在且在数据范围内 + 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) + } + const [existing] = await pool.query(checkSql, checkParams) if (existing.length === 0) { - return res.status(404).json({ code: 404, message: '合同不存在' }) + return res.status(404).json({ code: 404, message: '合同不存在或无权操作' }) } // 如果更新了 customer_id 或 employee_id,需要校验 @@ -201,9 +234,19 @@ async function update(req, res) { async function remove(req, res) { const { id } = req.params try { - const [existing] = await pool.query('SELECT id FROM contracts WHERE id = ?', [id]) + 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) + } + const [existing] = await pool.query(checkSql, checkParams) if (existing.length === 0) { - return res.status(404).json({ code: 404, message: '合同不存在' }) + return res.status(404).json({ code: 404, message: '合同不存在或无权操作' }) } await pool.query('DELETE FROM contracts WHERE id = ?', [id]) res.json({ code: 0, message: 'ok' }) diff --git a/routes/customers.js b/routes/customers.js index 4a6a9fa..a13f73f 100644 --- a/routes/customers.js +++ b/routes/customers.js @@ -1,5 +1,6 @@ // routes/customers.js —— 客户管理 CRUD const { pool } = require('../db') +const { getDataScope } = require('../middleware/permissions') // 提取分页参数 function pagination(query) { @@ -18,6 +19,16 @@ 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) + } + if (name) { where += ' AND name LIKE ?' params.push(`%${name}%`) @@ -71,7 +82,19 @@ async function list(req, res) { // GET /api/customers/:id —— 详情 async function detail(req, res) { try { - const [rows] = await pool.query('SELECT * FROM customers WHERE id = ?', [req.params.id]) + 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) + } + + const [rows] = await pool.query(sql, params) if (rows.length === 0) { return res.status(404).json({ code: 404, message: '客户不存在' }) } @@ -86,7 +109,7 @@ async function detail(req, res) { async function create(req, res) { const { name, phone, province, city, district, - address, customer_type, email, remark, + address, customer_type, email, remark, responsible_user_id, } = req.body || {} if (!name) { @@ -94,11 +117,14 @@ 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, customer_type, email, remark) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO customers (name, phone, province, city, district, address, customer_type, email, remark, responsible_user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [name, phone || null, province || null, city || null, district || null, - address || null, customer_type || 'Normal', email || null, remark || null] + address || null, customer_type || 'Normal', email || null, remark || null, ownerId] ) const [rows] = await pool.query('SELECT * FROM customers WHERE id = ?', [result.insertId]) res.json({ code: 0, message: 'ok', data: rows[0] }) @@ -113,14 +139,24 @@ async function update(req, res) { const { id } = req.params const fields = [ 'name', 'phone', 'province', 'city', 'district', - 'address', 'customer_type', 'email', 'remark', + 'address', 'customer_type', 'email', 'remark', 'responsible_user_id', ] try { - // 先确认记录存在 - const [existing] = await pool.query('SELECT id FROM customers WHERE id = ?', [id]) + // 确认记录存在且在数据范围内 + 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) + } + const [existing] = await pool.query(checkSql, checkParams) if (existing.length === 0) { - return res.status(404).json({ code: 404, message: '客户不存在' }) + return res.status(404).json({ code: 404, message: '客户不存在或无权操作' }) } // 动态构建 SET 子句(只更新传入的字段) @@ -151,9 +187,19 @@ async function update(req, res) { async function remove(req, res) { const { id } = req.params try { - const [existing] = await pool.query('SELECT id FROM customers WHERE id = ?', [id]) + 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) + } + const [existing] = await pool.query(checkSql, checkParams) if (existing.length === 0) { - return res.status(404).json({ code: 404, message: '客户不存在' }) + return res.status(404).json({ code: 404, message: '客户不存在或无权操作' }) } await pool.query('DELETE FROM customers WHERE id = ?', [id]) res.json({ code: 0, message: 'ok' }) diff --git a/routes/employees.js b/routes/employees.js index 1a8c204..0cd6904 100644 --- a/routes/employees.js +++ b/routes/employees.js @@ -1,5 +1,6 @@ // routes/employees.js —— 员工管理 CRUD const { pool } = require('../db') +const { getDataScope } = require('../middleware/permissions') function pagination(query) { const page = Math.max(Number(query.page) || 1, 1) @@ -17,6 +18,16 @@ 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) + } + if (name) { where += ' AND name LIKE ?' params.push(`%${name}%`) @@ -60,7 +71,19 @@ async function list(req, res) { // GET /api/employees/:id —— 详情 async function detail(req, res) { try { - const [rows] = await pool.query('SELECT * FROM employees WHERE id = ?', [req.params.id]) + 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) + } + + const [rows] = await pool.query(sql, params) if (rows.length === 0) { return res.status(404).json({ code: 404, message: '员工不存在' }) } @@ -116,9 +139,20 @@ async function update(req, res) { ] try { - const [existing] = await pool.query('SELECT id FROM employees WHERE id = ?', [id]) + // 确认记录存在且在数据范围内 + 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) + } + const [existing] = await pool.query(checkSql, checkParams) if (existing.length === 0) { - return res.status(404).json({ code: 404, message: '员工不存在' }) + return res.status(404).json({ code: 404, message: '员工不存在或无权操作' }) } const sets = [] @@ -148,9 +182,19 @@ async function update(req, res) { async function remove(req, res) { const { id } = req.params try { - const [existing] = await pool.query('SELECT id FROM employees WHERE id = ?', [id]) + 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) + } + const [existing] = await pool.query(checkSql, checkParams) if (existing.length === 0) { - return res.status(404).json({ code: 404, message: '员工不存在' }) + return res.status(404).json({ code: 404, message: '员工不存在或无权操作' }) } await pool.query('DELETE FROM employees WHERE id = ?', [id]) res.json({ code: 0, message: 'ok' }) diff --git a/routes/suppliers.js b/routes/suppliers.js new file mode 100644 index 0000000..2df8a08 --- /dev/null +++ b/routes/suppliers.js @@ -0,0 +1,147 @@ +// routes/suppliers.js —— 供应商管理 CRUD +const { pool } = require('../db') + +function pagination(query) { + const page = Math.max(Number(query.page) || 1, 1) + const pageSize = Math.min(Math.max(Number(query.pageSize) || 10, 1), 100) + const offset = (page - 1) * pageSize + return { page, pageSize, offset } +} + +// GET /api/suppliers —— 列表 +async function list(req, res) { + try { + const { page, pageSize, offset } = pagination(req.query) + const { name, type, status } = req.query + + let where = 'WHERE 1=1' + const params = [] + + if (name) { + where += ' AND name LIKE ?' + params.push(`%${name}%`) + } + if (type) { + where += ' AND type LIKE ?' + params.push(`%${type}%`) + } + if (status !== undefined && status !== '') { + where += ' AND status = ?' + params.push(Number(status)) + } + + const [[{ total }]] = await pool.query( + `SELECT COUNT(*) AS total FROM suppliers ${where}`, + params + ) + + const [rows] = await pool.query( + `SELECT * FROM suppliers ${where} ORDER BY id DESC LIMIT ? OFFSET ?`, + [...params, pageSize, offset] + ) + + res.json({ + code: 0, + message: 'ok', + data: { + list: rows, + total, + page, + pageSize, + totalPages: Math.ceil(total / pageSize), + }, + }) + } catch (e) { + console.error('[suppliers list] error:', e) + res.status(500).json({ code: 500, message: e.message }) + } +} + +// GET /api/suppliers/:id —— 详情 +async function detail(req, res) { + try { + const [rows] = await pool.query('SELECT * FROM suppliers WHERE id = ?', [req.params.id]) + if (rows.length === 0) { + return res.status(404).json({ code: 404, message: '供应商不存在' }) + } + res.json({ code: 0, message: 'ok', data: rows[0] }) + } catch (e) { + console.error('[suppliers detail] error:', e) + res.status(500).json({ code: 500, message: e.message }) + } +} + +// POST /api/suppliers —— 新增 +async function create(req, res) { + const { name, contact, phone, address, type, content, status } = req.body || {} + + if (!name) { + return res.status(400).json({ code: 400, message: '供应商名称必填' }) + } + + try { + const [result] = await pool.query( + `INSERT INTO suppliers (name, contact, phone, address, type, content, status) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [name, contact || null, phone || null, address || null, + type || null, content || null, status !== undefined ? status : 1] + ) + const [rows] = await pool.query('SELECT * FROM suppliers WHERE id = ?', [result.insertId]) + res.json({ code: 0, message: 'ok', data: rows[0] }) + } catch (e) { + console.error('[suppliers create] error:', e) + res.status(500).json({ code: 500, message: e.message }) + } +} + +// PUT /api/suppliers/:id —— 更新 +async function update(req, res) { + const { id } = req.params + const fields = ['name', 'contact', 'phone', 'address', 'type', 'content', 'status'] + + try { + const [existing] = await pool.query('SELECT id FROM suppliers WHERE id = ?', [id]) + if (existing.length === 0) { + return res.status(404).json({ code: 404, message: '供应商不存在' }) + } + + const sets = [] + const params = [] + for (const f of fields) { + if (req.body[f] !== undefined) { + sets.push(`${f} = ?`) + params.push(req.body[f]) + } + } + if (sets.length === 0) { + return res.status(400).json({ code: 400, message: '没有需要更新的字段' }) + } + + params.push(id) + await pool.query(`UPDATE suppliers SET ${sets.join(', ')} WHERE id = ?`, params) + + const [rows] = await pool.query('SELECT * FROM suppliers WHERE id = ?', [id]) + res.json({ code: 0, message: 'ok', data: rows[0] }) + } catch (e) { + console.error('[suppliers update] error:', e) + res.status(500).json({ code: 500, message: e.message }) + } +} + +// DELETE /api/suppliers/:id —— 删除 +async function remove(req, res) { + const { id } = req.params + try { + const [existing] = await pool.query('SELECT id FROM suppliers WHERE id = ?', [id]) + if (existing.length === 0) { + return res.status(404).json({ code: 404, message: '供应商不存在' }) + } + await pool.query('DELETE FROM suppliers WHERE id = ?', [id]) + res.json({ code: 0, message: 'ok' }) + } catch (e) { + console.error('[suppliers delete] error:', e) + res.status(500).json({ code: 500, message: e.message }) + } +} + +module.exports = { list, detail, create, update, remove } diff --git a/routes/users.js b/routes/users.js index be1803a..b6027e0 100644 --- a/routes/users.js +++ b/routes/users.js @@ -3,8 +3,16 @@ const jwt = require('jsonwebtoken') const bcrypt = require('bcryptjs') const { pool } = require('../db') -// 安全字段:不返回密码哈希 -const SAFE_FIELDS = 'id, username, real_name, role, status, created_at, updated_at' +// 安全字段:关联查询 roles 和 employees +const USER_LIST_SQL = ` + SELECT u.id, u.username, u.is_active, u.role_id, u.employee_id, u.department, + u.created_at, u.updated_at, + r.name AS role_name, r.description AS role_description, + e.name AS real_name + FROM users u + LEFT JOIN roles r ON u.role_id = r.id + LEFT JOIN employees e ON u.employee_id = e.id +` function pagination(query) { const page = Math.max(Number(query.page) || 1, 1) @@ -24,18 +32,22 @@ async function login(req, res) { try { const [rows] = await pool.query( - 'SELECT id, username, password, real_name, role, status FROM users WHERE username = ?', + `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 + FROM users u + LEFT JOIN roles r ON u.role_id = r.id + LEFT JOIN employees e ON u.employee_id = e.id + WHERE u.username = ?`, [username] ) - // 用户不存在或密码错,统一提示避免枚举 if (rows.length === 0) { return res.status(400).json({ code: 400, message: '账号或密码错误' }) } const user = rows[0] // 检查账号是否被禁用 - if (user.status === 0) { + if (user.is_active === 0) { return res.status(403).json({ code: 403, message: '账号已被禁用,请联系管理员' }) } @@ -44,8 +56,25 @@ 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] + ) + const permissions = perms.map(p => p.name) + const token = jwt.sign( - { id: user.id, username: user.username, role: user.role }, + { + id: user.id, + username: user.username, + name: user.real_name || user.username, + role_id: user.role_id, + roleName: user.role_name, + department: user.department, + permissions, + }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN || '2h' } ) @@ -58,8 +87,11 @@ async function login(req, res) { userInfo: { id: user.id, username: user.username, - real_name: user.real_name, - role: user.role, + name: user.real_name || user.username, + role_id: user.role_id, + roleName: user.role_name, + department: user.department, + permissions, }, }, }) @@ -73,7 +105,7 @@ async function login(req, res) { async function info(req, res) { try { const [rows] = await pool.query( - `SELECT ${SAFE_FIELDS} FROM users WHERE id = ?`, + `${USER_LIST_SQL} WHERE u.id = ?`, [req.user.id] ) if (rows.length === 0) { @@ -126,35 +158,35 @@ async function changePassword(req, res) { // ========== 用户管理 CRUD(管理员) ========== -// GET /api/users —— 用户列表(管理员) +// GET /api/users —— 用户列表 async function list(req, res) { try { const { page, pageSize, offset } = pagination(req.query) - const { username, role, status } = req.query + const { username, role_id, is_active } = req.query let where = 'WHERE 1=1' const params = [] if (username) { - where += ' AND username LIKE ?' + where += ' AND u.username LIKE ?' params.push(`%${username}%`) } - if (role) { - where += ' AND role = ?' - params.push(role) + if (role_id) { + where += ' AND u.role_id = ?' + params.push(Number(role_id)) } - if (status !== undefined && status !== '') { - where += ' AND status = ?' - params.push(Number(status)) + if (is_active !== undefined && is_active !== '') { + where += ' AND u.is_active = ?' + params.push(Number(is_active)) } const [[{ total }]] = await pool.query( - `SELECT COUNT(*) AS total FROM users ${where}`, + `SELECT COUNT(*) AS total FROM users u ${where}`, params ) const [rows] = await pool.query( - `SELECT ${SAFE_FIELDS} FROM users ${where} ORDER BY id DESC LIMIT ? OFFSET ?`, + `${USER_LIST_SQL} ${where} ORDER BY u.id DESC LIMIT ? OFFSET ?`, [...params, pageSize, offset] ) @@ -175,11 +207,11 @@ async function list(req, res) { } } -// GET /api/users/:id —— 用户详情(管理员) +// GET /api/users/:id —— 用户详情 async function detail(req, res) { try { const [rows] = await pool.query( - `SELECT ${SAFE_FIELDS} FROM users WHERE id = ?`, + `${USER_LIST_SQL} WHERE u.id = ?`, [req.params.id] ) if (rows.length === 0) { @@ -192,9 +224,9 @@ async function detail(req, res) { } } -// POST /api/users —— 创建用户(管理员) +// POST /api/users —— 创建用户 async function create(req, res) { - const { username, password, real_name = null, role = 'user', status = 1 } = req.body || {} + const { username, password, role_id, employee_id, department, is_active = 1 } = req.body || {} if (!username || !password) { return res.status(400).json({ code: 400, message: '用户名和密码必填' }) @@ -205,19 +237,36 @@ async function create(req, res) { if (typeof password !== 'string' || password.length < 6) { return res.status(400).json({ code: 400, message: '密码至少 6 位' }) } - if (!['admin', 'user'].includes(role)) { - return res.status(400).json({ code: 400, message: '角色只能为 admin 或 user' }) + + // 校验 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: '指定的角色不存在' }) + } + } + + // 校验 employee_id 是否存在 + if (employee_id) { + const [emp] = await pool.query('SELECT id, department 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, real_name, role, status) VALUES (?, ?, ?, ?, ?)', - [username, hash, real_name, role, status] + '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] ) const [rows] = await pool.query( - `SELECT ${SAFE_FIELDS} FROM users WHERE id = ?`, + `${USER_LIST_SQL} WHERE u.id = ?`, [result.insertId] ) res.json({ code: 0, message: 'ok', data: rows[0] }) @@ -230,38 +279,52 @@ async function create(req, res) { } } -// PUT /api/users/:id —— 更新用户(管理员) +// PUT /api/users/:id —— 更新用户 async function update(req, res) { const { id } = req.params - const fields = ['username', 'real_name', 'role', 'status'] + const fields = ['username', 'role_id', 'employee_id', 'department', 'is_active'] try { - const [existing] = await pool.query('SELECT id, role FROM users WHERE id = ?', [id]) + const [existing] = await pool.query('SELECT id, role_id, is_active FROM users WHERE id = ?', [id]) if (existing.length === 0) { return res.status(404).json({ code: 404, message: '用户不存在' }) } - const targetUser = existing[0] - // 不允许修改自己的角色或禁用自己 if (Number(id) === req.user.id) { - if (req.body.role !== undefined && req.body.role !== req.user.role) { + 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.status === 0) { + if (req.body.is_active === 0) { return res.status(400).json({ code: 400, message: '不能禁用自己' }) } } + // 校验 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: '指定的角色不存在' }) + } + } + + // 校验 employee_id + if (req.body.employee_id) { + const [emp] = await pool.query('SELECT id, department 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 = [] const params = [] for (const f of fields) { if (req.body[f] !== undefined) { - // role 字段只允许 admin / user - if (f === 'role' && !['admin', 'user'].includes(req.body[f])) { - return res.status(400).json({ code: 400, message: '角色只能为 admin 或 user' }) - } sets.push(`${f} = ?`) params.push(req.body[f]) } @@ -285,7 +348,7 @@ async function update(req, res) { await pool.query(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`, params) const [rows] = await pool.query( - `SELECT ${SAFE_FIELDS} FROM users WHERE id = ?`, + `${USER_LIST_SQL} WHERE u.id = ?`, [id] ) res.json({ code: 0, message: 'ok', data: rows[0] }) @@ -298,7 +361,7 @@ async function update(req, res) { } } -// DELETE /api/users/:id —— 删除用户(管理员) +// DELETE /api/users/:id —— 删除用户 async function remove(req, res) { const { id } = req.params try { diff --git a/server.js b/server.js index bf72ed4..208007f 100644 --- a/server.js +++ b/server.js @@ -10,6 +10,8 @@ const employees = require('./routes/employees') 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 app = express() app.use(express.json()) // 解析 application/json 请求体 @@ -30,61 +32,60 @@ function auth(req, res, next) { } } -// ============ 管理员校验中间件 ============ -function requireAdmin(req, res, next) { - if (!req.user || req.user.role !== 'admin') { - return res.status(403).json({ code: 403, message: '需要管理员权限' }) - } - next() -} - // ============ 登录/登出/个人信息 ============ app.post('/api/user/login', users.login) app.get('/api/user/info', auth, users.info) app.post('/api/user/logout', auth, users.logout) app.put('/api/user/password', auth, users.changePassword) -// ============ 用户管理 CRUD(管理员)/api/users ============ -app.get('/api/users', auth, requireAdmin, users.list) -app.get('/api/users/:id', auth, requireAdmin, users.detail) -app.post('/api/users', auth, requireAdmin, users.create) -app.put('/api/users/:id', auth, requireAdmin, users.update) -app.delete('/api/users/:id', auth, requireAdmin, users.remove) +// ============ 用户管理 CRUD /api/users(管理员专属) ============ +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) -// ============ 客户管理 /api/customers ============ -app.get('/api/customers', auth, customers.list) -app.get('/api/customers/:id', auth, customers.detail) -app.post('/api/customers', auth, customers.create) -app.put('/api/customers/:id', auth, customers.update) -app.delete('/api/customers/:id', auth, customers.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) // ============ 员工管理 /api/employees ============ -app.get('/api/employees', auth, employees.list) -app.get('/api/employees/:id', auth, employees.detail) -app.post('/api/employees', auth, employees.create) -app.put('/api/employees/:id', auth, employees.update) -app.delete('/api/employees/:id', auth, employees.remove) +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) // ============ 合同管理 /api/contracts ============ -app.get('/api/contracts', auth, contracts.list) -app.get('/api/contracts/:id', auth, contracts.detail) -app.post('/api/contracts', auth, contracts.create) -app.put('/api/contracts/:id', auth, contracts.update) -app.delete('/api/contracts/:id', auth, contracts.remove) +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) // ============ 售后管理 /api/after-sales ============ -app.get('/api/after-sales', auth, afterSales.list) -app.get('/api/after-sales/:id', auth, afterSales.detail) -app.post('/api/after-sales', auth, afterSales.create) -app.put('/api/after-sales/:id', auth, afterSales.update) -app.delete('/api/after-sales/:id', auth, afterSales.remove) +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) // ============ 产品管理 /api/products ============ -app.get('/api/products', auth, products.list) -app.get('/api/products/:id', auth, products.detail) -app.post('/api/products', auth, products.create) -app.put('/api/products/:id', auth, products.update) -app.delete('/api/products/:id', auth, products.remove) +app.get('/api/products', auth, checkPermission('product', 'read'), products.list) +app.get('/api/products/:id', auth, checkPermission('product', 'read'), products.detail) +app.post('/api/products', auth, checkPermission('product', 'create'), products.create) +app.put('/api/products/:id', auth, checkPermission('product', 'update'), products.update) +app.delete('/api/products/:id', auth, checkPermission('product', 'delete'), products.remove) + +// ============ 供应商管理 /api/suppliers ============ +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) +app.put('/api/suppliers/:id', auth, checkPermission('supplier', 'update'), suppliers.update) +app.delete('/api/suppliers/:id', auth, checkPermission('supplier', 'delete'), suppliers.remove) // ============ 启动 ============ const PORT = Number(process.env.PORT) || 3000 diff --git a/test-all.js b/test-all.js index 7faca2d..d54900b 100644 --- a/test-all.js +++ b/test-all.js @@ -1,29 +1,42 @@ -// test-all.js —— 手动运行,逐接口验证全部 CRUD -// 用法: node test-all.js +// test-all.js —— 全量接口测试:权限隔离 + 数据范围隔离 + CRUD +// 用法: 先启动服务 node server.js,再运行 node test-all.js require('dotenv').config() const http = require('http') const BASE = 'http://127.0.0.1:3000' -let token = '' let failCount = 0 let passCount = 0 +// 所有用户的 token +const tokens = {} + +// 测试中创建的数据 ID,用于最后清理 +const created = { + customers: [], + employees: [], + products: [], + contracts: [], + afterSales: [], + suppliers: [], + users: [], +} + // ========== 工具函数 ========== -function req(method, path, body, useToken = true) { +function req(method, path, body, token) { return new Promise((resolve, reject) => { const u = new URL(path, BASE) const opts = { hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, headers: { 'Content-Type': 'application/json' }, } - if (useToken && token) opts.headers['Authorization'] = 'Bearer ' + token + if (token) opts.headers['Authorization'] = 'Bearer ' + token const r = http.request(opts, (res) => { let d = '' res.on('data', (c) => (d += c)) res.on('end', () => { try { resolve({ status: res.statusCode, body: JSON.parse(d) }) } - catch { resolve({ status: res.statusCode, body: d.substring(0, 300) }) } + catch { resolve({ status: res.statusCode, body: d.substring(0, 500) }) } }) }) r.on('error', reject) @@ -32,318 +45,644 @@ function req(method, path, body, useToken = true) { }) } -function check(label, res, expectStatus, expectInfo) { - const ok = res.status === expectStatus - if (ok) { +function check(label, res, expectStatus) { + if (res.status === expectStatus) { passCount++ console.log(` ✅ ${label}`) } else { failCount++ console.log(` ❌ ${label} 预期 HTTP ${expectStatus}, 实际 HTTP ${res.status}`) - console.log(` 响应: ${JSON.stringify(res.body).substring(0, 200)}`) + console.log(` 响应: ${JSON.stringify(res.body).substring(0, 300)}`) + } + return res +} + +function checkValue(label, actual, expected) { + if (actual === expected) { + passCount++ + console.log(` ✅ ${label} (${actual})`) + } else { + failCount++ + console.log(` ❌ ${label} 预期 ${expected}, 实际 ${actual}`) } - if (expectInfo) console.log(` ↳ ${expectInfo}`) } // ========== 主流程 ========== async function main() { - console.log('╔══════════════════════════════════════════╗') - console.log('║ 企业管理系统 — 接口全量测试 ║') - console.log('╚══════════════════════════════════════════╝') + console.log('╔══════════════════════════════════════════════════╗') + console.log('║ 蜜雪冰城企业管理系统 — 全量接口测试 ║') + console.log('╚══════════════════════════════════════════════════╝') console.log(`服务地址: ${BASE}\n`) - // ────────── 1. 登录 ────────── - console.log('━'.repeat(50)) - console.log('【1】 用户登录模块') - console.log('━'.repeat(50)) + // ══════════════════════════════════════════ + // 【1】 多角色登录 + // ══════════════════════════════════════════ + console.log('━'.repeat(55)) + console.log('【1】 多角色登录') + console.log('━'.repeat(55)) - let r = await req('POST', '/api/user/login', { username: 'admin', password: '123456' }, false) - check('POST /api/user/login (正确密码)', r, 200) - if (r.body?.data?.token) { - token = r.body.data.token - console.log(` ↳ 角色: ${r.body.data.userInfo.role}, 姓名: ${r.body.data.userInfo.real_name}`) + 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: '总经理' }, + ] + + for (const acct of accounts) { + const r = await req('POST', '/api/user/login', { username: acct.username, password: acct.password }) + check(`POST /api/user/login (${acct.label} ${acct.username})`, r, 200) + if (r.body?.data?.token) { + tokens[acct.username] = r.body.data.token + } } - r = await req('POST', '/api/user/login', { username: 'admin', password: 'wrong' }, false) - check('POST /api/user/login (错误密码)', r, 400, '→ 账号或密码错误') + // 错误密码 + let r = await req('POST', '/api/user/login', { username: 'admin', password: 'wrong' }) + check('POST /api/user/login (错误密码)', r, 400) - r = await req('POST', '/api/user/login', { username: '', password: '' }, false) - check('POST /api/user/login (空参数)', r, 400, '→ 用户名和密码必填') + r = await req('POST', '/api/user/login', { username: '', password: '' }) + check('POST /api/user/login (空参数)', r, 400) - // ────────── 2. 个人信息 ────────── - console.log('\n━'.repeat(50)) - console.log('【2】 个人信息 & 改密') - console.log('━'.repeat(50)) + // ══════════════════════════════════════════ + // 【2】 JWT payload 验证 + // ══════════════════════════════════════════ + console.log('\n' + '━'.repeat(55)) + console.log('【2】 JWT payload 验证(新字段)') + console.log('━'.repeat(55)) - r = await req('GET', '/api/user/info') - check('GET /api/user/info', r, 200) - if (r.body?.data) console.log(` ↳ 用户名: ${r.body.data.username}, 角色: ${r.body.data.role}`) + // 验证 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 permissions 数量', adminInfo.permissions?.length, 25) + } - r = await req('POST', '/api/user/logout') - check('POST /api/user/logout', r, 200, '→ JWT 无状态,前端删 token 即可') + // 验证 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, '招商部') + check('liming 有 customer:read', { status: limingInfo.permissions?.includes('customer:read') ? 200 : 500 }, 200) + check('liming 无 product:read', { status: !limingInfo.permissions?.includes('product:read') ? 200 : 500 }, 200) + check('liming 无 supplier:read', { status: !limingInfo.permissions?.includes('supplier:read') ? 200 : 500 }, 200) + } - r = await req('PUT', '/api/user/password', { oldPassword: 'wrong', newPassword: 'newpwd999' }) - check('PUT /api/user/password (旧密码错)', r, 400, '→ 旧密码错误') + // 验证 /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) + } - r = await req('PUT', '/api/user/password', { oldPassword: '123456', newPassword: '123456' }) - check('PUT /api/user/password (新旧相同)', r, 400, '→ 新密码不能与旧密码相同') + // ══════════════════════════════════════════ + // 【3】 权限隔离测试 + // ══════════════════════════════════════════ + console.log('\n' + '━'.repeat(55)) + console.log('【3】 权限隔离测试(无权限应返回 403)') + console.log('━'.repeat(55)) - r = await req('PUT', '/api/user/password', { oldPassword: '123456', newPassword: '123' }) - check('PUT /api/user/password (新密码太短)', r, 400, '→ 新密码至少 6 位') + // 招商经理 → products(无权限) + r = await req('GET', '/api/products', null, tokens.liming) + check('liming → GET /api/products → 403', r, 403) - // ────────── 3. 用户管理 CRUD ────────── - console.log('\n━'.repeat(50)) - console.log('【3】 用户管理 CRUD(管理员)') - console.log('━'.repeat(50)) + r = await req('POST', '/api/products', { name: 'x' }, tokens.liming) + check('liming → POST /api/products → 403', r, 403) - r = await req('GET', '/api/users?page=1&pageSize=10') - check('GET /api/users (列表)', r, 200) - if (r.body?.data) console.log(` ↳ 共 ${r.body.data.total} 个用户, 本页 ${r.body.data.list?.length} 条`) + // 招商经理 → suppliers(无权限) + r = await req('GET', '/api/suppliers', null, tokens.liming) + check('liming → GET /api/suppliers → 403', r, 403) - const ts = Date.now() - const newUser = `tester_${ts}` - r = await req('POST', '/api/users', { username: newUser, password: 'pass123', real_name: '测试员', role: 'user' }) - check('POST /api/users (创建)', r, 200) - let userId = r.body?.data?.id - if (userId) console.log(` ↳ 新建用户 ID: ${userId}, 用户名: ${newUser}`) + // 招商经理 → after-sales(无权限) + r = await req('GET', '/api/after-sales', null, tokens.liming) + check('liming → GET /api/after-sales → 403', r, 403) - r = await req('POST', '/api/users', { username: 'admin', password: '123456' }) - check('POST /api/users (重复用户名)', r, 409, '→ 用户名已存在') + // 采购经理 → customers(无权限) + r = await req('GET', '/api/customers', null, tokens.zhaoqiang) + check('zhaoqiang → GET /api/customers → 403', r, 403) - r = await req('POST', '/api/users', { username: 'bad', password: '12', role: 'superman' }) - check('POST /api/users (非法角色)', r, 400, '→ 角色只能为 admin 或 user') + // 采购经理 → after-sales(无权限) + r = await req('GET', '/api/after-sales', null, tokens.zhaoqiang) + check('zhaoqiang → GET /api/after-sales → 403', r, 403) - if (userId) { - r = await req('GET', '/api/users/' + userId) - check('GET /api/users/:id (详情)', r, 200) + // 财务人员 → products(无权限) + r = await req('GET', '/api/products', null, tokens.chenfang) + check('chenfang → GET /api/products → 403', r, 403) - r = await req('PUT', '/api/users/' + userId, { real_name: '改名后的测试员', status: 1 }) - check('PUT /api/users/:id (更新姓名)', r, 200) - if (r.body?.data) console.log(` ↳ 新姓名: ${r.body.data.real_name}`) + // 财务人员 → suppliers(无权限) + r = await req('GET', '/api/suppliers', null, tokens.chenfang) + check('chenfang → GET /api/suppliers → 403', r, 403) - r = await req('PUT', '/api/users/' + userId, { password: 'newpass456' }) - check('PUT /api/users/:id (管理员重置密码)', r, 200, '→ 管理员可直接改他人密码') + // 财务人员 → 创建 customers(只有 read 权限,无 create) + r = await req('POST', '/api/customers', { name: '财务创建' }, tokens.chenfang) + check('chenfang → POST /api/customers → 403', r, 403) - // 用新用户登录验证改密成功 - const r2 = await req('POST', '/api/user/login', { username: newUser, password: 'newpass456' }, false) - check(' └ 新用户用新密码登录', r2, 200) + // 财务人员 → 创建 employees(只有 read,无 create) + r = await req('POST', '/api/employees', { name: '财务创建' }, tokens.chenfang) + check('chenfang → POST /api/employees → 403', r, 403) - // 测试非管理员访问 - const userToken = r2.body?.data?.token - if (userToken) { - const oldToken = token - token = userToken - r = await req('GET', '/api/users') - check(' └ 普通用户访问用户列表', r, 403, '→ 需要管理员权限') - token = oldToken + // 总经理 → 创建 customers(只有 read,无 create) + r = await req('POST', '/api/customers', { name: '总经理创建' }, tokens.zhangchao) + check('zhangchao → POST /api/customers → 403', r, 403) + + // 总经理 → 创建 contracts(只有 read,无 create) + r = await req('POST', '/api/contracts', { contract_name: 'x', customer_id: 1 }, tokens.zhangchao) + 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) + + // 无 token + r = await req('GET', '/api/customers', null, null) + check('无 token → GET /api/customers → 401', r, 401) + + // ══════════════════════════════════════════ + // 【4】 数据范围隔离测试 + // ══════════════════════════════════════════ + console.log('\n' + '━'.repeat(55)) + 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) + 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') + } + + // wangli 只能看到自己负责的 + r = await req('GET', '/api/customers', null, tokens.wangli) + check('wangli GET /api/customers (应只看到自己的)', 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') + } + + // admin 能看到全部 + r = await req('GET', '/api/customers', null, tokens.admin) + check('admin GET /api/customers (应看到全部)', r, 200) + if (r.body?.data) { + checkValue('admin 加盟商总数 >= 2', r.body.data.total >= 2 ? 'YES' : 'NO', 'YES') + } + + // liming 不能看 wangli 的加盟商详情 + if (custBId) { + r = await req('GET', '/api/customers/' + custBId, null, tokens.liming) + check('liming GET wangli的加盟商详情 → 404', r, 404) + } + + // liming 不能修改 wangli 的加盟商 + if (custBId) { + r = await req('PUT', '/api/customers/' + custBId, { name: '越权修改' }, tokens.liming) + check('liming PUT wangli的加盟商 → 404', r, 404) + } + + // 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) + + 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) + + // liming 只能看到招商部员工 + 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') + } + + // 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') + } + + // 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) + + // ══════════════════════════════════════════ + // 【5】 suppliers CRUD 测试 + // ══════════════════════════════════════════ + console.log('\n' + '━'.repeat(55)) + console.log('【5】 suppliers CRUD(采购经理操作)') + console.log('━'.repeat(55)) + + r = await req('POST', '/api/suppliers', { + name: '柠檬供应商-测试', contact: '张三', phone: '13800009999', + address: '四川安岳', type: '原材料', content: '优质柠檬供应商', + }, tokens.zhaoqiang) + check('zhaoqiang POST /api/suppliers (创建)', r, 200) + const supId = r.body?.data?.id + if (supId) created.suppliers.push(supId) + + r = await req('POST', '/api/suppliers', { name: '' }, tokens.zhaoqiang) + check('zhaoqiang POST /api/suppliers (缺名称)', 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, '柠檬供应商-测试') } - r = await req('DELETE', '/api/users/' + userId) - check('DELETE /api/users/:id (删除)', r, 200) + 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('GET', '/api/suppliers?name=柠檬', null, tokens.zhaoqiang) + check('zhaoqiang GET /api/suppliers?name=柠檬 (搜索)', r, 200) } - r = await req('DELETE', '/api/users/1') - check('DELETE /api/users/1 (删自己)', r, 400, '→ 不能删除自己') + // ══════════════════════════════════════════ + // 【6】 users CRUD 测试(适配新字段) + // ══════════════════════════════════════════ + console.log('\n' + '━'.repeat(55)) + console.log('【6】 users CRUD(管理员操作)') + console.log('━'.repeat(55)) - r = await req('GET', '/api/users', null, false) - check('GET /api/users (无 token)', r, 401, '→ 未登录') + 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}`) - // ────────── 4. 客户管理 ────────── - console.log('\n━'.repeat(50)) - console.log('【4】 客户管理 CRUD') - console.log('━'.repeat(50)) + // 创建新用户,关联到已有员工 + r = await req('POST', '/api/users', { + username: 'testuser_' + Date.now(), + password: 'pass123456', + role_id: 3, // franchise_manager + employee_id: empLmId, + department: '招商部', + }, tokens.admin) + check('admin POST /api/users (创建带role_id+employee_id)', 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}`) + } - r = await req('GET', '/api/customers?page=1&pageSize=10') - check('GET /api/customers (列表)', r, 200) - if (r.body?.data) console.log(` ↳ 共 ${r.body.data.total} 条`) + r = await req('GET', '/api/users/' + newUserId, null, tokens.admin) + check('admin GET /api/users/:id (详情)', r, 200) - r = await req('POST', '/api/customers', { name: '测试加盟商', phone: '13800001111', province: '广东', city: '东莞', district: '松山湖', address: '科技路88号', customer_type: 'VIP', email: 'test@test.com' }) - check('POST /api/customers (创建VIP客户)', r, 200) - let custId = r.body?.data?.id - if (custId) console.log(` ↳ 新建客户 ID: ${custId}, 类型: ${r.body.data.customer_type}`) + 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('POST', '/api/customers', { name: '' }) - check('POST /api/customers (缺姓名)', r, 400, '→ 客户姓名必填') + r = await req('PUT', '/api/users/' + newUserId, { is_active: 1, password: 'newpass123' }, tokens.admin) + check('admin PUT /api/users/:id (启用+重置密码)', r, 200) + } - // 不传 customer_type 应为默认 Normal - r = await req('POST', '/api/customers', { name: '默认类型测试' }) - check('POST /api/customers (不传类型默认Normal)', r, 200) - if (r.body?.data) console.log(` ↳ 默认类型: ${r.body.data.customer_type}`) - if (r.body?.data?.id) await req('DELETE', '/api/customers/' + r.body.data.id) + // admin 不能删除自己(先获取 admin 的真实 ID) + const adminUser = allUsers.body?.data?.list?.find(u => u.username === 'admin') + const adminUserId = adminUser?.id - if (custId) { - r = await req('GET', '/api/customers/' + custId) + r = await req('DELETE', '/api/users/' + adminUserId, null, tokens.admin) + check('admin DELETE /api/users/' + adminUserId + ' (删自己) → 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) + + // ══════════════════════════════════════════ + // 【7】 customers CRUD 测试 + // ══════════════════════════════════════════ + console.log('\n' + '━'.repeat(55)) + console.log('【7】 customers CRUD') + console.log('━'.repeat(55)) + + r = await req('POST', '/api/customers', { + name: 'CRUD测试加盟商', phone: '13800007777', province: '河南', city: '郑州', + customer_type: 'VIP', email: 'test@mixue.com', + }, tokens.admin) + check('admin POST /api/customers (创建)', r, 200) + const custCrudId = r.body?.data?.id + if (custCrudId) { + created.customers.push(custCrudId) + checkValue('默认 responsible_user_id = admin', r.body.data.responsible_user_id, adminUserId) + } + + r = await req('POST', '/api/customers', { name: '' }, tokens.admin) + check('POST /api/customers (缺名称)', r, 400) + + if (custCrudId) { + r = await req('GET', '/api/customers/' + custCrudId, null, tokens.admin) check('GET /api/customers/:id (详情)', r, 200) - r = await req('PUT', '/api/customers/' + custId, { phone: '13900002222', customer_type: 'Normal', remark: '更新备注' }) - check('PUT /api/customers/:id (更新类型+电话)', r, 200) - if (r.body?.data) console.log(` ↳ 更新后类型: ${r.body.data.customer_type}, 电话: ${r.body.data.phone}`) + 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=测试') - check('GET /api/customers?name=测试 (按姓名搜索)', r, 200) + r = await req('GET', '/api/customers?name=CRUD', null, tokens.admin) + check('GET /api/customers?name=CRUD (搜索)', r, 200) - r = await req('GET', '/api/customers?customer_type=Normal') - check('GET /api/customers?customer_type=Normal (按类型筛选)', r, 200) - if (r.body?.data) console.log(` ↳ Normal 类型共 ${r.body.data.total} 条`) - - r = await req('DELETE', '/api/customers/' + custId) - check('DELETE /api/customers/:id (删除)', r, 200) + r = await req('GET', '/api/customers?customer_type=VIP', null, tokens.admin) + check('GET /api/customers?customer_type=VIP (筛选)', r, 200) } - r = await req('GET', '/api/customers/99999') - check('GET /api/customers/99999 (不存在)', r, 404, '→ 客户不存在') + r = await req('GET', '/api/customers/99999', null, tokens.admin) + check('GET /api/customers/99999 (不存在)', r, 404) - // ────────── 5. 员工管理 ────────── - console.log('\n━'.repeat(50)) - console.log('【5】 员工管理 CRUD') - console.log('━'.repeat(50)) + // ══════════════════════════════════════════ + // 【8】 employees CRUD 测试 + // ══════════════════════════════════════════ + console.log('\n' + '━'.repeat(55)) + console.log('【8】 employees CRUD') + console.log('━'.repeat(55)) - r = await req('GET', '/api/employees?page=1&pageSize=10') - check('GET /api/employees (列表)', r, 200) + r = await req('POST', '/api/employees', { + name: 'CRUD测试员工', gender: '男', age: 28, education: '本科', + department: '品控部', position: '品控专员', salary: 12000, + phone: '13700005555', email: 'emp@mixue.com', status: 1, + }, tokens.admin) + check('admin POST /api/employees (创建)', r, 200) + const empCrudId = r.body?.data?.id + if (empCrudId) created.employees.push(empCrudId) - r = await req('POST', '/api/employees', { name: '测试员工', gender: '男', age: 30, education: '本科', department: '研发部', position: '工程师', salary: 15000, phone: '13700000001', email: 'emp@test.com' }) - check('POST /api/employees (创建)', r, 200) - let empId = r.body?.data?.id - if (empId) console.log(` ↳ 新建员工 ID: ${empId}, 部门: ${r.body.data.department}`) - - if (empId) { - r = await req('GET', '/api/employees/' + empId) + if (empCrudId) { + r = await req('GET', '/api/employees/' + empCrudId, null, tokens.admin) check('GET /api/employees/:id (详情)', r, 200) - r = await req('PUT', '/api/employees/' + empId, { salary: 18000, position: '高级工程师' }) + 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') + r = await req('GET', '/api/employees?status=1', null, tokens.admin) check('GET /api/employees?status=1 (在职筛选)', r, 200) - r = await req('DELETE', '/api/employees/' + empId) - check('DELETE /api/employees/:id (删除)', r, 200) + r = await req('GET', '/api/employees?department=品控部', null, tokens.admin) + check('GET /api/employees?department=品控部 (部门筛选)', r, 200) } - // ────────── 6. 产品管理 ────────── - console.log('\n━'.repeat(50)) - console.log('【6】 产品管理 CRUD') - console.log('━'.repeat(50)) + // ══════════════════════════════════════════ + // 【9】 products CRUD 测试 + // ══════════════════════════════════════════ + console.log('\n' + '━'.repeat(55)) + console.log('【9】 products CRUD(采购经理操作)') + console.log('━'.repeat(55)) - r = await req('GET', '/api/products?page=1&pageSize=10') - check('GET /api/products (列表)', r, 200) + r = await req('POST', '/api/products', { + name: '安岳柠檬-测试', type: '原材料', quantity: 10000, + price: 5.50, unit: 'kg', specification: '特级', supplier: '安岳柠檬基地', + }, tokens.zhaoqiang) + check('zhaoqiang POST /api/products (创建)', r, 200) + const prodId = r.body?.data?.id + if (prodId) created.products.push(prodId) - r = await req('POST', '/api/products', { name: '测试产品-X1', type: '电子产品', quantity: 500, price: 299.99, unit: '台', specification: '型号 X1-2026', supplier: '供应商A' }) - check('POST /api/products (创建)', r, 200) - let prodId = r.body?.data?.id - if (prodId) console.log(` ↳ 新建产品 ID: ${prodId}, 库存: ${r.body.data.quantity}`) + r = await req('POST', '/api/products', { name: '' }, tokens.zhaoqiang) + check('POST /api/products (缺名称)', r, 400) if (prodId) { - r = await req('GET', '/api/products/' + prodId) + r = await req('GET', '/api/products/' + prodId, null, tokens.zhaoqiang) check('GET /api/products/:id (详情)', r, 200) - r = await req('PUT', '/api/products/' + prodId, { price: 259.99, quantity: 480 }) - check('PUT /api/products/:id (更新价格库存)', r, 200) + r = await req('PUT', '/api/products/' + prodId, { price: 6.00, quantity: 8000 }, tokens.zhaoqiang) + check('PUT /api/products/:id (更新)', r, 200) - r = await req('GET', '/api/products?name=测试') - check('GET /api/products?name=测试 (搜索)', r, 200) + r = await req('GET', '/api/products?name=柠檬', null, tokens.zhaoqiang) + check('GET /api/products?name=柠檬 (搜索)', r, 200) - r = await req('DELETE', '/api/products/' + prodId) - check('DELETE /api/products/:id (删除)', r, 200) + r = await req('GET', '/api/products?type=原材料', null, tokens.zhaoqiang) + check('GET /api/products?type=原材料 (筛选)', r, 200) } - // ────────── 7. 合同管理 ────────── - console.log('\n━'.repeat(50)) - console.log('【7】 合同管理 CRUD(关联客户+员工)') - console.log('━'.repeat(50)) + // ══════════════════════════════════════════ + // 【10】 contracts CRUD 测试 + // ══════════════════════════════════════════ + console.log('\n' + '━'.repeat(55)) + console.log('【10】 contracts CRUD(含 type 字段)') + console.log('━'.repeat(55)) - // 先创建客户和员工做外键 - const cRes = await req('POST', '/api/customers', { name: '合同测试客户' }) - const eRes = await req('POST', '/api/employees', { name: '合同业务员' }) - const cId = cRes.body?.data?.id - const eId = eRes.body?.data?.id - console.log(` ↳ 先创建客户(${cId}) 和 员工(${eId}) 供合同关联`) - - r = await req('POST', '/api/contracts', { customer_id: cId, contract_name: '年度供货协议', contract_no: 'HT-2026-088', amount: 500000, effective_date: '2026-06-01', expiry_date: '2027-05-31', employee_id: eId, status: '生效' }) - check('POST /api/contracts (创建)', r, 200) - let conId = r.body?.data?.id - if (conId) { - console.log(` ↳ 新建合同 ID: ${conId}`) - if (r.body?.data?.customer_name) console.log(` ↳ 联查客户: ${r.body.data.customer_name}, 业务员: ${r.body.data.employee_name}`) + // 采购经理创建合同 → 自动 type='supply' + r = await req('POST', '/api/contracts', { + customer_id: custAId, contract_name: '原材料采购合同', amount: 500000, + effective_date: '2026-06-01', expiry_date: '2027-05-31', status: '生效', + }, tokens.zhaoqiang) + check('zhaoqiang POST /api/contracts (自动type=supply)', r, 200) + if (r.body?.data) { + checkValue('合同type自动设为supply', r.body.data.type, 'supply') + created.contracts.push(r.body.data.id) } - r = await req('POST', '/api/contracts', { customer_id: 99999, contract_name: '无效合同' }) - check('POST /api/contracts (客户不存在)', r, 400, '→ 关联客户不存在') - - if (conId) { - r = await req('GET', '/api/contracts') - check('GET /api/contracts (列表)', r, 200) - - r = await req('GET', '/api/contracts/' + conId) - check('GET /api/contracts/:id (详情)', r, 200) - - r = await req('PUT', '/api/contracts/' + conId, { status: '完成', remark: '已履约' }) - check('PUT /api/contracts/:id (更新状态)', r, 200) - - r = await req('GET', '/api/contracts?status=完成') - check('GET /api/contracts?status=完成 (状态筛选)', r, 200) - - r = await req('DELETE', '/api/contracts/' + conId) - check('DELETE /api/contracts/:id (删除)', r, 200) + // 管理员创建合同 → 默认 type='franchise' + r = await req('POST', '/api/contracts', { + customer_id: custAId, contract_name: '加盟协议-测试', amount: 300000, + effective_date: '2026-06-01', expiry_date: '2027-05-31', status: '生效', + employee_id: empLmId, + }, tokens.admin) + check('admin POST /api/contracts (默认type=franchise)', r, 200) + if (r.body?.data) { + checkValue('合同type默认franchise', r.body.data.type, 'franchise') + check('合同有 customer_name 联查', { status: r.body.data.customer_name ? 200 : 500 }, 200) + created.contracts.push(r.body.data.id) } - // 清理外键依赖数据 - if (cId) await req('DELETE', '/api/customers/' + cId) - if (eId) await req('DELETE', '/api/employees/' + eId) + r = await req('POST', '/api/contracts', { customer_id: 99999, contract_name: '无效' }, tokens.admin) + check('POST /api/contracts (客户不存在)', r, 400) - // ────────── 8. 售后管理 ────────── - console.log('\n━'.repeat(50)) - console.log('【8】 售后管理 CRUD(关联客户+员工)') - console.log('━'.repeat(50)) + r = await req('GET', '/api/contracts', null, tokens.admin) + check('GET /api/contracts (列表)', r, 200) - const c2 = await req('POST', '/api/customers', { name: '售后测试客户' }) - const e2 = await req('POST', '/api/employees', { name: '售后处理员' }) - const cId2 = c2.body?.data?.id - const eId2 = e2.body?.data?.id - console.log(` ↳ 先创建客户(${cId2}) 和 员工(${eId2})`) + // ══════════════════════════════════════════ + // 【11】 after-sales CRUD 测试 + // ══════════════════════════════════════════ + console.log('\n' + '━'.repeat(55)) + console.log('【11】 after-sales CRUD') + console.log('━'.repeat(55)) - r = await req('POST', '/api/after-sales', { customer_id: cId2, feedback: '设备运行异常,需技术支持', employee_id: eId2, handle_method: '更换故障模块', handle_status: '处理中', service_date: '2026-06-20' }) - check('POST /api/after-sales (创建)', r, 200) - let asId = r.body?.data?.id + r = await req('POST', '/api/after-sales', { + customer_id: custAId, feedback: '冰淇淋机不出料', + employee_id: empWlId, 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) { - console.log(` ↳ 新建售后 ID: ${asId}`) - if (r.body?.data?.customer_name) console.log(` ↳ 联查客户: ${r.body.data.customer_name}, 处理人: ${r.body.data.employee_name}`) + created.afterSales.push(asId) + check('售后有 customer_name 联查', { status: r.body.data.customer_name ? 200 : 500 }, 200) } - r = await req('POST', '/api/after-sales', { customer_id: 99999, feedback: '测试' }) - check('POST /api/after-sales (客户不存在)', r, 400, '→ 关联客户不存在') + r = await req('POST', '/api/after-sales', { customer_id: 99999, feedback: '测试' }, tokens.wangli) + check('POST /api/after-sales (客户不存在)', r, 400) + + r = await req('POST', '/api/after-sales', { customer_id: custAId }, tokens.wangli) + check('POST /api/after-sales (缺feedback)', r, 400) if (asId) { - r = await req('GET', '/api/after-sales') - check('GET /api/after-sales (列表)', r, 200) - - r = await req('GET', '/api/after-sales/' + asId) + r = await req('GET', '/api/after-sales/' + asId, null, tokens.wangli) check('GET /api/after-sales/:id (详情)', r, 200) - r = await req('PUT', '/api/after-sales/' + asId, { handle_status: '已完成', handle_method: '远程升级固件解决' }) + r = await req('PUT', '/api/after-sales/' + asId, { handle_status: '已完成', handle_method: '更换密封圈' }, tokens.wangli) check('PUT /api/after-sales/:id (更新)', r, 200) - r = await req('GET', '/api/after-sales?handle_status=已完成') - check('GET /api/after-sales?handle_status=已完成 (状态筛选)', r, 200) - - r = await req('DELETE', '/api/after-sales/' + asId) - check('DELETE /api/after-sales/:id (删除)', r, 200) + r = await req('GET', '/api/after-sales?handle_status=已完成', null, tokens.wangli) + check('GET /api/after-sales?handle_status=已完成 (筛选)', r, 200) } - if (cId2) await req('DELETE', '/api/customers/' + cId2) - if (eId2) await req('DELETE', '/api/employees/' + eId2) + // ══════════════════════════════════════════ + // 【12】 个人信息 & 改密 + // ══════════════════════════════════════════ + console.log('\n' + '━'.repeat(55)) + console.log('【12】 个人信息 & 改密') + console.log('━'.repeat(55)) - // ────────── 收尾 ────────── - console.log('\n' + '═'.repeat(50)) + 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) + + r = await req('PUT', '/api/user/password', { oldPassword: '123456', newPassword: '123456' }, tokens.admin) + check('PUT /api/user/password (新旧相同)', r, 400) + + r = await req('PUT', '/api/user/password', { oldPassword: '123456', newPassword: '123' }, tokens.admin) + check('PUT /api/user/password (新密码太短)', r, 400) + + // ══════════════════════════════════════════ + // 【13】 数据清理 + // ══════════════════════════════════════════ + console.log('\n' + '━'.repeat(55)) + console.log('【13】 测试数据清理') + console.log('━'.repeat(55)) + + // 按依赖顺序删除 + for (const id of created.afterSales) { + await req('DELETE', '/api/after-sales/' + id, null, tokens.admin) + } + for (const id of created.contracts) { + await req('DELETE', '/api/contracts/' + id, null, tokens.admin) + } + for (const id of created.customers) { + await req('DELETE', '/api/customers/' + id, null, tokens.admin) + } + for (const id of created.employees) { + await req('DELETE', '/api/employees/' + id, null, tokens.admin) + } + for (const id of created.products) { + await req('DELETE', '/api/products/' + id, null, tokens.admin) + } + for (const id of created.suppliers) { + await req('DELETE', '/api/suppliers/' + id, null, tokens.admin) + } + for (const id of created.users) { + await req('DELETE', '/api/users/' + id, null, tokens.admin) + } + console.log(' 🧹 清理完成') + + // ══════════════════════════════════════════ + // 收尾 + // ══════════════════════════════════════════ + console.log('\n' + '═'.repeat(55)) console.log(`测试完成: 通过 ${passCount} / 失败 ${failCount}`) if (failCount > 0) { console.log('⚠️ 存在失败用例,请检查上方输出') } else { console.log('🎉 全部接口通过!') } - console.log('═'.repeat(50)) + console.log('═'.repeat(55)) - // 优雅退出 process.exit(failCount > 0 ? 1 : 0) }