checkLogin(); $user = $this->getCurrentUser(); // 只有超级管理员 或 MES类目的管理员可访问 if ($user['role'] === self::ROLE_SUPER_ADMIN) { // 超级管理员可以访问 } elseif ($user['role'] === self::ROLE_ADMIN && $user['category'] === self::CATEGORY_MES) { // MES 类目管理员可以访问 } else { header('Location: ' . BASE_URL . '/Front/index'); exit; } $config = new SysConfig(); $sysConfig = $config->getConfig(); // 统计数据 $employee = new Employee(); $employeeCount = count($employee->getAll()); $customer = new Customer(); $customerCount = count($customer->getAll()); $productModel = new ProductModel(); $modelCount = count($productModel->getAll()); $workstation = new Workstation(); $stationCount = count($workstation->getAll()); $this->assign('title', '后台管理'); $this->assign('user', $user); $this->assign('sysConfig', $sysConfig); $this->assign('employeeCount', $employeeCount); $this->assign('customerCount', $customerCount); $this->assign('modelCount', $modelCount); $this->assign('stationCount', $stationCount); $this->render(); } // 员工管理 public function employee() { $this->checkLogin(); $user = $this->getCurrentUser(); // 仅超级管理员可访问员工管理 if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) { exit('无权限访问'); } $employee = new Employee(); $employees = $employee->getAll(); // 管理员看不到超级管理员(比自己权限高的不可见) if ($user['role'] === self::ROLE_ADMIN) { $employees = array_filter($employees, function($emp) { return $emp['role'] !== self::ROLE_SUPER_ADMIN; }); $employees = array_values($employees); // re-index } // Flash 消息(一次性提示,如密码生成提示) $flash = $_SESSION['flash_message'] ?? null; if ($flash) { unset($_SESSION['flash_message']); } $this->assign('csrfToken', $this->csrfToken()); $this->assign('title', '员工管理'); $this->assign('user', $user); $this->assign('employees', $employees); $this->assign('flash', $flash); $this->render(); } public function employeeAdd() { $this->checkLogin(); $user = $this->getCurrentUser(); // 只有超级管理员可以添加员工(管理员由超管分配类目) if ($user['role'] !== self::ROLE_SUPER_ADMIN) { exit('无权限访问'); } if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->csrfVerify(); // 输入验证 $empName = trim($_POST['emp_name'] ?? ''); if (empty($empName) || mb_strlen($empName) > 50) { exit('员工姓名无效'); } $role = $_POST['role']; $category = $_POST['category'] ?? self::CATEGORY_MES; // 管理员和操作员必须分配类目 if ($role !== self::ROLE_SUPER_ADMIN && empty($category)) { exit('请为管理员/操作员选择管理类目'); } // 自动生成员工编号:ZXY + 三位流水号 $emp_no = $this->generateEmpNo(); $data = array( 'emp_no' => $emp_no, 'emp_name' => $empName, 'password' => $_POST['password'] ?? '', 'role' => $role, 'category' => ($role === self::ROLE_SUPER_ADMIN) ? self::CATEGORY_ALL : $category, 'created_by' => $user['emp_name'] ); try { $employee = new Employee(); $id = $employee->addEmployee($data); if (!$id) { // 插入失败(如编号重复),抛出异常让 catch 处理 $dbErr = $employee->getError() ?: '未知数据库错误'; throw new \RuntimeException('添加员工失败:' . $dbErr); } $genPwd = $employee->getLastGeneratedPwd(); if ($genPwd) { // 自动生成密码时,跳转到列表并显示密码提示 $_SESSION['flash_message'] = "员工 {$empName}({$emp_no})已创建,初始密码为:{$genPwd},请通知该员工首次登录后修改密码"; } } catch (\RuntimeException $e) { $this->assign('error', $e->getMessage()); $this->assign('title', '添加员工'); $this->assign('user', $user); $this->assign('manageableRoles', self::getManageableRoles($user['role'])); $this->assign('nextEmpNo', $emp_no); $this->assign('csrfToken', $this->csrfToken()); $this->render(); return; } header('Location: ' . BASE_URL . '/Admin/employee'); exit; } // 传递可选角色列表和类目列表到视图 $manageableRoles = self::getManageableRoles($user['role']); $categoryOptions = self::getCategoryOptions(); // 预览下一个编号 $nextEmpNo = $this->generateEmpNo(); $this->assign('title', '添加员工'); $this->assign('user', $user); $this->assign('manageableRoles', $manageableRoles); $this->assign('categoryOptions', $categoryOptions); $this->assign('nextEmpNo', $nextEmpNo); $this->assign('csrfToken', $this->csrfToken()); $this->render(); } public function employeeEdit($id) { $this->checkLogin(); $user = $this->getCurrentUser(); // 只有超级管理员可以编辑 if ($user['role'] !== self::ROLE_SUPER_ADMIN) { exit('无权限访问'); } $employee = new Employee(); $emp = $employee->where(['id = :id'], [':id' => $id])->fetch(); if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->csrfVerify(); try { $newRole = $_POST['role']; $newCategory = $_POST['category'] ?? self::CATEGORY_MES; $data = array( 'emp_name' => $_POST['emp_name'], 'role' => $newRole, 'category' => ($newRole === self::ROLE_SUPER_ADMIN) ? self::CATEGORY_ALL : $newCategory ); $employee->updateEmployee($id, $data); header('Location: ' . BASE_URL . '/Admin/employee'); exit; } catch (\Throwable $e) { error_log('[employeeEdit] ' . $e->getMessage()); $this->assign('error', '更新失败:' . $e->getMessage()); $this->assign('title', '编辑员工'); $this->assign('user', $user); $this->assign('emp', $emp); $manageableRoles = self::getManageableRoles($user['role']); $categoryOptions = self::getCategoryOptions(); $this->assign('manageableRoles', $manageableRoles); $this->assign('categoryOptions', $categoryOptions); $this->assign('csrfToken', $this->csrfToken()); $this->render(); return; } } $manageableRoles = self::getManageableRoles($user['role']); $categoryOptions = self::getCategoryOptions(); $this->assign('title', '编辑员工'); $this->assign('user', $user); $this->assign('emp', $emp); $this->assign('manageableRoles', $manageableRoles); $this->assign('categoryOptions', $categoryOptions); $this->assign('csrfToken', $this->csrfToken()); $this->render(); } public function employeeDelete($id) { $this->checkLogin(); $this->requireMethod('POST'); $this->csrfVerify(); $user = $this->getCurrentUser(); // 只有超级管理员可以删除 if ($user['role'] !== self::ROLE_SUPER_ADMIN) { exit('无权限访问'); } $employee = new Employee(); $emp = $employee->where(['id = :id'], [':id' => $id])->fetch(); // 不能删除自己 if ((int)$emp['id'] === (int)$user['id']) { exit('不能删除自己的账号'); } $employee->delete($id); header('Location: ' . BASE_URL . '/Admin/employee'); } public function employeeResetPwd($id) { $this->checkLogin(); $user = $this->getCurrentUser(); // 只有超级管理员可以重置密码 if ($user['role'] !== self::ROLE_SUPER_ADMIN) { exit('无权限访问'); } $employee = new Employee(); $emp = $employee->where(['id = :id'], [':id' => $id])->fetch(); if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->csrfVerify(); try { $new_pwd = $_POST['new_password']; $employee = new Employee(); $employee->resetPassword($id, $new_pwd); header('Location: ' . BASE_URL . '/Admin/employee'); exit; } catch (\Throwable $e) { error_log('[employeeResetPwd] ' . $e->getMessage()); $this->assign('error', '重置密码失败:' . $e->getMessage()); $this->assign('title', '重置密码'); $this->assign('user', $user); $this->assign('emp', $emp); $this->assign('csrfToken', $this->csrfToken()); $this->render(); return; } } $this->assign('title', '重置密码'); $this->assign('user', $user); $this->assign('emp', $emp); $this->assign('csrfToken', $this->csrfToken()); $this->render(); } /** * 设置员工工位权限 */ public function employeePermission($id) { $this->checkLogin(); $user = $this->getCurrentUser(); // 只有超级管理员可以设置工位权限 if ($user['role'] !== self::ROLE_SUPER_ADMIN) { exit('无权限访问'); } $employee = new Employee(); $emp = $employee->where(['id = :id'], [':id' => $id])->fetch(); if (!$emp) { exit('员工不存在'); } $workstation = new Workstation(); $allStations = $workstation->getAll(); $permModel = new EmployeePermission(); if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->csrfVerify(); $permissions = []; // $_POST['allow'] 是勾选的工位类型数组 $allowedTypes = isset($_POST['allow']) ? (array)$_POST['allow'] : []; foreach ($allStations as $station) { $stype = $station['station_type']; $permissions[$stype] = in_array($stype, $allowedTypes) ? 1 : 0; } $permModel->savePermissions($id, $permissions); header('Location: ' . BASE_URL . '/Admin/employeePermission/' . $id . '?saved=1'); exit; } // 获取当前权限(表不存在时友好提示,而非 500) try { $currentPermissions = $permModel->getByEmployee($id); } catch (\PDOException $e) { exit('数据表 DGZXY_employee_station_permission 不存在,请在数据库中执行 sql/create_employee_station_permission.sql'); } $saved = isset($_GET['saved']) && $_GET['saved'] === '1'; $this->assign('title', '工位权限设置 - ' . $emp['emp_name']); $this->assign('user', $user); $this->assign('emp', $emp); $this->assign('stations', $allStations); $this->assign('currentPermissions', $currentPermissions); $this->assign('saved', $saved); $this->assign('activeMenu', 'employee'); $this->assign('csrfToken', $this->csrfToken()); $this->render(); } // 客户管理 — 超级管理员和管理员 public function customer() { $this->checkLogin(); $user = $this->getCurrentUser(); if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) { exit('无权限访问'); } $customer = new Customer(); $customers = $customer->getAll(); $this->assign('title', '客户管理'); $this->assign('user', $user); $this->assign('customers', $customers); $this->assign('csrfToken', $this->csrfToken()); $this->render(); } public function customerAdd() { $this->checkLogin(); $user = $this->getCurrentUser(); if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) { exit('无权限访问'); } if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->csrfVerify(); try { $data = array( 'company_name' => $_POST['company_name'], 'contact1' => $_POST['contact1'], 'phone1' => $_POST['phone1'], 'contact2' => $_POST['contact2'], 'phone2' => $_POST['phone2'], 'created_by' => $user['emp_name'] ); $customer = new Customer(); $customer->addCustomer($data); header('Location: ' . BASE_URL . '/Admin/customer'); exit; } catch (\Throwable $e) { error_log('[customerAdd] ' . $e->getMessage()); $this->assign('error', '添加失败:' . $e->getMessage()); $this->assign('title', '添加客户'); $this->assign('user', $user); $this->assign('csrfToken', $this->csrfToken()); $this->render(); return; } } $this->assign('title', '添加客户'); $this->assign('user', $user); $this->assign('csrfToken', $this->csrfToken()); $this->render(); } public function customerEdit($id) { $this->checkLogin(); $user = $this->getCurrentUser(); if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) { exit('无权限访问'); } $customer = new Customer(); $cust = $customer->where(['id = :id'], [':id' => $id])->fetch(); if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->csrfVerify(); try { $data = array( 'company_name' => $_POST['company_name'], 'contact1' => $_POST['contact1'], 'phone1' => $_POST['phone1'], 'contact2' => $_POST['contact2'], 'phone2' => $_POST['phone2'] ); $customer->updateCustomer($id, $data); header('Location: ' . BASE_URL . '/Admin/customer'); exit; } catch (\Throwable $e) { error_log('[customerEdit] ' . $e->getMessage()); $this->assign('error', '更新失败:' . $e->getMessage()); $this->assign('title', '编辑客户'); $this->assign('user', $user); $this->assign('cust', $cust); $this->assign('csrfToken', $this->csrfToken()); $this->render(); return; } } $this->assign('title', '编辑客户'); $this->assign('user', $user); $this->assign('cust', $cust); $this->assign('csrfToken', $this->csrfToken()); $this->render(); } public function customerDelete($id) { $this->checkLogin(); $this->requireMethod('POST'); $this->csrfVerify(); $user = $this->getCurrentUser(); if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) { exit('无权限访问'); } $customer = new Customer(); $customer->delete($id); header('Location: ' . BASE_URL . '/Admin/customer'); } // 产品型号管理 — 仅超级管理员 public function productModel() { $this->checkLogin(); $user = $this->getCurrentUser(); if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) { exit('无权限访问'); } $productModel = new ProductModel(); $allModels = $productModel->getAll(); // 按 type 分组 $grouped = []; foreach ($allModels as $m) { $t = $m['type'] ?: '未分类'; $grouped[$t][] = $m; } // 从数据库读取类型配置(排序),表不存在时回退到硬编码默认值 $typeConfigModel = new ProductTypeConfig(); $typeConfigs = $typeConfigModel->getAll(); if (empty($typeConfigs)) { $typeConfigs = ProductTypeConfig::getDefaultConfigs(); } $orderedTypeNames = []; $typeColorMap = []; foreach ($typeConfigs as $cfg) { $orderedTypeNames[] = $cfg['type_name']; $typeColorMap[$cfg['type_name']] = $cfg['color'] ?? 'default'; } // 按配置的顺序排列,未配置的追加在后面 $ordered = []; foreach ($orderedTypeNames as $t) { if (isset($grouped[$t])) { $ordered[$t] = $grouped[$t]; unset($grouped[$t]); } } $ordered += $grouped; // 剩下的(包括"未分类")追加在后面 $this->assign('title', '产品型号管理'); $this->assign('user', $user); $this->assign('groupedModels', $ordered); $this->assign('typeConfigs', $typeConfigs); $this->assign('typeColorMap', $typeColorMap); $this->assign('csrfToken', $this->csrfToken()); $this->render(); } public function productModelAdd() { $this->checkLogin(); $user = $this->getCurrentUser(); if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) { exit('无权限访问'); } if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->csrfVerify(); $type = trim($_POST['type'] ?? ''); $product_type = trim($_POST['product_type'] ?? ''); $capacity = trim($_POST['capacity'] ?? ''); $color = trim($_POST['color'] ?? ''); $model_code = trim($_POST['model_code'] ?? ''); $model_desc = trim($_POST['description'] ?? ''); // 从数据库获取类型配置,表不存在时回退到默认配置 $typeConfigModel = new ProductTypeConfig(); $typeConfig = $typeConfigModel->getByTypeName($type); if (!$typeConfig) { // 数据库配置不可用时,使用硬编码默认配置验证类型 $defaultConfigs = ProductTypeConfig::getDefaultConfigs(); $typeNames = []; foreach ($defaultConfigs as $dc) { $typeNames[] = $dc['type_name']; if ($dc['type_name'] === $type) { $typeConfig = $dc; } } if (!$typeConfig) { $this->assign('error', '类型无效,请选择:' . implode('、', $typeNames)); $this->assign('title', '添加产品型号'); $this->assign('user', $user); $this->assign('csrfToken', $this->csrfToken()); $this->render(); return; } } // 根据配置决定 product_type 和 capacity 值 $productTypeField = $typeConfig['product_type_field'] ?? 'product_type'; if ($productTypeField === 'capacity' && $capacity !== '') { // 电池类型:capacity 存入 capacity 字段,product_type 留空 $product_type = ''; } elseif ($productTypeField === 'color' && $color !== '') { $product_type = $color; } // 隐藏产品类型输入框的类型 → 设为空 if (($typeConfig['product_type_state'] ?? 'editable') === 'hidden') { $product_type = ''; } // model_code 输入框隐藏时,用 product_type 值自动填充(如"外壳颜色"类型) if (($typeConfig['model_code_state'] ?? 'editable') === 'hidden' && $model_code === '') { $model_code = $product_type ?: $color; } $productModel = new ProductModel(); try { // 检测同一产品类型+类型下型号代码是否已存在 $exist = $productModel->findByTypeAndCode($type, $product_type, $model_code); if ($exist) { $this->assign('error', '该类型+产品类型下型号「' . htmlspecialchars($model_code) . '」已存在,请勿重复录入!'); $this->assign('title', '添加产品型号'); $this->assign('user', $user); $this->assign('csrfToken', $this->csrfToken()); $this->render(); return; } $data = array( 'type' => $type, 'product_type' => $product_type, 'model_code' => $model_code, 'model_desc' => $model_desc, 'capacity' => $capacity, 'color' => $color, ); $result = $productModel->addModel($data); if ($result === false) { $err = $productModel->getError(); $this->assign('error', '添加失败:' . ($err ?: '未知错误')); $this->assign('title', '添加产品型号'); $this->assign('user', $user); $this->assign('csrfToken', $this->csrfToken()); $this->render(); return; } } catch (\Throwable $e) { error_log('[productModelAdd] ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); $this->assign('error', '添加失败,数据库错误:' . $e->getMessage()); $this->assign('title', '添加产品型号'); $this->assign('user', $user); $this->assign('csrfToken', $this->csrfToken()); $this->render(); return; } header('Location: ' . BASE_URL . '/Admin/productModel'); exit; } // 传入类型配置列表给视图,表不存在时回退到硬编码默认值 $typeConfigModel = new ProductTypeConfig(); $typeConfigs = $typeConfigModel->getEnabled(); if (empty($typeConfigs)) { $typeConfigs = ProductTypeConfig::getDefaultConfigs(); } $this->assign('title', '添加产品型号'); $this->assign('user', $user); $this->assign('typeConfigs', $typeConfigs); $this->assign('csrfToken', $this->csrfToken()); $this->render(); } public function productModelEdit($id) { $this->checkLogin(); $user = $this->getCurrentUser(); if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) { exit('无权限访问'); } $productModel = new ProductModel(); $model = $productModel->where(['id = :id'], [':id' => $id])->fetch(); if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->csrfVerify(); $type = trim($_POST['type'] ?? ''); $product_type = trim($_POST['product_type'] ?? ''); $capacity = trim($_POST['capacity'] ?? ''); $color = trim($_POST['color'] ?? ''); $model_code = trim($_POST['model_code'] ?? ''); $model_desc = trim($_POST['description'] ?? ''); // 从数据库获取类型配置,表不存在时回退到默认配置 $typeConfigModel = new ProductTypeConfig(); $typeConfig = $typeConfigModel->getByTypeName($type); if (!$typeConfig) { // 数据库配置不可用时,使用硬编码默认配置验证类型 $defaultConfigs = ProductTypeConfig::getDefaultConfigs(); $typeNames = []; foreach ($defaultConfigs as $dc) { $typeNames[] = $dc['type_name']; if ($dc['type_name'] === $type) { $typeConfig = $dc; } } if (!$typeConfig) { $this->assign('error', '类型无效,请选择:' . implode('、', $typeNames)); $this->assign('title', '编辑产品型号'); $this->assign('user', $user); $this->assign('model', $model); $this->assign('csrfToken', $this->csrfToken()); $this->render(); return; } } // 根据配置决定 product_type 和 capacity 值 $productTypeField = $typeConfig['product_type_field'] ?? 'product_type'; if ($productTypeField === 'capacity' && $capacity !== '') { // 电池类型:capacity 存入 capacity 字段,product_type 留空 $product_type = ''; } elseif ($productTypeField === 'color' && $color !== '') { $product_type = $color; } // 隐藏产品类型输入框的类型 → 设为空 if (($typeConfig['product_type_state'] ?? 'editable') === 'hidden') { $product_type = ''; } try { // 检测同一类型+产品类型下型号代码是否已存在(排除自身) $exist = $productModel->findByTypeAndCode($type, $product_type, $model_code); if ($exist && $exist['id'] != $id) { $this->assign('error', '该类型+产品类型下型号「' . htmlspecialchars($model_code) . '」已存在,请勿重复录入!'); $this->assign('title', '编辑产品型号'); $this->assign('user', $user); $this->assign('model', $model); $this->assign('csrfToken', $this->csrfToken()); $this->render(); return; } $data = array( 'type' => $type, 'product_type' => $product_type, 'model_code' => $model_code, 'model_desc' => $model_desc, 'capacity' => $capacity, 'color' => $color, ); $result = $productModel->updateModel($id, $data); if ($result === false) { $err = $productModel->getError(); $this->assign('error', '更新失败:' . ($err ?: '未知错误')); $this->assign('title', '编辑产品型号'); $this->assign('user', $user); $this->assign('model', $model); $this->assign('csrfToken', $this->csrfToken()); $this->render(); return; } } catch (\Throwable $e) { error_log('[productModelEdit] ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); $this->assign('error', '更新失败,数据库错误:' . $e->getMessage()); $this->assign('title', '编辑产品型号'); $this->assign('user', $user); $this->assign('model', $model); $this->assign('csrfToken', $this->csrfToken()); $this->render(); return; } header('Location: ' . BASE_URL . '/Admin/productModel'); exit; } if (!$model) { exit('产品型号不存在或已被删除'); } // 传入类型配置列表给视图,表不存在时回退到硬编码默认值 $typeConfigModel = new ProductTypeConfig(); $typeConfigs = $typeConfigModel->getEnabled(); if (empty($typeConfigs)) { $typeConfigs = ProductTypeConfig::getDefaultConfigs(); } $this->assign('title', '编辑产品型号'); $this->assign('user', $user); $this->assign('model', $model); $this->assign('typeConfigs', $typeConfigs); $this->assign('csrfToken', $this->csrfToken()); $this->render(); } public function productModelDelete($id) { $this->checkLogin(); $this->requireMethod('POST'); $this->csrfVerify(); $user = $this->getCurrentUser(); if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) { exit('无权限访问'); } $productModel = new ProductModel(); $productModel->deleteModel($id); header('Location: ' . BASE_URL . '/Admin/productModel'); } /** * 产品类型配置管理页面 * 管理员可以在页面中增删改类型,定义每种类型的显示列、标签等 */ public function productTypeConfig() { $this->checkLogin(); $user = $this->getCurrentUser(); if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) { exit('无权限访问'); } $typeConfigModel = new ProductTypeConfig(); // AJAX: 保存(添加/更新) if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->csrfVerify(); $action = $_POST['action'] ?? 'save'; if ($action === 'delete') { $id = intval($_POST['id'] ?? 0); if ($id > 0) { $result = $typeConfigModel->deleteConfig($id); if ($result === false) { echo json_encode(['success' => false, 'error' => '删除失败:数据库错误,请检查 DGZXY_product_type_config 表是否存在']); exit; } } echo json_encode(['success' => true]); exit; } // save 操作 $id = intval($_POST['id'] ?? 0); $type_name = trim($_POST['type_name'] ?? ''); $sort_order = intval($_POST['sort_order'] ?? 0); $display_columns = trim($_POST['display_columns'] ?? ''); $product_type_state = trim($_POST['product_type_state'] ?? 'editable'); $product_type_label = trim($_POST['product_type_label'] ?? '产品类型'); $product_type_field = trim($_POST['product_type_field'] ?? 'product_type'); $product_type_placeholder = trim($_POST['product_type_placeholder'] ?? ''); $model_code_state = trim($_POST['model_code_state'] ?? 'editable'); $color_state = trim($_POST['color_state'] ?? 'editable'); $color = trim($_POST['color'] ?? 'default'); $enabled = intval($_POST['enabled'] ?? 1); $show_in_inbound = intval($_POST['show_in_inbound'] ?? 1); // 校验三态值合法性 $validStates = ['hidden', 'readonly', 'editable']; if (!in_array($product_type_state, $validStates)) $product_type_state = 'editable'; if (!in_array($model_code_state, $validStates)) $model_code_state = 'editable'; if (!in_array($color_state, $validStates)) $color_state = 'editable'; if ($type_name === '') { echo json_encode(['success' => false, 'error' => '类型名称不能为空']); exit; } $data = [ 'type_name' => $type_name, 'sort_order' => $sort_order, 'display_columns' => $display_columns, 'product_type_state' => $product_type_state, 'product_type_label' => $product_type_label, 'product_type_field' => $product_type_field, 'product_type_placeholder' => $product_type_placeholder, 'model_code_state' => $model_code_state, 'color_state' => $color_state, 'color' => $color, 'enabled' => $enabled, 'show_in_inbound' => $show_in_inbound, ]; if ($id > 0) { $result = $typeConfigModel->updateConfig($id, $data); if ($result === false) { echo json_encode(['success' => false, 'error' => '更新失败:数据库错误,请检查 DGZXY_product_type_config 表是否存在']); exit; } } else { // 检查重名 $exist = $typeConfigModel->getByTypeName($type_name); if ($exist) { echo json_encode(['success' => false, 'error' => '类型「' . $type_name . '」已存在']); exit; } $result = $typeConfigModel->addConfig($data); if ($result === false) { echo json_encode(['success' => false, 'error' => '添加失败:数据库错误,请检查 DGZXY_product_type_config 表是否存在']); exit; } } echo json_encode(['success' => true]); exit; } // GET: 渲染页面 $configs = $typeConfigModel->getAll(); $this->assign('title', '产品类型配置'); $this->assign('user', $user); $this->assign('configs', $configs); $this->assign('csrfToken', $this->csrfToken()); $this->render(); } // 工位管理 — 仅超级管理员 public function workstation() { $this->checkLogin(); $user = $this->getCurrentUser(); if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) { exit('无权限访问'); } $workstation = new Workstation(); $stations = $workstation->getAll(); $this->assign('title', '工位管理'); $this->assign('user', $user); $this->assign('stations', $stations); $this->assign('csrfToken', $this->csrfToken()); $this->render(); } public function workstationAdd() { $this->checkLogin(); $user = $this->getCurrentUser(); if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) { exit('无权限访问'); } // 准备模板字段配置数据(提前初始化,供错误分支和 GET 视图使用) require_once APP_PATH . 'app/helpers/field_helper.php'; $templateTypes = ['inbound', 'pcb_test', 'battery', 'assembly', 'finished_test', 'warehouse', 'delivery']; $templateFieldsMap = []; foreach ($templateTypes as $type) { $templateFieldsMap[$type] = getDefaultFieldsConfig($type); } if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->csrfVerify(); $stationType = trim($_POST['station_type']); // 校验工位类型格式:必须以字母开头,只能包含字母数字和下划线 if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $stationType)) { $this->assign('error', '工位类型格式不正确,必须以字母开头,只能包含字母、数字和下划线'); $this->assign('title', '添加工位'); $this->assign('user', $user); $this->assign('templateFieldsMap', $templateFieldsMap); $this->assign('csrfToken', $this->csrfToken()); $this->render(); return; } // 状态值验证 $status = intval($_POST['status'] ?? 1); if (!in_array($status, [0, 1], true)) { $status = 1; // 默认启用 } try { $data = array( 'station_name' => $_POST['station_name'], 'station_code' => $_POST['station_code'], 'station_type' => $stationType, 'sort_order' => intval($_POST['sort_order'] ?? 1), 'status' => $status, 'fields_config' => $_POST['fields_config'] ?? '' ); // 如果未传入配置,则尝试从模板复制或使用默认配置 if (empty($data['fields_config'])) { $templateType = $_POST['template_station'] ?? ''; if (!empty($templateType)) { $templateConfig = getDefaultFieldsConfig($templateType); $data['fields_config'] = json_encode($templateConfig, JSON_UNESCAPED_UNICODE); } else { $data['fields_config'] = json_encode([], JSON_UNESCAPED_UNICODE); } } $workstation = new Workstation(); $workstation->addStation($data); header('Location: ' . BASE_URL . '/Admin/workstation'); exit; } catch (\Throwable $e) { error_log('[workstationAdd] ' . $e->getMessage()); $this->assign('error', '添加失败:' . $e->getMessage()); $this->assign('title', '添加工位'); $this->assign('user', $user); $this->assign('templateFieldsMap', $templateFieldsMap); $this->assign('csrfToken', $this->csrfToken()); $this->render(); return; } } $this->assign('title', '添加工位'); $this->assign('user', $user); $this->assign('templateFieldsMap', $templateFieldsMap); $this->assign('csrfToken', $this->csrfToken()); $this->render(); } public function workstationEdit($id) { $this->checkLogin(); $user = $this->getCurrentUser(); if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) { exit('无权限访问'); } require_once APP_PATH . 'app/helpers/field_helper.php'; $workstation = new Workstation(); $station = $workstation->where(['id = :id'], [':id' => $id])->fetch(); // 加载工位定义元数据 $stationDef = new StationDefinition(); $stationDefinition = $stationDef->getByType($station['station_type']); // 解析字段配置用于编辑器(提前初始化,供错误分支使用) $fieldEditor = renderFieldConfigEditor($station['station_type'], $station['fields_config'] ?? '', $stationDefinition); if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->csrfVerify(); // 状态值验证:只允许 0(禁用)和 1(启用) $status = intval($_POST['status']); if (!in_array($status, [0, 1], true)) { $this->assign('error', '状态值无效,请选择启用或禁用'); $this->assign('title', '编辑工位'); $this->assign('user', $user); $this->assign('station', $station); $this->assign('fieldEditor', $fieldEditor); $this->assign('csrfToken', $this->csrfToken()); $this->render(); return; } try { $data = array( 'station_name' => $_POST['station_name'], 'sort_order' => $_POST['sort_order'], 'status' => $status, 'fields_config' => $_POST['fields_config'] ?? '' ); $workstation->updateStation($id, $data); // 同步更新 station_definitions 表(管控产品类型行为) $sdData = [ 'has_product_type' => isset($_POST['sd_has_product_type']) ? 1 : 0, 'has_product_model' => isset($_POST['sd_has_product_model']) ? 1 : 0, 'filter_enabled' => isset($_POST['sd_filter_enabled']) ? 1 : 0, 'show_today_count' => isset($_POST['sd_show_today_count']) ? 1 : 0, 'show_total_count' => isset($_POST['sd_show_total_count']) ? 1 : 0, ]; if ($stationDefinition && !empty($stationDefinition['id'])) { // 更新已有定义 $sdFields = []; $sdParams = []; foreach ($sdData as $key => $val) { $sdFields[] = "`{$key}` = :{$key}"; $sdParams[":{$key}"] = $val; } $sdParams[':id'] = $stationDefinition['id']; $stationDef->execute( "UPDATE `" . StationDefinition::TABLE_PREFIX . "station_definitions` SET " . implode(', ', $sdFields) . " WHERE id = :id", $sdParams ); // 更新已有工位:同步字段变更到数据表 StationDefinition::clearCache(); $updatedDef = $stationDef->getByType($station['station_type']); $fieldConfigs = parseFieldsConfig($station['station_type'], $_POST['fields_config'] ?? ''); unset($fieldConfigs['_meta']); $stationDef->ensureColumns($station['station_type'], array_values($fieldConfigs)); } else { // 新建定义 $sdData['station_type'] = $station['station_type']; $sdData['db_table'] = 'station_' . $station['station_type']; $sdData['time_column'] = 'created_at'; $sdFields = implode('`, `', array_keys($sdData)); $sdPlaceholders = ':' . implode(', :', array_keys($sdData)); $stationDef->execute( "INSERT INTO `" . StationDefinition::TABLE_PREFIX . "station_definitions` (`{$sdFields}`) VALUES ({$sdPlaceholders})", $sdData ); // 新工位:自动创建数据表 StationDefinition::clearCache(); $newDef = $stationDef->getByType($station['station_type']); $fieldConfigs = parseFieldsConfig($station['station_type'], $_POST['fields_config'] ?? ''); unset($fieldConfigs['_meta']); $stationDef->ensureTable($station['station_type'], array_values($fieldConfigs)); } // 清除缓存,确保下次读取时获取最新数据 StationDefinition::clearCache(); header('Location: ' . BASE_URL . '/Admin/workstation'); exit; } catch (\Throwable $e) { error_log('[workstationEdit] ' . $e->getMessage()); $this->assign('error', '更新失败:' . $e->getMessage()); $this->assign('title', '编辑工位'); $this->assign('user', $user); $this->assign('station', $station); $this->assign('fieldEditor', $fieldEditor); $this->assign('csrfToken', $this->csrfToken()); $this->render(); return; } } $this->assign('title', '编辑工位'); $this->assign('user', $user); $this->assign('station', $station); $this->assign('fieldEditor', $fieldEditor); $this->assign('csrfToken', $this->csrfToken()); $this->render(); } public function workstationDelete($id) { $this->checkLogin(); $this->requireMethod('POST'); $this->csrfVerify(); $user = $this->getCurrentUser(); if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) { exit('无权限访问'); } $workstation = new Workstation(); $workstation->deleteStation($id); header('Location: ' . BASE_URL . '/Admin/workstation'); } // 系统配置 — 仅超级管理员可访问 public function config() { $this->checkLogin(); $user = $this->getCurrentUser(); if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) { exit('无权限访问'); } $config = new SysConfig(); $sysConfig = $config->getConfig(); if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->csrfVerify(); try { $data = array( 'company_name' => $_POST['company_name'], 'mes_name' => $_POST['mes_name'] ); $config->updateConfig($data); // 保存代码前缀规则 if (isset($_POST['prefix_types']) && isset($_POST['prefix_values'])) { $types = $_POST['prefix_types']; $values = $_POST['prefix_values']; $rules = []; $typeCount = count($types); for ($i = 0; $i < $typeCount; $i++) { $typeName = trim($types[$i]); $prefixVal = trim($values[$i]); if ($typeName !== '') { $rules[$typeName] = $prefixVal; } } $config->updateCodePrefixRules($rules); } header('Location: ' . BASE_URL . '/Admin/config'); exit; } catch (\Throwable $e) { error_log('[config] ' . $e->getMessage()); $this->assign('error', '保存失败:' . $e->getMessage()); $this->assign('title', '系统配置'); $this->assign('user', $user); $this->assign('sysConfig', $sysConfig); $this->assign('codePrefixRules', $config->getCodePrefixRules()); $this->assign('csrfToken', $this->csrfToken()); $this->render(); return; } } $this->assign('title', '系统配置'); $this->assign('user', $user); $this->assign('sysConfig', $sysConfig); $this->assign('codePrefixRules', $config->getCodePrefixRules()); $this->assign('csrfToken', $this->csrfToken()); $this->render(); } // 批量导入 — 仅超级管理员 public function import() { $this->checkLogin(); $user = $this->getCurrentUser(); if ($user['role'] !== self::ROLE_SUPER_ADMIN) { exit('无权限访问'); } $msg = ''; // 表类型 -> 数据库表名 映射 $tableMap = [ 'employee' => 'employee', 'customer' => 'customer', 'product_model' => 'product_model', 'inbound' => 'station_inbound', 'pcb_test' => 'pcb_test', 'battery' => 'battery_status', 'assembly' => 'product_assembly', 'finished_test' => 'finished_test', 'warehouse' => 'warehouse_in', 'delivery' => 'delivery', ]; // 需要排除的列(自动生成的字段) $excludeColumns = ['id', 'created_at', 'updated_at']; if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['import_file'])) { $this->csrfVerify(); $tableType = $_POST['table_type'] ?? ''; if (!isset($tableMap[$tableType])) { $msg = '无效的数据表类型'; } else { $tableName = $tableMap[$tableType]; $file = $_FILES['import_file']; if ($file['error'] !== UPLOAD_ERR_OK) { $msg = '文件上传失败,错误码:' . $file['error']; } else { // 验证文件类型:仅允许 CSV $tmpPath = $file['tmp_name']; $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); $mime = finfo_file(finfo_open(FILEINFO_MIME_TYPE), $tmpPath); $allowedExt = ['csv']; $allowedMime = ['text/csv', 'text/plain', 'application/csv', 'application/vnd.ms-excel']; if (!in_array($ext, $allowedExt, true) || !in_array($mime, $allowedMime, true)) { $msg = '仅支持 CSV 格式文件,当前文件类型:' . htmlspecialchars($ext) . ' (' . htmlspecialchars($mime) . ')'; error_log('[import] Upload rejected: ext=' . $ext . ' mime=' . $mime); $this->assign('title', '批量导入'); $this->assign('user', $user); $this->assign('msg', $msg); $this->assign('csrfToken', $this->csrfToken()); $this->render(); return; } $handle = fopen($tmpPath, 'r'); if (!$handle) { $msg = '无法打开文件'; } else { // 读取 CSV 头部 $headers = fgetcsv($handle); if (!$headers) { $msg = 'CSV 文件为空'; } else { // BOM 处理 $headers[0] = trim($headers[0], "\xEF\xBB\xBF"); $headers = array_map('trim', $headers); // 过滤掉排除列 $validHeaders = []; foreach ($headers as $h) { if (!in_array($h, $excludeColumns)) { $validHeaders[] = $h; } } // 解析每行数据 $success = 0; $fail = 0; $model = new \core\base\Model(); $model->table($tableName); while (($row = fgetcsv($handle)) !== false) { $data = []; foreach ($headers as $i => $col) { if (!in_array($col, $excludeColumns) && isset($row[$i])) { $val = $row[$i] !== '' ? $row[$i] : null; // 员工密码自动 bcrypt 加密 if ($tableName === 'employee' && $col === 'password' && $val !== null) { $empModel = new \app\models\Employee(); $val = $empModel->hashPassword($val); } $data[$col] = $val; } } if (!empty($data)) { try { $model->add($data); $success++; } catch (\Exception $e) { $fail++; } } } $msg = "导入完成:成功 {$success} 条,失败 {$fail} 条"; } fclose($handle); } } } } $this->assign('title', '批量导入'); $this->assign('user', $user); $this->assign('msg', $msg); $this->assign('csrfToken', $this->csrfToken()); $this->render(); } // 下载导入模板 — 仅超级管理员 public function importTemplate($type) { $this->checkLogin(); $user = $this->getCurrentUser(); if ($user['role'] !== self::ROLE_SUPER_ADMIN) { exit('无权限访问'); } $tableMap = [ 'employee' => 'employee', 'customer' => 'customer', 'product_model' => 'product_model', 'inbound' => 'station_inbound', 'pcb_test' => 'pcb_test', 'battery' => 'battery_status', 'assembly' => 'product_assembly', 'finished_test' => 'finished_test', 'warehouse' => 'warehouse_in', 'delivery' => 'delivery', ]; if (!isset($tableMap[$type])) { exit('无效的模板类型'); } $tableName = $tableMap[$type]; $excludeCols = ['id', 'created_at', 'updated_at']; $model = new \core\base\Model(); $model->table($tableName); $fullTableName = $model->getTable(); $columns = $model->query("SHOW COLUMNS FROM `{$fullTableName}`"); $headers = []; foreach ($columns as $col) { if (!in_array($col['Field'], $excludeCols)) { $headers[] = $col['Field']; } } header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="' . $type . '_template.csv"'); header('Pragma: no-cache'); // BOM for Excel UTF-8 compatibility echo "\xEF\xBB\xBF"; $output = fopen('php://output', 'w'); fputcsv($output, $headers); fclose($output); exit; } // 产品追溯 - 跨表查询产品完整信息 — 仅超级管理员 public function productSearch() { $this->checkLogin(); $user = $this->getCurrentUser(); if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) { exit('无权限访问'); } $keyword = trim($_GET['keyword'] ?? ''); $results = []; $searched = false; if (!empty($keyword)) { $searched = true; $model = new \core\base\Model(); // 用 LIKE 跨 product_assembly 表搜索 $like = '%' . $keyword . '%'; $results = $model->query( "SELECT pa.*, COALESCE(bs.voltage_platform, '') AS voltage_platform, COALESCE(bs.no_load_voltage, '') AS no_load_voltage, COALESCE(bs.internal_resistance, '') AS internal_resistance, COALESCE(pt.test_status, '') AS pcb_test_status, COALESCE(ft.test_result, '') AS finished_test_result, COALESCE(wi.in_time, '') AS warehouse_time, COALESCE(d.customer_name, '') AS delivery_customer, COALESCE(d.delivery_time, '') AS delivery_time FROM DGZXY_product_assembly pa LEFT JOIN DGZXY_battery_status bs ON pa.battery_serial = bs.serial_no LEFT JOIN DGZXY_pcb_test pt ON pa.pcb_serial = pt.serial_no LEFT JOIN DGZXY_finished_test ft ON pa.finished_serial = ft.finished_serial LEFT JOIN DGZXY_warehouse_in wi ON pa.finished_serial = wi.finished_serial LEFT JOIN DGZXY_delivery d ON pa.finished_serial = d.finished_serial WHERE pa.finished_serial LIKE :kw1 OR pa.battery_serial LIKE :kw2 OR pa.pcb_serial LIKE :kw3 OR pa.product_model LIKE :kw4 ORDER BY pa.id DESC LIMIT 200", [':kw1' => $like, ':kw2' => $like, ':kw3' => $like, ':kw4' => $like] ); } $this->assign('title', '产品追溯'); $this->assign('user', $user); $this->assign('keyword', $keyword); $this->assign('results', $results); $this->assign('searched', $searched); $this->assign('activeMenu', 'productSearch'); $this->render(); } // ========== 数据库敏感字段脱敏(P0:防止密码哈希等敏感信息泄露) ========== // 命中以下字段名(含子串匹配)的值在数据库管理页面一律显示为 ******,且禁止通过编辑页修改 protected static $SENSITIVE_FIELDS = [ 'password', 'passwd', 'pwd', 'token', 'api_token', 'access_token', 'refresh_token', 'secret', 'secret_key', 'api_key', 'private_key', 'salt', 'auth_key', 'mini_token', 'session_token', 'reset_token', 'verify_token', 'enc_key', 'cert', ]; protected function isSensitiveField($field) { $f = strtolower(trim((string)$field)); foreach (self::$SENSITIVE_FIELDS as $s) { if ($f === $s || strpos($f, $s) !== false) { return true; } } return false; } // P2-10:敏感系统表(含 token/密钥/凭证语义)不在数据库管理界面暴露 protected static $SENSITIVE_TABLE_PATTERNS = [ 'token', 'api_token', 'secret', 'password', 'passwd', 'pwd', 'session', 'miniapp', 'asd_api', 'oauth', 'credential', 'auth', 'salt', 'key', 'encrypt', 'private', 'permission', // 权限映射表(员工权限/员工工位权限)不暴露,防权限结构泄露 ]; protected function isSensitiveTable($table) { $t = strtolower(trim((string)$table)); foreach (self::$SENSITIVE_TABLE_PATTERNS as $p) { if (strpos($t, $p) !== false) { return true; } } return false; } // v3-P2:系统/框架级表(迁移、系统配置等)不在数据库管理界面暴露, // 进一步缩小可见表范围,降低结构信息泄露面。 protected static $SYSTEM_TABLE_PATTERNS = [ 'migration', 'phinxlog', 'sys_config', 'sysconfig', 'system_config', 'schema_version', 'seeds', 'cache', 'sessions', 'jobs', 'failed_jobs', 'logs', 'sys_log', 'audit', ]; protected function isSystemTable($table) { $t = strtolower(trim((string)$table)); foreach (self::$SYSTEM_TABLE_PATTERNS as $p) { if (strpos($t, $p) !== false) { return true; } } return false; } // v5-P2:数据库表业务名称映射(用于管理界面以中文业务名展示,降低原始表名结构泄露) // key 为去掉前缀后的展示名,value 为中文业务名;未命中时回退为原始展示名。 protected static $TABLE_BUSINESS_NAMES = [ 'asd_device' => '昂盛达设备', 'asd_test_data' => '昂盛达测试数据', 'asd_test_detail' => '昂盛达测试明细', 'asd_test_record' => '昂盛达测试记录', 'asset' => '资产', 'battery_status' => '电池状态', 'customer' => '客户', 'delivery' => '出货单', 'document' => '文档', 'employee' => '员工', 'finance_record' => '财务记录', 'finished_test' => '成品测试', 'hr_employee' => '人事员工', 'import_template' => '导入模板', 'inventory' => '库存', 'pcb_test' => 'PCB 测试', 'product_assembly' => '产品装配', 'product_model' => '产品型号', 'product_type_config' => '产品类型配置', 'purchase_item' => '采购明细', 'purchase_order' => '采购订单', 'receivable_payable' => '应收应付', 'sales_item' => '销售明细', 'sales_order' => '销售订单', 'station_aging' => '工位老化', 'station_definitions' => '工位定义', 'station_inbound' => '工位入库', 'station_records' => '工位记录', 'station_semi_finished' => '工位半成品', 'supplier' => '供应商', 'warehouse_in' => '入库', 'workstation' => '工位', ]; protected function tableBusinessName($displayName) { return self::$TABLE_BUSINESS_NAMES[$displayName] ?? $displayName; } // v3-P2:数据库管理操作审计日志(轻量文件日志,便于事后追溯谁访问/改动了哪张表) // 日志写入 runtime/db_audit.log,.htaccess 已禁止 .log 通过 Web 访问。 protected function dbAuditLog($action, $table = '', $extra = '') { try { $user = $this->getCurrentUser(); $dir = APP_PATH . 'runtime'; if (!is_dir($dir)) { @mkdir($dir, 0750, true); } $ip = $_SERVER['REMOTE_ADDR'] ?? '-'; $line = sprintf( "[%s] user=%s(%s) role=%s ip=%s action=%s table=%s %s\n", date('Y-m-d H:i:s'), $user['emp_no'] ?? '-', $user['emp_name'] ?? '-', $user['role'] ?? '-', $ip, $action, $table !== '' ? $table : '-', $extra ); @file_put_contents($dir . '/db_audit.log', $line, FILE_APPEND | LOCK_EX); } catch (\Throwable $e) { // 审计失败不得影响主流程 } } // 对行集合中的敏感字段值脱敏(原地修改) protected function maskSensitiveRows(array &$rows) { foreach ($rows as &$row) { if (!is_array($row)) continue; foreach ($row as $k => $v) { if ($this->isSensitiveField($k)) { $row[$k] = '******'; } } } unset($row); } // 数据库管理 - 表列表 — 仅超级管理员可访问 public function database() { $this->checkLogin(); $user = $this->getCurrentUser(); // v3-P2 安全加固:原始数据库管理(可查看/编辑/删除任意表记录)属高危操作, // 仅超级管理员可访问,对普通管理员完全隐藏表名与记录数。 if ($user['role'] !== self::ROLE_SUPER_ADMIN) { exit('无权限访问'); } $this->dbAuditLog('list_tables'); $tableList = $this->getVisibleTables(); $this->assign('title', '数据库管理'); $this->assign('user', $user); $this->assign('tables', $tableList); $this->assign('activeMenu', 'database'); $this->render(); } // v7-P3:可见表列表(过滤敏感/系统表、去前缀、按展示名稳定排序) // 同时供列表页与详情页按索引定位,避免 URL 中暴露原始表名 protected function getVisibleTables() { $model = new \core\base\Model(); $tables = $model->query("SHOW TABLES"); $prefix = \core\base\Model::TABLE_PREFIX; $list = []; foreach ($tables as $row) { $tbl = reset($row); // 获取第一个值(表名) // 跳过 jp_ 前缀的表(不显示) if (strpos($tbl, 'jp_') === 0) { continue; } // P2-10 安全加固:敏感系统表不在管理界面暴露(限制显示范围,防结构泄露) if ($this->isSensitiveTable($tbl)) { continue; } // v3-P2:框架/系统级表(迁移、系统配置、日志等)同样不暴露 if ($this->isSystemTable($tbl)) { continue; } // 使用完整表名查询行数 $count = $model->query("SELECT COUNT(*) AS cnt FROM `{$tbl}`"); // 展示给用户的名称:去掉 DGZXY_ 前缀 $displayName = $tbl; if (strpos($tbl, $prefix) === 0) { $displayName = substr($tbl, strlen($prefix)); } $list[] = [ 'name' => $displayName, 'business_name' => $this->tableBusinessName($displayName), 'full_name' => $tbl, 'count' => $count[0]['cnt'] ?? 0, ]; } // 按展示名排序,保证索引在各请求间一致(修复 v7-P3:URL 不暴露原始表名) usort($list, function ($a, $b) { return strcmp($a['name'], $b['name']); }); return $list; } // 数据库管理 - 查看表数据 — 仅超级管理员可访问 // v7-P3:$idx 为 getVisibleTables() 的稳定索引,URL 不再含原始表名 public function databaseView($idx = 0) { $this->checkLogin(); $user = $this->getCurrentUser(); // v3-P2:原始数据库查看仅超级管理员可访问 if ($user['role'] !== self::ROLE_SUPER_ADMIN) { exit('无权限访问'); } // v7-P3:通过稳定索引解析表名,不在 URL 暴露原始表名 $tables = $this->getVisibleTables(); $idx = intval($idx); if (!isset($tables[$idx])) { echo '

无效的表索引

'; exit; } $table = $tables[$idx]['name']; $page = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1; $perPage = 20; $model = new \core\base\Model(); $model->table($table); $fullTableName = $model->getTable(); // 验证表存在(使用参数化查询,用完整表名匹配) $check = $model->query("SHOW TABLES LIKE :tbl", [':tbl' => $fullTableName]); if (empty($check)) { echo '

表不存在

'; exit; } // 获取列信息 $columns = $model->query("SHOW COLUMNS FROM `{$fullTableName}`"); // 总数 $total = $model->query("SELECT COUNT(*) AS cnt FROM `{$fullTableName}`"); $totalCount = $total[0]['cnt'] ?? 0; $totalPages = ceil($totalCount / $perPage); $offset = ($page - 1) * $perPage; // 获取数据(offset/limit 已 intval 安全) $rows = $model->query("SELECT * FROM `{$fullTableName}` ORDER BY id DESC LIMIT {$offset}, {$perPage}"); // P0 脱敏:密码/token 等敏感字段不展示明文 $this->maskSensitiveRows($rows); $this->assign('title', '查看表:' . $table); $this->assign('user', $user); $this->assign('table', $table); $this->assign('idx', $idx); $this->assign('columns', $columns); $this->assign('rows', $rows); $this->assign('page', $page); $this->assign('totalPages', $totalPages); $this->assign('totalCount', $totalCount); $this->assign('activeMenu', 'database'); $this->assign('csrfToken', $this->csrfToken()); $this->render(); } // 数据库管理 - 编辑行 — 仅超级管理员可访问 // v7-P3:$idx 为 getVisibleTables() 的稳定索引 public function databaseEdit($idx = 0, $id = 0) { $this->checkLogin(); $user = $this->getCurrentUser(); // v3-P2:原始数据库编辑仅超级管理员可访问 if ($user['role'] !== self::ROLE_SUPER_ADMIN) { exit('无权限访问'); } // v7-P3:通过稳定索引解析表名 $tables = $this->getVisibleTables(); $idx = intval($idx); if (!isset($tables[$idx])) { echo '

无效的表索引

'; exit; } $table = $tables[$idx]['name']; $this->dbAuditLog('edit_record', $table, 'id=' . intval($id)); $model = new \core\base\Model(); $model->table($table); $fullTableName = $model->getTable(); $columns = $model->query("SHOW COLUMNS FROM `{$fullTableName}`"); $row = $model->query("SELECT * FROM `{$fullTableName}` WHERE id = :id", [':id' => intval($id)]); if (empty($row)) { echo '

记录不存在

'; exit; } $row = $row[0]; // P0 脱敏:识别并标记敏感字段,显示值脱敏 $sensitiveFields = []; foreach ($columns as $col) { if ($this->isSensitiveField($col['Field'])) { $sensitiveFields[] = $col['Field']; } } foreach ($row as $k => $v) { if ($this->isSensitiveField($k)) { $row[$k] = '******'; } } if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->csrfVerify(); $data = []; foreach ($columns as $col) { $field = $col['Field']; if ($field === 'id') continue; // 禁止通过数据库编辑页修改密码/token 等敏感字段 if ($this->isSensitiveField($field)) continue; if (isset($_POST[$field])) { $data[$field] = $_POST[$field] !== '' ? $_POST[$field] : null; } } if (!empty($data)) { $model->where('id = :id', [':id' => $id])->update($data); } header('Location: ' . BASE_URL . '/Admin/databaseView/' . $idx); exit; } $this->assign('title', '编辑记录'); $this->assign('user', $user); $this->assign('table', $table); $this->assign('idx', $idx); $this->assign('columns', $columns); $this->assign('row', $row); $this->assign('sensitiveFields', $sensitiveFields); $this->assign('activeMenu', 'database'); $this->assign('csrfToken', $this->csrfToken()); $this->render(); } // 数据库管理 - 删除行 — 仅超级管理员可访问 // v7-P3:$idx 为 getVisibleTables() 的稳定索引 public function databaseDelete($idx = 0, $id = 0) { $this->checkLogin(); $this->requireMethod('POST'); $this->csrfVerify(); $user = $this->getCurrentUser(); // v3-P2:原始数据库删除仅超级管理员可访问 if ($user['role'] !== self::ROLE_SUPER_ADMIN) { exit('无权限访问'); } // v7-P3:通过稳定索引解析表名 $tables = $this->getVisibleTables(); $idx = intval($idx); if (!isset($tables[$idx])) { exit('无效的表索引'); } $table = $tables[$idx]['name']; $this->dbAuditLog('delete_record', $table, 'id=' . intval($id)); $model = new \core\base\Model(); $model->table($table); $model->delete($id); header('Location: ' . BASE_URL . '/Admin/databaseView/' . $idx); exit; } // ========== 昂盛达测试数据导入管理 ========== /** * 测试数据列表 — 仅超级管理员 */ public function asdTestData() { $this->checkLogin(); $user = $this->getCurrentUser(); if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) { exit('无权限访问'); } $keyword = $_GET['keyword'] ?? ''; $asdModel = new \app\models\AsdTestData(); $records = $asdModel->getAllWithStats($keyword); $totalCount = $asdModel->countRecords($keyword); $this->assign('title', '测试数据管理'); $this->assign('user', $user); $this->assign('records', $records); $this->assign('totalCount', $totalCount); $this->assign('keyword', $keyword); $this->assign('activeMenu', 'asdTestData'); $this->assign('csrfToken', $this->csrfToken()); $this->render(); } /** * 测试数据详情 — 仅超级管理员 */ public function asdTestDataView($id) { $this->checkLogin(); $user = $this->getCurrentUser(); if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) { exit('无权限访问'); } $asdModel = new \app\models\AsdTestData(); $record = $asdModel->findById($id); if (!$record) { echo '

记录不存在

'; exit; } $details = $asdModel->getDetails($id); $this->assign('title', '测试数据详情'); $this->assign('user', $user); $this->assign('record', $record); $this->assign('details', $details); $this->assign('activeMenu', 'asdTestData'); $this->render(); } /** * 删除测试数据 — 仅超级管理员 */ public function asdTestDataDelete($id) { $this->checkLogin(); $this->requireMethod('POST'); $this->csrfVerify(); $user = $this->getCurrentUser(); if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) { exit('无权限访问'); } $asdModel = new \app\models\AsdTestData(); $asdModel->deleteRecord($id); header('Location: ' . BASE_URL . '/Admin/asdTestData'); exit; } /** * 手动导入测试数据(模拟昂盛达上位机提交) — 仅超级管理员 */ public function asdTestDataImport() { $this->checkLogin(); $user = $this->getCurrentUser(); if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) { exit('无权限访问'); } // 获取工位列表供选择(容错处理) $stations = []; try { $workstation = new \app\models\Workstation(); $stations = $workstation->getAll(); if (!is_array($stations)) $stations = []; } catch (\Throwable $e) { $stations = []; } // 获取设备列表(供扫码选择下拉框使用) $devices = []; try { $deviceModel = new \app\models\AsdDevice(); $devices = $deviceModel->getAllActive(); if (!is_array($devices)) $devices = []; } catch (\Throwable $e) { $devices = []; } // CSRF Token 传递给视图 $csrfToken = $this->csrfToken(); $this->assign('csrfToken', $csrfToken); // 激活 Tab:优先从 GET 参数 tab 获取,支持 form/json/device $activeTab = 'form'; $tabParam = $_GET['tab'] ?? ''; if (in_array($tabParam, ['form', 'json', 'device'], true)) { $activeTab = $tabParam; } if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->csrfVerify(); $action = $_POST['action'] ?? ''; if ($action === 'import_json') { // 直接粘贴 JSON 数据导入 $activeTab = 'json'; // 保持在 JSON 导入 Tab $jsonStr = $_POST['json_data'] ?? ''; if (empty(trim($jsonStr))) { $this->assign('error', '请粘贴 JSON 数据'); } else { $jsonData = json_decode($jsonStr, true); if (!$jsonData || !is_array($jsonData)) { $this->assign('error', 'JSON 格式错误: ' . json_last_error_msg()); } elseif (empty($jsonData['QrCode'])) { $this->assign('error', 'JSON 缺少必填字段: QrCode(条码/二维码)'); } elseif (empty($jsonData['DataList']) || !is_array($jsonData['DataList'])) { $this->assign('error', 'JSON 缺少必填字段: DataList(测试工步数据)'); } else { $asdModel = new \app\models\AsdTestData(); $recordId = $asdModel->addRecord([ 'tester' => $jsonData['Tester'] ?? $user['emp_name'], 'qr_code' => $jsonData['QrCode'], 'mo' => $jsonData['Mo'] ?? '', 'site' => $jsonData['Site'] ?? '', 'station' => $jsonData['Station'] ?? '', 'work_order' => $jsonData['WorkOrder'] ?? '', 'project_name' => $jsonData['ProjectName'] ?? '', 'status' => strtoupper($jsonData['Status'] ?? 'PASS'), 'group_name' => $jsonData['Group'] ?? '', 'device' => $jsonData['Device'] ?? '', 'running_time' => $jsonData['RunningTime'] ?? '', 'test_time' => $jsonData['TestTime'] ?? date('Y-m-d H:i:s'), ]); if (!$recordId) { $err = $asdModel->getError(); $this->assign('error', '保存失败: ' . ($err ?: '数据库写入错误')); } else { $detailCount = 0; foreach ($jsonData['DataList'] as $item) { $r = $asdModel->addDetail([ 'record_id' => $recordId, 'seq' => $item['Seq'] ?? 1, 'test_name' => $item['TestName'] ?? '', 'test_item' => $item['TestItem'] ?? '', 'test_units' => $item['TestUnits'] ?? '', 'data_value' => $item['DataValue'] ?? null, 'lower_limit' => $item['LowerLimit'] ?? null, 'upper_limit' => $item['UpperLimit'] ?? null, 'test_value' => $item['TestValue'] ?? '', 'test_limit' => $item['TestLimit'] ?? '', 'test_result' => $item['TestResult'] ?? 'PASS', ]); if ($r) $detailCount++; } $this->assign('success', "JSON 导入成功!记录ID: {$recordId},工步数: {$detailCount}"); } } } } else { // 表单方式提交 $activeTab = 'form'; $dataList = []; $stepNames = $_POST['step_name'] ?? []; $stepItems = $_POST['step_item'] ?? []; $stepUnits = $_POST['step_units'] ?? []; $stepValues = $_POST['step_value'] ?? []; $stepLower = $_POST['step_lower'] ?? []; $stepUpper = $_POST['step_upper'] ?? []; $stepResults = $_POST['step_result'] ?? []; for ($i = 0; $i < count($stepNames); $i++) { $dataList[] = [ 'Seq' => $i + 1, 'TestName' => $stepNames[$i] ?? '', 'TestItem' => $stepItems[$i] ?? '', 'TestUnits' => $stepUnits[$i] ?? '', 'DataValue' => is_numeric($stepValues[$i]) ? (float)$stepValues[$i] : null, 'LowerLimit' => is_numeric($stepLower[$i]) ? (float)$stepLower[$i] : null, 'UpperLimit' => is_numeric($stepUpper[$i]) ? (float)$stepUpper[$i] : null, 'TestValue' => !is_numeric($stepValues[$i]) ? ($stepValues[$i] ?? '') : '', 'TestResult' => $stepResults[$i] ?? 'PASS', ]; } $status = 'PASS'; foreach ($dataList as $step) { if ($step['TestResult'] === 'FAIL') { $status = 'NG'; break; } } $asdModel = new \app\models\AsdTestData(); $recordId = $asdModel->addRecord([ 'tester' => $user['emp_name'], 'qr_code' => $_POST['qr_code'] ?? '', 'mo' => $_POST['mo'] ?? '', 'site' => $_POST['site'] ?? '', 'station' => $_POST['station'] ?? '', 'work_order' => $_POST['work_order'] ?? '', 'project_name' => $_POST['project_name'] ?? '', 'status' => $status, 'group_name' => $_POST['group_name'] ?? '', 'device' => $_POST['device'] ?? '', 'running_time' => $_POST['running_time'] ?? '', 'test_time' => date('Y-m-d H:i:s'), ]); if ($recordId) { foreach ($dataList as $item) { $asdModel->addDetail([ 'record_id' => $recordId, 'seq' => $item['Seq'], 'test_name' => $item['TestName'], 'test_item' => $item['TestItem'], 'test_units' => $item['TestUnits'], 'data_value' => $item['DataValue'], 'lower_limit' => $item['LowerLimit'], 'upper_limit' => $item['UpperLimit'], 'test_value' => $item['TestValue'], 'test_result' => $item['TestResult'], ]); } $this->assign('success', "表单导入成功!记录ID: {$recordId}"); } else { $err = $asdModel->getError(); $this->assign('error', '保存失败: ' . ($err ?: '数据库写入错误')); } } } $this->assign('title', '导入测试数据'); $this->assign('user', $user); $this->assign('stations', $stations); $this->assign('devices', $devices); $this->assign('activeTab', $activeTab); $this->assign('activeMenu', 'asdTestData'); $this->assign('csrfToken', $this->csrfToken()); $this->render(); } /** * 批量删除测试数据(按设备或按条件) — 仅超级管理员 */ public function asdBatchDelete() { $this->checkLogin(); $user = $this->getCurrentUser(); if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) { exit('无权限访问'); } if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->csrfVerify(); $action = $_POST['action'] ?? ''; $deviceCode = $_POST['device_code'] ?? ''; $ids = $_POST['ids'] ?? ''; $pdo = \core\db\Db::pdo(); if ($action === 'by_device' && !empty($deviceCode)) { // 按设备编码删除 $idsStmt = $pdo->prepare("SELECT id FROM DGZXY_asd_test_record WHERE device = :code"); $idsStmt->execute([':code' => $deviceCode]); $recordIds = $idsStmt->fetchAll(\PDO::FETCH_COLUMN); if (!empty($recordIds)) { $placeholders = implode(',', array_fill(0, count($recordIds), '?')); $pdo->prepare("DELETE FROM DGZXY_asd_test_detail WHERE record_id IN ({$placeholders})")->execute($recordIds); $pdo->prepare("DELETE FROM DGZXY_asd_test_record WHERE device = :code")->execute([':code' => $deviceCode]); } header('Location: ' . BASE_URL . '/Admin/asdTestData'); exit; } if ($action === 'by_ids' && !empty($ids)) { // 按 ID 列表批量删除 $idArr = array_map('intval', explode(',', $ids)); $idArr = array_filter($idArr, function($v) { return $v > 0; }); if (!empty($idArr)) { $placeholders = implode(',', array_fill(0, count($idArr), '?')); $pdo->prepare("DELETE FROM DGZXY_asd_test_detail WHERE record_id IN ({$placeholders})")->execute($idArr); $pdo->prepare("DELETE FROM DGZXY_asd_test_record WHERE id IN ({$placeholders})")->execute($idArr); } header('Location: ' . BASE_URL . '/Admin/asdTestData'); exit; } if ($action === 'clear_all') { // 清空全部测试数据 $pdo->exec("DELETE FROM DGZXY_asd_test_detail"); $pdo->exec("DELETE FROM DGZXY_asd_test_record"); $pdo->exec("ALTER TABLE DGZXY_asd_test_record AUTO_INCREMENT = 1"); $pdo->exec("ALTER TABLE DGZXY_asd_test_detail AUTO_INCREMENT = 1"); header('Location: ' . BASE_URL . '/Admin/asdTestData'); exit; } } // 获取设备列表供前端使用 $devices = []; try { $deviceModel = new \app\models\AsdDevice(); $devices = $deviceModel->getAllActive(); } catch (\Throwable $e) {} $this->assign('title', '批量删除测试数据'); $this->assign('user', $user); $this->assign('devices', $devices); $this->assign('activeMenu', 'asdTestData'); $this->assign('csrfToken', $this->csrfToken()); $this->render(); } /** * 自动生成员工编号:ZXY + 三位流水号(如 ZXY001, ZXY002...) * 查询数据库中 ZXY 开头的最大编号,+1 作为新编号 */ private function generateEmpNo() { $model = new \core\base\Model(); // 循环生成,直到找到不重复的编号 for ($i = 0; $i < 999; $i++) { $row = $model->query( "SELECT emp_no FROM `DGZXY_employee` WHERE emp_no LIKE :prefix ORDER BY emp_no DESC LIMIT 1", [':prefix' => 'ZXY%'] ); if (!empty($row) && isset($row[0]['emp_no'])) { $lastNo = $row[0]['emp_no']; if (preg_match('/\d+/', $lastNo, $matches)) { $num = (int)$matches[0] + 1 + $i; } else { $num = 1 + $i; } } else { $num = 1 + $i; } $emp_no = 'ZXY' . str_pad($num, 3, '0', STR_PAD_LEFT); // 检查是否已存在(防止并发重复) $exists = $model->query( "SELECT id FROM `DGZXY_employee` WHERE emp_no = :no LIMIT 1", [':no' => $emp_no] ); if (empty($exists)) { return $emp_no; } } // 兜底:用时间戳生成 return 'ZXY' . date('His'); } // ========== 昂盛达设备调试 ========== /** * 昂盛达设备调试页 — 测试链接状态、数据读写 * 仅超级管理员与管理员可访问 */ public function asdDeviceDebug() { $this->checkLogin(); $user = $this->getCurrentUser(); if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) { exit('无权限访问'); } $pdo = \core\db\Db::pdo(); $devices = []; $result = null; $selDeviceId = intval($_POST['device_id'] ?? ($_GET['device_id'] ?? 0)); try { // 动态探测设备表真实列,避免硬编码 group_name/device_code 等线上不存在的列导致 1054 $cols = []; try { $dc = $pdo->query("DESCRIBE DGZXY_asd_device"); if ($dc) { foreach ($dc->fetchAll(\PDO::FETCH_ASSOC) as $c) { $cols[] = $c['Field']; } } } catch (\Throwable $e) { /* 探测失败走兜底 */ } $has = function ($name) use ($cols) { return in_array($name, $cols, true); }; $selectCols = $has('device_name') ? 'id, device_name, device_ip, device_port, status' : '*'; $orderCols = []; foreach (['group_name', 'device_code', 'device_name', 'id'] as $oc) { if ($has($oc)) { $orderCols[] = $oc . ' ASC'; } } $orderSql = $orderCols ? ('ORDER BY ' . implode(', ', $orderCols)) : ''; // 关键字查找设备(按名称/IP/编号模糊匹配) $keyword = trim($_POST['keyword'] ?? ($_GET['keyword'] ?? '')); $whereSql = ''; $params = []; if ($keyword !== '') { $likeParts = []; foreach (['device_name', 'device_ip', 'device_code', 'group_name'] as $kc) { if ($has($kc)) { $likeParts[] = $kc . ' LIKE :kw'; } } if ($likeParts) { $whereSql = 'WHERE ' . implode(' OR ', $likeParts); $params[':kw'] = '%' . $keyword . '%'; } } $sql = "SELECT {$selectCols} FROM DGZXY_asd_device {$whereSql} {$orderSql}"; $stmt = $pdo->prepare($sql); $stmt->execute($params); $devices = $stmt->fetchAll(\PDO::FETCH_ASSOC); if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->csrfVerify(); $action = $_POST['action'] ?? ''; if ($action === 'manualConnect') { // 手动连接:直接用表单输入的 IP:端口,不依赖数据库里的记录 $mip = trim($_POST['manual_ip'] ?? ''); $mport = intval($_POST['manual_port'] ?? 502); if ($mport <= 0) { $mport = 502; } $result = $this->asdDebugTestLink([ 'device_ip' => $mip, 'device_port' => $mport, 'device_name' => '手动连接(' . $mip . ':' . $mport . ')', ]); } else { $deviceId = intval($_POST['device_id'] ?? 0); $device = null; $ds = $pdo->prepare("SELECT * FROM DGZXY_asd_device WHERE id = :id"); $ds->execute([':id' => $deviceId]); $device = $ds->fetch(\PDO::FETCH_ASSOC); if (!$device) { $result = ['error' => '设备不存在或已被删除(ID=' . $deviceId . ')']; } elseif ($action === 'testLink') { $result = $this->asdDebugTestLink($device); } elseif ($action === 'readData') { $result = $this->asdDebugReadData($device); } elseif ($action === 'writeData') { $result = $this->asdDebugWriteData($device); } else { $result = ['error' => '未知操作: ' . $action]; } } } } catch (\Throwable $e) { // 把隐藏异常转为可见提示,便于定位(调试页允许暴露具体错误) $result = ['action' => 'error', 'error' => '调试页异常:' . $e->getMessage()]; } $manualConnect = !empty($_POST['action']) && $_POST['action'] === 'manualConnect'; $this->assign('title', '昂盛达设备调试'); $this->assign('user', $user); $this->assign('devices', $devices); $this->assign('selDeviceId', $selDeviceId); $this->assign('result', $result); $this->assign('keyword', $keyword ?? ''); $this->assign('manualConnect', $manualConnect ?? false); $this->assign('activeMenu', 'asdDeviceDebug'); $this->assign('csrfToken', $this->csrfToken()); $this->render(); } /** * 测试设备 TCP 链接状态(端口取 device_port,缺省 502) */ protected function asdDebugTestLink($device) { $ip = $device['device_ip'] ?? ''; $port = !empty($device['device_port']) ? intval($device['device_port']) : 502; $reachable = false; $latency = 0; $err = ''; if (empty($ip)) { $err = '设备 IP 未设置,请在设备管理中填写 device_ip'; } elseif (!function_exists('fsockopen')) { $err = 'PHP 未启用 fsockopen 扩展,无法测试网络连通性'; } else { $start = microtime(true); $errno = 0; $errstr = ''; $fp = @fsockopen($ip, $port, $errno, $errstr, 3); if ($fp) { $reachable = true; $latency = round((microtime(true) - $start) * 1000, 1); fclose($fp); } else { $err = '连接失败: ' . $errstr . ' (errno=' . $errno . ')'; } } return [ 'action' => 'testLink', 'device' => $device, 'ip' => $ip, 'port' => $port, 'reachable' => $reachable, 'latency' => $latency, 'error' => $err, ]; } /** * 读取设备相关数据(asd_test_record 按 device_code 过滤 + 各表计数) */ protected function asdDebugReadData($device) { $pdo = \core\db\Db::pdo(); $deviceCode = $device['device_code'] ?? ''; $rows = []; try { $stmt = $pdo->prepare("SELECT * FROM DGZXY_asd_test_record WHERE device_code = :dc ORDER BY id DESC LIMIT 10"); $stmt->execute([':dc' => $deviceCode]); $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC); } catch (\Throwable $e) { // 读取异常交由视图提示 } $counts = ['rec_cnt' => 0, 'data_cnt' => 0, 'dev_cnt' => 0]; try { $c = $pdo->query("SELECT (SELECT COUNT(*) FROM DGZXY_asd_test_record) AS rec_cnt, (SELECT COUNT(*) FROM DGZXY_asd_test_data) AS data_cnt, (SELECT COUNT(*) FROM DGZXY_asd_device) AS dev_cnt")->fetch(\PDO::FETCH_ASSOC); if ($c) { $counts = $c; } } catch (\Throwable $e) { // ignore } return [ 'action' => 'readData', 'device' => $device, 'device_code' => $deviceCode, 'rows' => $rows, 'counts' => $counts, ]; } /** * 写入探针记录并回读后删除,验证数据读写链路 */ protected function asdDebugWriteData($device) { $deviceCode = $device['device_code'] ?? ''; $probeCode = 'DEBUG_PROBE_' . date('YmdHis') . '_' . rand(100, 999); $model = new \app\models\AsdTestData(); $writtenId = 0; $readBack = null; $readBackMatch = false; $deleted = false; $err = ''; try { $writtenId = $model->addRecord([ 'qr_code' => $probeCode, 'device_code' => $deviceCode, 'test_result' => 'PASS', 'test_date' => date('Y-m-d H:i:s'), 'remark' => 'ASD_DEBUG_PROBE', ]); if ($writtenId) { $readBack = $model->findById($writtenId); $readBackMatch = !empty($readBack) && ($readBack['qr_code'] ?? '') === $probeCode; $deleted = (bool) $model->deleteRecord($writtenId); } else { $err = '写入失败(未获取到插入 ID)'; } } catch (\Throwable $e) { $err = '写入/回读异常: ' . $e->getMessage(); } return [ 'action' => 'writeData', 'device' => $device, 'device_code' => $deviceCode, 'probe_code' => $probeCode, 'written_id' => $writtenId, 'read_back' => $readBack, 'read_back_match'=> $readBackMatch, 'deleted' => $deleted, 'error' => $err, ]; } // ========== 数据库升级/迁移 ========== /** * 数据库升级页面 — 仅超级管理员可访问 * 自动扫描 sql/ 目录下的 .sql 文件,支持一键执行 */ public function databaseUpgrade() { $this->checkLogin(); $user = $this->getCurrentUser(); if ($user['role'] !== self::ROLE_SUPER_ADMIN) { exit('无权限访问'); } $pdo = \core\db\Db::pdo(); // 确保迁移记录表存在 $this->ensureMigrationsTable($pdo); $sqlDir = APP_PATH . 'sql'; $message = ''; $messageType = ''; // success / danger / warning // 处理 POST 请求 if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->csrfVerify(); $action = $_POST['action'] ?? ''; if ($action === 'execute' && !empty($_POST['file'])) { // 执行单个 SQL 文件 $file = basename($_POST['file']); $filepath = $sqlDir . '/' . $file; if (!file_exists($filepath)) { $message = "文件不存在: {$file}"; $messageType = 'danger'; } else { $result = $this->runSqlFile($pdo, $filepath, $file); $message = $result['message']; $messageType = $result['success'] ? 'success' : 'danger'; } } elseif ($action === 'execute_all') { // 执行所有未执行的 SQL 文件 $files = $this->scanSqlFiles($sqlDir); $pendingFiles = $this->filterPendingFiles($pdo, $files); if (empty($pendingFiles)) { $message = '所有迁移脚本已执行完毕,没有需要执行的脚本。'; $messageType = 'warning'; } else { $successCount = 0; $failCount = 0; $msgs = []; foreach ($pendingFiles as $file) { $filepath = $sqlDir . '/' . $file; $result = $this->runSqlFile($pdo, $filepath, $file); if ($result['success']) { $successCount++; } else { $failCount++; $msgs[] = $result['message']; } } $message = "执行完毕:成功 {$successCount} 个"; if ($failCount > 0) { $message .= ",失败 {$failCount} 个"; $message .= "\n" . implode("\n", $msgs); $messageType = 'warning'; } else { $message .= ",所有脚本执行成功!"; $messageType = 'success'; } } } } // 扫描 SQL 文件并检查执行状态 $allFiles = $this->scanSqlFiles($sqlDir); $migratedMap = $this->getMigratedFiles($pdo); $pendingCount = 0; $fileList = []; foreach ($allFiles as $file) { $isMigrated = isset($migratedMap[$file]); $info = $migratedMap[$file] ?? null; if (!$isMigrated) $pendingCount++; $fileList[] = [ 'name' => $file, 'is_migrated' => $isMigrated, 'executed_at' => $info['executed_at'] ?? '', 'status' => $info['status'] ?? null, 'error_msg' => $info['error_msg'] ?? '', 'preview' => $this->getSqlPreview($sqlDir . '/' . $file), ]; } $this->assign('title', '数据库升级'); $this->assign('user', $user); $this->assign('fileList', $fileList); $this->assign('pendingCount', $pendingCount); $this->assign('message', $message); $this->assign('messageType', $messageType); $this->assign('activeMenu', 'databaseUpgrade'); $this->assign('csrfToken', $this->csrfToken()); $this->render(); } /** * 执行单条 SQL 并立即释放结果集游标。 * * 不能使用 PDO::exec():exec() 拿不到语句句柄,在“非缓冲查询”环境下会残留 * 未取走的结果集,导致后续 prepare/execute 报 2014 * (Cannot execute queries while other unbuffered queries are active)。 * 这里改用 prepare -> execute -> closeCursor,彻底释放连接。 */ private function pdoExec($pdo, $sql, $params = []) { $stmt = $pdo->prepare($sql); $stmt->execute($params); $stmt->closeCursor(); return true; } /** * 幂等写入迁移记录。 * * 使用 INSERT ... ON DUPLICATE KEY UPDATE:当同名文件(uk_filename 唯一约束) * 已存在时,更新其状态/时间/错误信息,而不是抛出 1062 Duplicate entry。 * 这样即便之前有失败记录,重跑也不会因唯一约束冲突而报“执行失败”。 */ private function recordMigration($pdo, $filename, $status, $errorMsg = '') { $this->pdoExec( $pdo, "INSERT INTO `DGZXY_migrations` (filename, executed_at, status, error_msg) VALUES (:fn, NOW(), :st, :err) ON DUPLICATE KEY UPDATE executed_at = NOW(), status = VALUES(status), error_msg = VALUES(error_msg)", [':fn' => $filename, ':st' => $status, ':err' => $errorMsg] ); return true; } /** * 判断 SQL 执行错误是否为「可忽略的幂等错误」。 * * 在 SAFE 模式重复执行迁移时,以下错误是预期内的,不应算作失败: * - 1060 Duplicate column name:ADD COLUMN 时列已存在 * - 1061 Duplicate key name:CREATE INDEX 时索引已存在 * - 1091 Can't DROP ... check that column/index exists:DROP 时对象已不存在 * - already exists:其他“已存在”类提示 */ private function isIgnorableSqlError($msg) { $patterns = [ 'Duplicate column name', 'Duplicate key name', 'already exists', '1060', '1061', '1091', ]; foreach ($patterns as $p) { if (stripos($msg, $p) !== false) { return true; } } return false; } /** * 确保 migrations 表存在 */ private function ensureMigrationsTable($pdo) { $this->pdoExec($pdo, "CREATE TABLE IF NOT EXISTS `DGZXY_migrations` ( `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, `filename` VARCHAR(255) NOT NULL COMMENT 'SQL文件名', `executed_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '执行时间', `status` TINYINT DEFAULT 1 COMMENT '1=成功 0=失败', `error_msg` TEXT COMMENT '错误信息', UNIQUE KEY `uk_filename` (`filename`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='数据库迁移记录表'"); } /** * 扫描 sql 目录下的 .sql 文件 */ private function scanSqlFiles($dir) { $files = []; if (is_dir($dir)) { $dh = opendir($dir); while (($file = readdir($dh)) !== false) { if (preg_match('/\.sql$/i', $file) && $file !== 'create_migrations.sql') { $files[] = $file; } } closedir($dh); sort($files); } return $files; } /** * 获取已迁移的文件列表 */ private function getMigratedFiles($pdo) { $stmt = $pdo->prepare("SELECT filename, executed_at, status, error_msg FROM `DGZXY_migrations` ORDER BY id ASC"); $stmt->execute(); $rows = $stmt->fetchAll(); $stmt->closeCursor(); $map = []; foreach ($rows as $row) { $map[$row['filename']] = $row; } return $map; } /** * 过滤出未执行的文件 */ private function filterPendingFiles($pdo, $files) { $migratedMap = $this->getMigratedFiles($pdo); $pending = []; foreach ($files as $file) { if (!isset($migratedMap[$file])) { $pending[] = $file; } } return $pending; } /** * 获取 SQL 文件预览(前 20 行) */ private function getSqlPreview($filepath) { if (!file_exists($filepath)) return ''; $lines = file($filepath); $preview = ''; $maxLines = 20; $count = 0; foreach ($lines as $line) { if ($count >= $maxLines) { $preview .= '... (更多内容已省略)'; break; } // 跳过纯注释行(但保留有意义的行) $trimmed = trim($line); if ($trimmed === '' || preg_match('/^--/', $trimmed)) { $preview .= htmlspecialchars($line); continue; } $preview .= htmlspecialchars($line); $count++; } return $preview; } /** * 执行单个 SQL 文件 * * 支持两种模式: * 1. 普通模式(默认):所有语句在一个事务中执行,任一条失败则全部回滚 * 2. SAFE 模式(文件首行含 -- @SAFE 标记):每条语句独立执行,失败跳过继续 * 适用于幂等补丁(字段可能已存在、表可能已创建等场景) */ private function runSqlFile($pdo, $filepath, $filename) { try { // 读取文件内容 $sqlContent = file_get_contents($filepath); // 安全检查:禁止执行危险的数据库操作 $dangerousPatterns = [ '/\bDROP\s+DATABASE\b/i', '/\bDROP\s+SCHEMA\b/i', '/\bTRUNCATE\s+TABLE\b/i', '/\bALTER\s+TABLE\s+\S+\s+DROP\s+DATABASE\b/i', ]; foreach ($dangerousPatterns as $pattern) { if (preg_match($pattern, $sqlContent)) { error_log("[databaseUpgrade] Blocked dangerous SQL in {$filename}: matched pattern"); return ['success' => false, 'message' => "{$filename} — 安全拦截:SQL 文件包含危险操作(DROP DATABASE / TRUNCATE),已被拒绝执行"]; } } if (empty(trim($sqlContent))) { // 空文件,标记为已执行 $this->recordMigration($pdo, $filename, 1, ''); return ['success' => true, 'message' => "{$filename} — 空文件,已跳过"]; } // 检测是否为 SAFE 模式(逐句独立执行,容错) $isSafeMode = (strpos($sqlContent, '-- @SAFE') !== false); // 按分号拆分 SQL 语句 $statements = $this->splitSqlStatements($sqlContent); if ($isSafeMode) { // SAFE 模式:逐句独立执行,失败跳过 $total = 0; $success = 0; $ignored = 0; // 可忽略的幂等错误(列/索引已存在等) $realErrors = []; // 需要关注的真实错误 foreach ($statements as $stmt) { $stmt = trim($stmt); if (empty($stmt)) continue; // 移除所有 -- 行注释后检查是否还有实质 SQL 内容 $cleanStmt = preg_replace('/^\s*--.*$/m', '', $stmt); $cleanStmt = trim($cleanStmt); if (empty($cleanStmt)) continue; $total++; try { $this->pdoExec($pdo, $stmt); $success++; } catch (\PDOException $e) { $em = $e->getMessage(); if ($this->isIgnorableSqlError($em)) { // 列/索引已存在等幂等错误,SAFE 模式下可安全跳过 $ignored++; } else { $realErrors[] = "语句{$total}: " . substr($em, 0, 160); } } } // 记录执行结果 if ($total === 0) { $this->recordMigration($pdo, $filename, 1, ''); return ['success' => true, 'message' => "{$filename} — 空文件(SAFE模式),已跳过"]; } // SAFE 模式下无真实错误即视为成功(跳过的都是预期内的幂等错误) $status = empty($realErrors) ? 1 : 1; $msg = "{$filename} — SAFE模式:{$success} 条执行成功"; if ($ignored > 0) { $msg .= ",{$ignored} 条已存在(自动跳过)"; } if (!empty($realErrors)) { $msg .= ",【需关注 " . count($realErrors) . " 条】" . implode(" | ", $realErrors); } $this->recordMigration($pdo, $filename, $status, implode("; ", $realErrors)); return ['success' => true, 'message' => $msg]; } // 普通模式:事务执行 $pdo->beginTransaction(); foreach ($statements as $stmt) { $stmt = trim($stmt); if (empty($stmt)) continue; // 移除所有 -- 行注释后检查是否还有实质 SQL 内容 $cleanStmt = preg_replace('/^\s*--.*$/m', '', $stmt); $cleanStmt = trim($cleanStmt); if (empty($cleanStmt)) continue; $this->pdoExec($pdo, $stmt); } $pdo->commit(); // 记录执行成功 $this->recordMigration($pdo, $filename, 1, ''); return ['success' => true, 'message' => "{$filename} — 执行成功!"]; } catch (\PDOException $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } $errorMsg = $e->getMessage(); // 尝试记录失败(幂等,重复执行时更新而非报错) try { $this->recordMigration($pdo, $filename, 0, $errorMsg); } catch (\Exception $ignoreEx) {} return ['success' => false, 'message' => "{$filename} — 执行失败: " . $errorMsg]; } catch (\Exception $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } $errorMsg = $e->getMessage(); try { $this->recordMigration($pdo, $filename, 0, $errorMsg); } catch (\Exception $ignoreEx) {} return ['success' => false, 'message' => "{$filename} — 执行失败: " . $errorMsg]; } } /** * 按分号拆分 SQL 语句(处理字符串中的分号) */ private function splitSqlStatements($sql) { $statements = []; $current = ''; $inString = false; $stringChar = ''; $len = strlen($sql); for ($i = 0; $i < $len; $i++) { $ch = $sql[$i]; // 处理字符串边界 if ($ch === "'" || $ch === '"') { if (!$inString) { $inString = true; $stringChar = $ch; } elseif ($ch === $stringChar) { // 检查是否是转义引号 $escapeCount = 0; $j = $i - 1; while ($j >= 0 && $sql[$j] === '\\') { $escapeCount++; $j--; } if ($escapeCount % 2 === 0) { $inString = false; $stringChar = ''; } } } // 只在字符串外识别分号 if ($ch === ';' && !$inString) { $statements[] = $current; $current = ''; } else { $current .= $ch; } } // 最后一部分 if (trim($current) !== '') { $statements[] = $current; } return $statements; } // ==================== 数据库备份与恢复 ==================== /** * 数据库备份管理页面 */ public function databaseBackup() { $this->checkLogin(); $user = $this->getCurrentUser(); if ($user['role'] !== self::ROLE_SUPER_ADMIN && $user['role'] !== self::ROLE_ADMIN) { exit('无权限访问'); } $backupDir = APP_PATH . 'backups'; $message = ''; $messageType = ''; // 处理操作 if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->csrfVerify(); $action = $_POST['action'] ?? ''; if ($action === 'backup') { // 执行备份 $result = $this->doBackup($backupDir); $message = $result['message']; $messageType = $result['success'] ? 'success' : 'danger'; } elseif ($action === 'restore' && !empty($_POST['file'])) { // 执行恢复 $file = basename($_POST['file']); $filepath = $backupDir . '/' . $file; if (!file_exists($filepath)) { $message = "备份文件不存在: {$file}"; $messageType = 'danger'; } else { $result = $this->doRestore($filepath); $message = $result['message']; $messageType = $result['success'] ? 'success' : 'danger'; } } elseif ($action === 'delete' && !empty($_POST['file'])) { // 删除备份文件 $file = basename($_POST['file']); $filepath = $backupDir . '/' . $file; if (file_exists($filepath) && unlink($filepath)) { $message = "备份文件 {$file} 已删除"; $messageType = 'success'; } else { $message = "删除失败: {$file}"; $messageType = 'danger'; } } elseif ($action === 'download' && !empty($_POST['file'])) { // 下载备份文件 $file = basename($_POST['file']); $filepath = $backupDir . '/' . $file; if (file_exists($filepath)) { header('Content-Type: application/octet-stream'); header('Content-Disposition: attachment; filename="' . $file . '"'); header('Content-Length: ' . filesize($filepath)); readfile($filepath); exit; } } } // 扫描备份文件列表 $backupFiles = []; if (is_dir($backupDir)) { $files = scandir($backupDir); foreach ($files as $file) { if ($file === '.' || $file === '..') continue; $filepath = $backupDir . '/' . $file; if (is_file($filepath) && pathinfo($file, PATHINFO_EXTENSION) === 'sql') { $backupFiles[] = [ 'name' => $file, 'size' => $this->formatFileSize(filesize($filepath)), 'size_bytes' => filesize($filepath), 'time' => date('Y-m-d H:i:s', filemtime($filepath)), ]; } } // 按时间倒序排列 usort($backupFiles, function ($a, $b) { return strcmp($b['time'], $a['time']); }); } $this->assign('title', '数据库备份与恢复'); $this->assign('user', $user); $this->assign('backupFiles', $backupFiles); $this->assign('message', $message); $this->assign('messageType', $messageType); $this->assign('activeMenu', 'databaseBackup'); $this->assign('csrfToken', $this->csrfToken()); $this->render(); } /** * 执行数据库备份 */ private function doBackup($backupDir) { try { $dbHost = DB_HOST; $dbName = DB_NAME; $dbUser = DB_USER; $dbPass = DB_PASS; $filename = $dbName . '_' . date('Ymd_His') . '.sql'; $filepath = $backupDir . '/' . $filename; // 方式1:尝试使用 mysqldump 命令行 $mysqldumpPath = $this->findMysqldump(); if ($mysqldumpPath) { $command = sprintf( '"%s" --host=%s --user=%s --password=%s --databases %s --add-drop-database --add-drop-table --default-character-set=utf8mb4 --skip-lock-tables --result-file="%s" 2>&1', $mysqldumpPath, escapeshellarg($dbHost), escapeshellarg($dbUser), escapeshellarg($dbPass), escapeshellarg($dbName), $filepath ); exec($command, $output, $returnCode); if ($returnCode === 0 && file_exists($filepath)) { return [ 'success' => true, 'message' => "备份成功!文件: {$filename} (" . $this->formatFileSize(filesize($filepath)) . ")" ]; } // mysqldump 失败,记录错误信息用于调试,回退到 PHP 方式 $dumpError = implode("\n", $output); } // 方式2:纯 PHP 实现备份 return $this->phpBackup($backupDir, $filename, $filepath, $dbHost, $dbName, $dbUser, $dbPass); } catch (\Exception $e) { return ['success' => false, 'message' => '备份失败: ' . $e->getMessage()]; } } /** * 查找 mysqldump 可执行文件路径 */ private function findMysqldump() { // exec 被禁用时,直接返回 null,让 phpBackup() 接管 if (!function_exists('exec')) { return null; } // 常见路径 $paths = [ 'mysqldump', // 系统 PATH 中 'D:\phpEnv\mysql\bin\mysqldump.exe', 'D:\phpEnv\MySQL\bin\mysqldump.exe', 'C:\phpEnv\mysql\bin\mysqldump.exe', 'C:\Program Files\MySQL\MySQL Server 8.0\bin\mysqldump.exe', 'C:\Program Files\MySQL\MySQL Server 5.7\bin\mysqldump.exe', '/usr/bin/mysqldump', '/usr/local/mysql/bin/mysqldump', ]; foreach ($paths as $path) { // 检查文件是否存在,或命令是否可用 // 用 @ 抑制 open_basedir 警告 if (@file_exists($path)) { return $path; } // 对于纯命令名,用 where/which 检测 if (!str_contains($path, '/') && !str_contains($path, '\\')) { $checkCmd = (PHP_OS_FAMILY === 'Windows') ? "where {$path} 2>nul" : "which {$path} 2>/dev/null"; exec($checkCmd, $out, $ret); if ($ret === 0 && !empty($out)) { return trim($out[0]); } } } return null; } /** * 纯 PHP 方式备份数据库 */ private function phpBackup($backupDir, $filename, $filepath, $dbHost, $dbName, $dbUser, $dbPass) { $pdo = \core\db\Db::pdo(); // 获取数据库字符集 $stmt = $pdo->query("SELECT @@character_set_database AS charset"); $charset = $stmt->fetch()['charset'] ?? 'utf8mb4'; $sql = "-- MES 数据库备份\n"; $sql .= "-- 数据库: {$dbName}\n"; $sql .= "-- 备份时间: " . date('Y-m-d H:i:s') . "\n"; $sql .= "-- 字符集: {$charset}\n\n"; $sql .= "SET NAMES {$charset};\n"; $sql .= "SET FOREIGN_KEY_CHECKS = 0;\n\n"; // 获取所有表 $tables = $pdo->query("SHOW TABLES")->fetchAll(\PDO::FETCH_COLUMN); foreach ($tables as $table) { // 跳过 jp_ 前缀的表 if (strpos($table, 'jp_') === 0) continue; // 表结构 $row = $pdo->query("SHOW CREATE TABLE `{$table}`")->fetch(); $sql .= "-- 表结构: {$table}\n"; $sql .= "DROP TABLE IF EXISTS `{$table}`;\n"; $sql .= $row['Create Table'] . ";\n\n"; // 表数据 $dataStmt = $pdo->query("SELECT * FROM `{$table}`"); $rows = $dataStmt->fetchAll(\PDO::FETCH_ASSOC); if (!empty($rows)) { $sql .= "-- 表数据: {$table}\n"; $columns = array_keys($rows[0]); $colList = '`' . implode('`, `', $columns) . '`'; // 分批插入,每批 500 行 $chunks = array_chunk($rows, 500); foreach ($chunks as $chunk) { $values = []; foreach ($chunk as $row) { $escaped = []; foreach ($row as $val) { if ($val === null) { $escaped[] = 'NULL'; } else { $escaped[] = $pdo->quote($val); } } $values[] = '(' . implode(', ', $escaped) . ')'; } $sql .= "INSERT INTO `{$table}` ({$colList}) VALUES\n"; $sql .= implode(",\n", $values) . ";\n"; } $sql .= "\n"; } } $sql .= "SET FOREIGN_KEY_CHECKS = 1;\n"; if (file_put_contents($filepath, $sql)) { return [ 'success' => true, 'message' => "备份成功(PHP方式)!文件: {$filename} (" . $this->formatFileSize(filesize($filepath)) . ")" ]; } return ['success' => false, 'message' => '备份失败:无法写入文件']; } /** * 执行数据库恢复 */ private function doRestore($filepath) { try { $pdo = \core\db\Db::pdo(); $dbName = DB_NAME; // 读取 SQL 文件 $sql = file_get_contents($filepath); if (empty($sql)) { return ['success' => false, 'message' => '备份文件为空']; } // 尝试方式1:使用 mysql 命令行恢复(更快、更可靠) $mysqlPath = $this->findMysql(); if ($mysqlPath) { $command = sprintf( '"%s" --host=%s --user=%s --password=%s --default-character-set=utf8mb4 %s < "%s" 2>&1', $mysqlPath, escapeshellarg(DB_HOST), escapeshellarg(DB_USER), escapeshellarg(DB_PASS), escapeshellarg($dbName), $filepath ); exec($command, $output, $returnCode); if ($returnCode === 0) { return ['success' => true, 'message' => '数据库恢复成功!(命令行方式)']; } $cliError = implode("\n", $output); } // 方式2:纯 PHP 逐条执行 SQL return $this->phpRestore($sql); } catch (\Exception $e) { return ['success' => false, 'message' => '恢复失败: ' . $e->getMessage()]; } } /** * 查找 mysql 客户端可执行文件路径 */ private function findMysql() { // exec 被禁用时,直接返回 null,让 phpRestore() 接管 if (!function_exists('exec')) { return null; } $paths = [ 'mysql', 'D:\phpEnv\mysql\bin\mysql.exe', 'D:\phpEnv\MySQL\bin\mysql.exe', 'C:\phpEnv\mysql\bin\mysql.exe', 'C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql.exe', 'C:\Program Files\MySQL\MySQL Server 5.7\bin\mysql.exe', '/usr/bin/mysql', '/usr/local/mysql/bin/mysql', ]; foreach ($paths as $path) { // 用 @ 抑制 open_basedir 警告 if (@file_exists($path)) { return $path; } if (!str_contains($path, '/') && !str_contains($path, '\\')) { $checkCmd = (PHP_OS_FAMILY === 'Windows') ? "where {$path} 2>nul" : "which {$path} 2>/dev/null"; exec($checkCmd, $out, $ret); if ($ret === 0 && !empty($out)) { return trim($out[0]); } } } return null; } /** * 纯 PHP 方式恢复数据库 */ private function phpRestore($sql) { $pdo = \core\db\Db::pdo(); // 分割 SQL 语句 $statements = $this->splitSqlStatements($sql); $successCount = 0; $errors = []; foreach ($statements as $statement) { $statement = trim($statement); if (empty($statement)) continue; // 跳过纯注释行 if (preg_match('/^--/', $statement)) continue; try { $pdo->exec($statement); $successCount++; } catch (\PDOException $e) { $errors[] = "错误: " . $e->getMessage() . "\nSQL: " . substr($statement, 0, 200); } } if (empty($errors)) { return [ 'success' => true, 'message' => "数据库恢复成功!(PHP方式)共执行 {$successCount} 条语句" ]; } return [ 'success' => false, 'message' => "恢复过程中出现 " . count($errors) . " 个错误(已执行 {$successCount} 条):\n" . implode("\n", array_slice($errors, 0, 5)) ]; } /** * 格式化文件大小 */ private function formatFileSize($bytes) { $units = ['B', 'KB', 'MB', 'GB']; $i = 0; while ($bytes >= 1024 && $i < count($units) - 1) { $bytes /= 1024; $i++; } return round($bytes, 2) . ' ' . $units[$i]; } }