Files
MES/app/controllers/AdminController.php
2026-08-08 18:28:49 +08:00

3280 lines
127 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\controllers;
use core\base\Controller;
use app\models\Employee;
use app\models\Workstation;
use app\models\Customer;
use app\models\ProductModel;
use app\models\SysConfig;
use app\models\EmployeePermission;
use app\models\ProductTypeConfig;
use app\models\StationDefinition;
class AdminController extends Controller
{
public function index()
{
$this->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 '<h3>无效的表索引</h3>';
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 '<h3>表不存在</h3>';
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 '<h3>无效的表索引</h3>';
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 '<h3>记录不存在</h3>';
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 '<h3>记录不存在</h3>';
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 nameADD COLUMN 时列已存在
* - 1061 Duplicate key nameCREATE INDEX 时索引已存在
* - 1091 Can't DROP ... check that column/index existsDROP 时对象已不存在
* - 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];
}
}