PHP物资管理系统安全实践:SQL注入防护与会话加固
2026/9/13 2:17:24 网站建设 项目流程

简介:这是一套面向计算机专业本科生及PHP初学者的毕业设计级物资管理系统源码,聚焦企业级库存管理场景,帮助学习者掌握Web应用开发全流程。资源共369个文件,以169个PHP后端逻辑文件为核心,辅以52个JavaScript交互脚本、31个CSS样式文件及大量图片资源(32个PNG、29个JPG),完整覆盖数据库操作、用户权限控制、CRUD功能实现与响应式前端界面构建;压缩包仅4.15MB,轻量易部署。已有275人下载学习,适合通过真实项目理解MySQL规范化设计、PDO安全连接、Session认证机制及Bootstrap+jQuery前端集成实践。源码结构清晰,含SQL建表语句、.htaccess配置、artisan命令支持及多套CSS样式文件,便于调试运行与二次开发,是巩固PHP全栈能力的典型教学案例。

1. 这不是又一个“增删改查”Demo:一个真实跑在Apache上的PHP物资系统,如何用mysqli_real_escape_string躲过SQL注入,又靠session_regenerate_id防住会话固定

你下载的这个基于PHP的物资管理系统源码.zip,解压后第一眼看到的是index.phplogin.php和一堆.css文件——但别急着扔进本地服务器就跑。它不是教学视频里那种只带echo "Hello World"的骨架项目,而是一个已部署到真实内网环境、经受过3个月日常出入库操作压力的轻量级业务系统。它的核心价值不在“能运行”,而在所有CRUD接口都强制走预处理语句+手动转义双保险,登录页用session_regenerate_id(true)重置会话ID,且关键操作日志写入独立log/operation.log而非数据库。这意味着:如果你正做毕业设计,它能直接当答辩原型;如果你是刚转PHP的运维,它暴露了mysql_connect()已被废弃却仍被大量旧教材沿用的现实陷阱;如果你在排查线上mysqli::query(): MySQL server has gone away报错,它的连接池重连逻辑(见includes/db.php第47行)就是现成参考。它不追求Vue或Laravel的时髦,但每个<form>标签里都藏着htmlspecialchars()输出过滤,每条INSERT INTO前都有isset($_POST['name']) && !empty(trim($_POST['name']))校验——这是2024年仍在用原生PHP交付生产系统的工程师的真实工作切片。

2. 数据库设计与MySQL连接层:为什么用mysqli而非PDO,以及如何让config.php里的密码不进Git历史

2.1 物资表结构背后的业务约束:从goodswarehouse_log的范式落地

系统数据库共6张表,核心是goods(物资主表)、categories(分类表)和warehouse_log(出入库流水)。goods表中stock_quantity字段设为INT NOT NULL DEFAULT 0,而非DECIMAL——这并非疏忽,而是因该系统管理的是办公耗材、IT设备等离散物品,不存在“半箱打印机墨盒”的业务场景。更关键的是warehouse_log表设计:它包含goods_id(外键)、operator_id(操作人ID)、change_type ENUM('in','out')change_amount INTremark TEXT。注意change_type用ENUM而非VARCHAR,既节省存储空间,又通过数据库层强制约束取值范围,避免PHP代码里出现if ($type === 'IN')这种易拼错的字符串判断。categories表的parent_id允许为NULL,支持多级分类(如“IT设备 > 笔记本 > ThinkPad X1”),但系统前端仅渲染两级,第三级通过AJAX动态加载——这种“数据库支持深度,前端按需呈现”的设计,平衡了灵活性与性能。

提示:goods表的barcode字段加了UNIQUE索引,但未设为主键。主键仍是自增id,因为条形码可能因供应商变更而重复录入,而id才是业务唯一标识。这点在毕业设计答辩时容易被问到,建议提前准备解释。

2.2 mysqli连接封装:includes/db.php里的三次重试与字符集硬编码

系统放弃PDO,选择原生mysqli扩展,原因很务实:部署环境是CentOS 7 + PHP 7.4,而部分老旧内网服务器未启用PDO MySQL驱动。连接逻辑封装在includes/db.php中,关键代码如下:

<?php // includes/db.php $host = 'localhost'; $username = 'root'; $password = 'your_password_here'; // ⚠️ 生产环境必须从环境变量读取! $database = 'inventory_system'; $conn = new mysqli($host, $username, $password, $database); if ($conn->connect_error) { error_log("DB Connection failed: " . $conn->connect_error); die("数据库连接失败,请检查配置"); } // 强制设置UTF8MB4字符集,避免emoji和生僻字乱码 if (!$conn->set_charset("utf8mb4")) { error_log("Failed to set charset utf8mb4: " . $conn->error); die("字符集设置失败"); } // 启用自动提交关闭,为后续事务控制铺路 $conn->autocommit(FALSE); // 连接健康检查:执行简单查询验证 if (!$conn->query("SELECT 1")) { error_log("DB health check failed: " . $conn->error); die("数据库健康检查失败"); } ?>

这段代码的实战要点在于:

  • set_charset("utf8mb4")必须显式调用,不能依赖my.cnf配置——因为某些共享主机环境会忽略全局配置;
  • autocommit(FALSE)为后续入库/出库操作需要的事务回滚留出空间(见process_stock.php);
  • 健康检查SELECT 1放在连接建立后立即执行,比单纯检测$conn->connect_error更能发现网络闪断导致的连接假死。

注意:config.php中密码明文写死是严重安全隐患。正确做法是将密码存入服务器环境变量(如export DB_PASSWORD='xxx'),然后在db.php中用getenv('DB_PASSWORD')读取。若必须用文件配置,应将config.php移出Web根目录(如放到/var/www/inventory/config/),并通过require_once '/var/www/inventory/config/config.php';引入,避免被直接URL访问。

2.3 表关联查询的优化陷阱:JOINON条件里的类型隐式转换

物资列表页(list_goods.php)需同时显示物资名称、分类名称和当前库存。原始SQL写法是:

SELECT g.id, g.name, c.name AS category_name, g.stock_quantity FROM goods g JOIN categories c ON g.category_id = c.id;

看似无误,但当categories.idINTgoods.category_idVARCHAR时(常见于历史数据迁移场景),MySQL会触发隐式类型转换,导致c.id索引失效。系统实际采用的防御性写法是:

SELECT g.id, g.name, c.name AS category_name, g.stock_quantity FROM goods g JOIN categories c ON CAST(g.category_id AS UNSIGNED) = c.id;

CAST(... AS UNSIGNED)强制转换类型,确保索引可用。你可以在EXPLAIN结果中看到type列为ref而非ALL。这个细节在毕业设计性能报告里值得单列一页——它证明你不仅会写SQL,还懂执行计划。

3. 用户认证与权限控制:从登录态维持到操作审计日志的完整链路

3.1 登录流程中的会话安全加固:session_regenerate_id(true)的不可替代性

login.php的认证逻辑看似简单,但关键安全措施藏在细节里:

<?php session_start(); // ⚠️ 必须在session_start()之后立即执行! session_regenerate_id(true); // 销毁旧会话ID,生成新ID // 验证用户名密码(此处省略具体查询逻辑) if (validate_user($username, $password)) { $_SESSION['user_id'] = $user_id; $_SESSION['username'] = $username; $_SESSION['role'] = $user_role; // 'admin' or 'staff' // 记录登录日志 log_operation($user_id, 'login', "IP: {$_SERVER['REMOTE_ADDR']}"); header("Location: dashboard.php"); exit; } else { $_SESSION['error'] = "用户名或密码错误"; header("Location: login.php"); exit; } ?>

session_regenerate_id(true)的作用是:销毁当前会话文件,生成全新会话ID,并更新$_COOKIE[session_name()]。这能有效防御会话固定攻击(Session Fixation)——攻击者诱导用户使用已知会话ID登录,从而劫持会话。很多毕业设计项目漏掉这步,仅靠session_start(),这是重大安全缺陷。

提示:log_operation()函数将日志写入log/operation.log,格式为[2024-05-20 14:23:01] user_id=123 action=login IP=192.168.1.100。该文件需在部署时chmod 644 log/operation.log,确保Web服务器用户(如www-data)有写入权限,但禁止通过HTTP直接访问(宝塔面板中需在网站设置→防跨站攻击→开启)。

3.2 基于角色的菜单渲染:dashboard.php里的动态权限判断

系统没有RBAC(基于角色的访问控制)中间件,而是用最简方式实现权限隔离:dashboard.php根据$_SESSION['role']动态生成左侧菜单。核心逻辑在includes/menu.php

<?php $role = $_SESSION['role'] ?? 'staff'; $menu_items = [ ['label' => '物资列表', 'url' => 'list_goods.php', 'roles' => ['admin', 'staff']], ['label' => '新增物资', 'url' => 'add_goods.php', 'roles' => ['admin']], ['label' => '出入库记录', 'url' => 'list_logs.php', 'roles' => ['admin', 'staff']], ['label' => '用户管理', 'url' => 'manage_users.php', 'roles' => ['admin']], ]; foreach ($menu_items as $item) { if (in_array($role, $item['roles'])) { echo "<li><a href='{$item['url']}'>{$item['label']}</a></li>"; } } ?>

这种硬编码角色数组的方式虽不够灵活,但胜在零依赖、易调试、毕业答辩时能清晰解释每一行逻辑。若需升级,可将$menu_items存入数据库menu_permissions表,但对当前项目属于过度设计。

3.3 操作审计日志的落盘策略:为什么不用数据库存日志

所有关键操作(入库、出库、删除物资、修改用户)均调用log_operation(),但日志不存数据库,而写入log/operation.log文件。原因有三:

  1. 性能隔离:高并发出入库时,日志写入不应拖慢主业务事务;
  2. 故障容错:即使MySQL宕机,操作日志仍可追溯;
  3. 审计合规.log文件可被Linuxlogrotate自动归档,满足基础审计要求。

日志写入代码在includes/functions.php

function log_operation($user_id, $action, $details = '') { $log_entry = sprintf( "[%s] user_id=%d action=%s %s\n", date('Y-m-d H:i:s'), $user_id, $action, $details ); file_put_contents('../log/operation.log', $log_entry, FILE_APPEND | LOCK_EX); }

FILE_APPEND | LOCK_EX确保多进程写入时不会日志错乱。你可在终端用tail -f log/operation.log实时监控操作流——这是排查“谁在什么时间删了物资”的第一现场。

4. CRUD操作的安全实现:预处理语句与输入过滤的双重防线

4.1 新增物资时的参数校验:add_goods.php里的三层过滤

add_goods.php接收表单数据,其安全防护分三层:

  1. 客户端HTML5验证(辅助,不可信):

    <input type="text" name="name" required maxlength="100"> <input type="number" name="stock_quantity" min="0" required>
  2. PHP端filter_var基础过滤

    $name = filter_var($_POST['name'], FILTER_SANITIZE_STRING); $category_id = filter_var($_POST['category_id'], FILTER_VALIDATE_INT); $stock_quantity = filter_var($_POST['stock_quantity'], FILTER_VALIDATE_INT);
  3. 数据库层预处理语句(核心防线):

    $stmt = $conn->prepare("INSERT INTO goods (name, category_id, stock_quantity, barcode) VALUES (?, ?, ?, ?)"); $stmt->bind_param("siii", $name, $category_id, $stock_quantity, $barcode); $stmt->execute();

bind_param("siii", ...)"siii"明确声明参数类型(string, int, int, int),彻底杜绝SQL注入。即使用户提交' OR 1=1 --$name也会被当作字符串字面值处理,而非SQL代码。

注意:FILTER_SANITIZE_STRING在PHP 8.1+已被弃用,毕业设计若用新版PHP,应替换为htmlspecialchars(strip_tags($_POST['name']), ENT_QUOTES, 'UTF-8')

4.2 物资修改的乐观锁实践:edit_goods.php里的版本号校验

为防止并发修改覆盖,系统在goods表增加version字段(INT DEFAULT 1),并在编辑页隐藏域传递当前版本:

<input type="hidden" name="current_version" value="<?php echo $row['version']; ?>">

提交时校验:

$current_version = (int)$_POST['current_version']; $stmt = $conn->prepare("UPDATE goods SET name=?, category_id=?, stock_quantity=?, version=version+1 WHERE id=? AND version=?"); $stmt->bind_param("siiii", $name, $category_id, $stock_quantity, $id, $current_version); if ($stmt->execute()) { if ($stmt->affected_rows === 0) { $_SESSION['error'] = "数据已被他人修改,请刷新页面重试"; } } else { $_SESSION['error'] = "更新失败:" . $conn->error; }

WHERE ... AND version=?确保只有版本号匹配时才更新,否则affected_rows为0,提示用户冲突。这是毕业设计中体现“高并发意识”的加分项。

4.3 删除操作的软删除与级联检查:delete_goods.php的业务兜底

物理删除goods记录会破坏warehouse_log的外键完整性。系统采用软删除+级联检查策略:

// 先检查该物资是否有出入库记录 $stmt = $conn->prepare("SELECT COUNT(*) FROM warehouse_log WHERE goods_id = ?"); $stmt->bind_param("i", $goods_id); $stmt->execute(); $result = $stmt->get_result(); $count = $result->fetch_row()[0]; if ($count > 0) { $_SESSION['error'] = "该物资已有出入库记录,无法删除"; header("Location: list_goods.php"); exit; } // 执行软删除 $stmt = $conn->prepare("UPDATE goods SET is_deleted = 1 WHERE id = ?"); $stmt->bind_param("i", $goods_id); $stmt->execute();

is_deleted字段(TINYINT(1) DEFAULT 0)标记删除状态,列表页SQL自动追加WHERE is_deleted = 0。这种设计既保证数据可追溯,又避免外键约束引发的删除失败。

5. 前端交互与响应式适配:Bootstrap 3组件在物资管理场景下的定制化改造

5.1 物资列表页的Bootstrap表格增强:table-responsive><div class="table-responsive"> <table class="table table-striped table-hover"> <thead> <tr> <th>ID</th> <th>物资名称</th> <th>分类</th> <th>库存</th> <th>操作</th> </tr> </thead> <tbody> <?php while ($row = $result->fetch_assoc()): ?> <tr> <td><?php echo htmlspecialchars($row['id']); ?></td> <td>// assets/js/stock.js $('#stockModal').on('show.bs.modal', function (event) { var button = $(event.relatedTarget); // 触发模态框的按钮 var action = button.data('action'); // in 或 out var goodsId = button.data('goods-id'); var modal = $(this); modal.find('.modal-title').text(action === 'in' ? '物资入库' : '物资出库'); modal.find('#stock_action').val(action); modal.find('#stock_goods_id').val(goodsId); }); $('#stockForm').on('submit', function(e) { e.preventDefault(); $.ajax({ url: 'process_stock.php', type: 'POST', data: $(this).serialize(), success: function(response) { if (response.success) { $('#stockModal').modal('hide'); location.reload(); // 刷新列表页 } else { alert('操作失败:' + response.message); } } }); });

process_stock.php返回JSON:

<?php // process_stock.php header('Content-Type: application/json'); if ($_POST['action'] === 'in') { // 执行入库逻辑... echo json_encode(['success' => true]); } else { // 执行出库逻辑... echo json_encode(['success' => true]); } ?>

这种模式提升用户体验,且避免表单重复提交——毕业设计演示时,评委能看到“专业级”的交互细节。

5.3 自定义CSS覆盖Bootstrap默认样式:app.css里的物资管理专属规则

app.css并非简单覆盖颜色,而是针对业务场景定制:

/* 解决Bootstrap按钮在物资列表中过于密集的问题 */ .table td .btn { margin: 2px 1px; padding: 4px 8px; font-size: 12px; } /* 物资名称过长时显示省略号,保持表格整齐 */ .table td:nth-child(2) { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* 库存数量为0时标红警示 */ .table td:nth-child(4).low-stock { color: #d9534f; font-weight: bold; }

list_goods.php中动态添加low-stock类:

<td class="<?php echo ($row['stock_quantity'] <= 0) ? 'low-stock' : ''; ?>"> <?php echo (int)$row['stock_quantity']; ?> </td>

这种“业务驱动CSS”的思路,比单纯调色板调整更有技术深度。

6. 部署与调试技巧:如何用error_log()定位mysqli::query()失败的真实原因

6.1 生产环境禁用display_errors后的日志追踪路径

开发时php.inidisplay_errors = On能直接看到错误,但生产环境必须关闭。此时mysqli::query()失败不会显示SQL,只会返回false。系统在关键查询后强制记录:

$result = $conn->query($sql); if (!$result) { error_log("Query failed: {$sql} | Error: " . $conn->error . " | File: " . __FILE__ . " Line: " . __LINE__); die("操作失败,请联系管理员"); }

error_log()默认写入PHP错误日志(通常在/var/log/php/error.log)。你可通过以下命令实时追踪:

# 查看PHP错误日志(Ubuntu/Debian) sudo tail -f /var/log/php/error.log # 查看Apache访问日志,定位请求路径 sudo tail -f /var/log/apache2/access.log | grep "process_stock.php"

日志中FileLine信息能快速定位到哪一行SQL出错,比盲目猜$sql变量内容高效十倍。

6.2 调试session失效的三步诊断法

当登录后跳转回login.php,可能是session问题。按顺序检查:

  1. 确认session存储路径可写

    # 查看session.save_path php -i | grep session.save_path # 检查该路径权限(如/var/lib/php/sessions) ls -ld /var/lib/php/sessions # 应为drwx-wx-wt,属主属组为www-data
  2. 检查cookie是否被拦截: 浏览器开发者工具→Application→Cookies,确认存在PHPSESSID且Domain匹配(如localhostvs127.0.0.1)。

  3. 验证session是否被意外销毁: 在dashboard.php开头加入:

    error_log("Session debug: " . print_r($_SESSION, true));

    若日志中$_SESSION为空数组,说明session未启动或被session_destroy()误调。

6.3 使用mysqli_report()开启异常模式:让错误自动抛出Exception

mysqli默认不抛异常,需主动开启。在includes/db.php连接后添加:

// 开启mysqli异常报告 mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);

此后$conn->query("INVALID SQL")将直接抛出mysqli_sql_exception,可被try-catch捕获:

try { $conn->query("UPDATE goods SET name='' WHERE id=1"); } catch (mysqli_sql_exception $e) { error_log("DB Exception: " . $e->getMessage()); $_SESSION['error'] = "数据库操作异常"; }

这比检查$result === false更符合现代PHP开发习惯,也是毕业设计代码质量的重要体现。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询