PHP药品销售系统开发实战:从数据库设计到订单事务实现
2026/9/19 13:56:57 网站建设 项目流程

简介:面向需要完成PHP/MySQL课程设计或毕业设计的计算机专业学生,这是一份基于PHP与MySQL开发的在线药品销售系统毕业论文文档。论文围绕药品零售商线上销售平台展开,完整覆盖摘要、绪论、相关技术简介、系统分析、数据库设计、详细设计与总结等章节,并深入介绍电子商务发展趋势、PHP脚本语言、MySQL关系型数据库、药品管理模块及订单统计功能等关键内容,既呈现了实际开发流程,也提供了毕业论文的通用写作框架。资源包为一个doc格式的Word文档,共1个文件,大小约2.19MB,便于直接阅读或修改,目前已有126人学习。读者可据此快速理解在线药品销售系统的设计思路、功能模块划分与数据库表结构,同时可作为毕业设计起步、论文排版参考以及答辩准备的有力支撑。

1. 从论文.doc看PHP在线药品销售系统要交付什么

“PHP在线药品销售系统 论文.doc”这种命名,通常是毕业设计或课程设计里的标配:左边是一套能跑起来的PHP商城,右边是配套的Word论文,两者凑成完整交付。很多同学把精力花在页面样式上,结果答辩时被问一句“药品和普通商品有什么区别”就卡住了。药品销售系统的难点不在登录、购物车这类通用功能,而在它叠加的业务约束:批准文号、生产批次、有效期、处方药管控、近效期先出。这些约束会反过来决定数据库怎么设计、下单扣库存怎么写、订单状态怎么流转。这里就按一套可落地的方案把骨架搭起来,用MySQL加PHP(PDO)加Redis做主要技术栈,适合要做毕设的、以及想把老商城改造成带合规字段的读者。

2. 药品销售系统的领域模型与数据库设计:从批准文号到批次库存

2.1 先拆实体还是先建表:药品销售系统的核心域

这类系统最常见的错误,是把“药品”直接建成一张大表,库存、销量、分类全塞进去。初期跑得通,一加批次和有效期就乱了。先拆实体,再用实体定表结构,后面的代码会好写很多。

核心实体包括:用户、药品分类、药品主数据、批次库存、购物车、订单、订单明细、处方记录。这个拆分背后要回答三个业务问题:

  1. 这个药能不能卖。由prescription_type(处方药或OTC)决定,处方药在下单时必须有处方标识。
  2. 这个批次有没有效。由batch_noexpire_date决定,药店的管理规范要求近效期先出(FEFO,First Expired First Out),过期批次不能参与销售。
  3. 下单能不能履约。这个不能只查一个总库存数字,要落到具体批次上扣减,否则会出现“总库存够、但有效期最近的批次不够”这种尴尬情况。

所以表的设计原则是:药品主数据与库存分离,库存与批次绑定,订单与商品快照绑定。

2.2 药品主数据表:商品名、通用名、批准文号、处方类型的取舍

先给出最核心的drug表结构:

CREATE TABLE `drug` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `category_id` int unsigned NOT NULL COMMENT '分类ID', `name` varchar(100) NOT NULL COMMENT '商品名', `generic_name` varchar(100) NOT NULL DEFAULT '' COMMENT '通用名', `approval_no` varchar(50) NOT NULL DEFAULT '' COMMENT '批准文号', `prescription_type` tinyint NOT NULL DEFAULT 0 COMMENT '0-OTC 1-处方药', `price` decimal(10,2) NOT NULL COMMENT '销售价', `status` tinyint NOT NULL DEFAULT 1 COMMENT '1上架 0下架', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category_id`), KEY `idx_approval_no` (`approval_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='药品主数据';

字段设计上有几个点要说明。generic_name(通用名)和name(商品名)必须分开存,同一款阿莫西林可能有多个厂家、多个商品名,但通用名相同。approval_no批准文号是药品的身份证号,产品页和论文的E-R图里都应该突出这个字段。pricedecimal(10,2)而不用float,因为浮点数在金额累加时会产出0.1+0.2 != 0.3这类问题,涉及钱一律用定点数。prescription_typetinyint存字典值,不要直接存“处方药”字符串,后续扩展状态时只加注释不动结构。

做完主表,配套的分类表不建议用无限级递归,常见做法是category表里带一个parent_id,只支持两级:大类(感冒用药、肠胃用药)和子类(风寒感冒、风热感冒),查询时用PHP递归组装,或者直接一条SQL把两级一次性查出来。

2.3 批次库存与订单表:两段式冻结库存和近效期先出落库

药品不能只有总量,必须有批次维度。batch_stock表设计如下:

CREATE TABLE `batch_stock` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `drug_id` int unsigned NOT NULL, `batch_no` varchar(50) NOT NULL COMMENT '生产批次号', `expire_date` date NOT NULL COMMENT '有效期至', `stock` int NOT NULL DEFAULT 0 COMMENT '物理库存', `frozen_stock` int NOT NULL DEFAULT 0 COMMENT '预占库存', PRIMARY KEY (`id`), KEY `idx_drug_expire` (`drug_id`, `expire_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='批次库存';

这里用了“物理库存+预占库存”两段式。用户下单时先冻结(frozen_stock加数量),支付成功后才真正扣减(stock减、frozen_stock减),用户取消则释放预占。这样做的目的是把“下单”和“扣减”解耦,避免用户在支付页停留时库存被其他人买走。

idx_drug_expire联合索引直接支撑近效期先出查询:WHERE drug_id = ? AND stock - frozen_stock > 0 AND expire_date >= CURDATE() ORDER BY expire_date ASC,先过期的批次优先售卖。如果没有批次概念,实现不了这个逻辑。

订单表要单独建,且订单号必须唯一:

CREATE TABLE `orders` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '业务订单号', `user_id` int unsigned NOT NULL, `total_amount` decimal(10,2) NOT NULL, `status` tinyint NOT NULL DEFAULT 0 COMMENT '0待付款 1已付款 2已发货 3已完成 -1已取消', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';

订单明细表order_items需要冗余下单时的商品名、批准文号、单价和批次号,这是电商的通用做法:商品信息后续可能改,但订单里的快照不能变。注意主表名不能叫orderorder是MySQL 8.0的保留字,直接建表会报语法错误,所以这里用orders

3. 用PHP和Redis把商品查询与购物车跑通

3.1 用PDO封装药品查询:预编译、绑定参数与返回数组

商品列表是系统最基础的接口。PHP操作MySQL我只会用PDO,原因有三:PDO支持预处理语句,从机制上挡住大部分SQL注入;异常模式配合try/catchmysql_*函数返回数组再判断错误清晰得多;后续换数据库驱动不用改业务代码。下面这段是一个带分类ID和分页的查询服务类:

<?php declare(strict_types=1); class DrugService { private PDO $pdo; public function __construct(PDO $pdo) { // 开启异常模式,SQL出错直接抛PDOException $this->pdo = $pdo; } public function listByCategory(int $categoryId, int $page, int $pageSize): array { $offset = ($page - 1) * $pageSize; $sql = 'SELECT id, name, generic_name, price, prescription_type FROM drug WHERE category_id = :cid AND status = 1 ORDER BY id DESC LIMIT :limit OFFSET :offset'; $stmt = $this->pdo->prepare($sql); // 整数必须显式绑定PARAM_INT,否则PDO会按字符串处理,影响索引 $stmt->bindValue(':cid', $categoryId, PDO::PARAM_INT); $stmt->bindValue(':limit', $pageSize, PDO::PARAM_INT); $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); $stmt->execute(); // FETCH_ASSOC只返回字段名下标,避免数字键混在返回结果里 return $stmt->fetchAll(PDO::FETCH_ASSOC); } }

这里两个地方容易被忽略。bindValue必须显式声明PDO::PARAM_INT,如果只传字符串,MySQL有可能放弃category_id上的索引,数据量小无所谓,数据量大了就是全表扫描。第二个是FETCH_ASSOC,如果不加,返回的数组会同时包含数字键和字段名键,直接json_encode出去会让接口体积变大,对接方拿到数据结构也不干净。

有一个容易被忽略的连接设置:PDO::ATTR_EMULATE_PREPARES要不要关。常见的做法是在构造PDO时设置PDO::ATTR_EMULATE_PREPARES => false,让MySQL端做真正的预处理;但如果用了LIMIT这种需要参数化的地方,某些MySQL版本配合本地预处理会有兼容问题,建议先保持默认值,实际报错再调整。

3.2 购物车放进Redis:hash结构、自增和过期策略

购物车这种临时数据,放MySQL表里属于能跑但没必要:读写频繁、生命周期短、用户不登录还要做临时标识。常见做法是直接放到Redis里,用hash类型存用户与药品的对应关系:

<?php class CartService { private Redis $redis; public function __construct(Redis $redis) { $this->redis = $redis; } public function add(int $userId, int $drugId, int $qty): void { // cart:1001 是hash的key,drugId是field,qty是value $key = 'cart:' . $userId; $this->redis->hIncrBy($key, (string)$drugId, $qty); // 7天不活跃自动清理,避免Redis内存被垃圾购物车拖垮 $this->redis->expire($key, 7 * 86400); } public function getItems(int $userId): array { // hGetAll返回 [drugId => qty] 的映射数组 return $this->redis->hGetAll('cart:' . $userId); } }

hIncrBy而不是先hGethSet,是为了避免并发情况下两个请求同时读到旧值、各自加一导致数量丢失。Redis的hIncrBy是原子操作,这一行代码直接把竞态解决了。expire设置成7天,作用是让长时间不登录的用户购物车自动失效,不用额外写清理任务。

购物车接口返回的数据结构也是外面对接时经常出问题的地方。要在控制器里把Redis返回的[drugId => qty]映射成[['drug_id' => 1, 'qty' => 2], ...]这种列表结构,再批量查药品表填充商品信息。PHP里array_maparray_column配合一次就能处理完,接口层永远返回规整的数组对象,前端不用关心存储结构。

3.3 商品图片缩略图:用GD库处理上传图

商品上架必须处理图片。PHP里生成缩略图最省事的方案是GD扩展,安装PHP时默认带。下面是一个最小可用的缩略图生成逻辑:

<?php function makeThumb(string $srcPath, string $dstPath, int $maxWidth = 400): void { [$width, $height, $type] = getimagesize($srcPath); // 按原图比例缩放,避免拉伸变形 $scale = $maxWidth / $width; $newWidth = (int)($width * $scale); $newHeight = (int)($height * $scale); $src = imagecreatefromstring(file_get_contents($srcPath)); $dst = imagecreatetruecolor($newWidth, $newHeight); imagecopyresampled($dst, $src, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height); // JPEG质量85,图片体积和清晰度比较平衡 imagejpeg($dst, $dstPath, 85); imagedestroy($src); imagedestroy($dst); }

代码里的imagecreatefromstring能自动识别JPEG、PNG、WebP等格式,不用针对每种类型写分支。imagecopyresampled是重采样缩放,比imagecopyresized清晰度高,适合药品说明书这类文字多的图片。质量参数85是常见做法,设置成100会让图片体积翻倍,设置太低文字边缘会出现锯齿。生成完缩略图,原图保存到独立目录,列表页只读缩略图,详情页用原图,服务器的磁盘和带宽压力会小很多。

4. 药品下单的事务边界、订单状态机与PHP安全底线

4.1 扣库存必须用事务加行锁:一个能扛住并发的下单示例

药品下单选批次扣库存是系统里最不能出错的环节。并发情况下,两个用户同时买了同一批次的最后一盒,不做锁控制就会出现“都看到库存充足、都下单成功、实际只有一盒”的结果。这里必须用数据库事务配合SELECT ... FOR UPDATE行锁:

<?php $pdo->beginTransaction(); try { // FOR UPDATE 对命中的批次行加排他锁,其他事务必须等这个事务结束 $stmt = $pdo->prepare( 'SELECT id, stock, frozen_stock FROM batch_stock WHERE drug_id = :drugId AND stock - frozen_stock > 0 AND expire_date >= CURDATE() ORDER BY expire_date ASC LIMIT 1 FOR UPDATE' ); $stmt->execute([':drugId' => $drugId]); $batch = $stmt->fetch(PDO::FETCH_ASSOC); if (!$batch) { throw new RuntimeException('该药品暂无可用库存'); } // 再次用条件更新保证不超卖,rowCount为0说明这一批次已经没了 $update = $pdo->prepare( 'UPDATE batch_stock SET frozen_stock = frozen_stock + :qty WHERE id = :batchId AND stock - frozen_stock >= :qty' ); $update->execute([ ':qty' => $qty, ':batchId' => $batch['id'], ]); if ($update->rowCount() === 0) { throw new RuntimeException('库存不足'); } $pdo->commit(); } catch (Throwable $e) { $pdo->rollBack(); throw $e; }

这段代码的逻辑是:先按近效期排序锁住一个可用批次;然后执行条件更新,SQL的WHERE里再次写上stock - frozen_stock >= :qty;最后rowCount()为0就抛异常回滚。FOR UPDATE锁的是索引行而不是整张表,并发下单A和B会排队,后到的事务等前一个提交后才能读到最新库存。这是把“最终一致性”的校验交给数据库,而不是依赖PHP代码里的if判断。

事务里有一个值得注意的细节:SELECTUPDATE之间不能有耗时操作,比如调用第三方处方审核接口或发送通知。行锁持有时间越短,并发吞吐越高。正确的做法是锁库存只做库存相关操作,处方校验放在事务之前完成。

4.2 订单状态机:支付、发货、取消的动作边界

订单状态用tinyint存储后,状态流转逻辑建议固定成一张表,避免不同开发人员随意修改status字段。我做项目时习惯先定义状态值,再在PHP里定义一个OrderStatus类,用常量代替魔法数字。

状态值状态名允许进入的动作说明
0待付款取消、去支付下单冻结库存后的初始态
1已付款发货支付回调成功后置为1
2已发货确认收货管理员后台操作
3已完成终态
-1已取消终态,需释放冻结库存

每个动作都是先更新状态,再执行关联操作。比如取消订单时,事务里要做两件事:把订单状态改成-1,把该订单明细关联的冻结库存释放掉(UPDATE batch_stock SET frozen_stock = frozen_stock - ? WHERE id = ?)。这里有一个常见的坑:直接用status = -1作为条件去更新订单,然后忘记释放库存,库存被冻结越积越多。我的习惯是把“释放冻结库存”和“更新订单状态”放在同一个事务里,要么都成功要么都失败。

支付回调是外部请求,要考虑重复通知。支付接口返回“成功”可能不止一次,所以在处理支付回调时要先查订单当前状态,只有status = 0才更新为status = 1,否则直接返回“已处理”响应。

4.3 PHP安全清单:反序列化、文件上传和错误日志的底线

PHP项目被扫描出来的漏洞翻来覆去就那几个方向,药品系统涉及用户实名和处方信息,安全底线不能省。

第一是SQL注入,上面的例子全部使用PDO预处理语句,绑定参数,绝不拼接SQL。第二是unserialize反序列化,永远不要对用户输入直接调用unserialize(),PHP对象注入和POP链在历史漏洞里反复出现。如果业务需要在客户端和服务端之间传结构化数据,用json_encode/json_decode替代。第三是文件上传,图片上传必须校验文件内容和后缀是否匹配,用getimagesize()finfo判断真实文件类型,同时把文件名改写成随机字符串,防止用户把PHP一句话木马改成.jpg后缀上传后再结合解析漏洞执行。第四是错误处理,不要把异常堆栈直接输出到页面,否则数据库账号、表结构、文件路径全暴露了。

统一错误处理的标准做法是注册异常处理器:

<?php set_exception_handler(function (Throwable $e) { // 完整错误信息写日志,方便排查 error_log(sprintf( '[%s] %s in %s:%d', date('Y-m-d H:i:s'), $e->getMessage(), $e->getFile(), $e->getLine() )); // 用户端只收到通用提示 http_response_code(500); header('Content-Type: application/json'); echo json_encode([ 'code' => 500, 'message' => '系统繁忙,请稍后再试', 'data' => null, ]); });

开发环境和生产环境的错误展示必须分开。本地调试时可以显示$e->getMessage(),生产环境一律走上面的通用响应。php.ini里的display_errors生产环境设为Offlog_errors设为On,这样既不影响问题排查,也不泄露内部信息。

5. 论文.doc 怎么写才能让代码和文档对得上

5.1 给所有接口一个统一返回结构,调试命令直接截图进论文

写论文和答辩时,代码和文档对不上是硬伤。一个最简单有效的操作:给所有接口定义统一的JSON返回结构,然后论文里出现的数据交互截图,全部来自真实接口调用。

{ "code": 0, "message": "ok", "data": { "order_no": "202501011200001001", "total_amount": "88.00", "status": 0 } }

PHP里实现很直接,控制器最终都是return $this->json(['order_no' => $orderNo], 0, 'ok')json()方法内部包装成上面的结构。code是业务状态码,0表示成功;message是给前端提示的文案;data是具体的业务数据。前端无论对接哪个接口,只用关心这三个字段。

答辩演示时用curl调用接口,比在浏览器里点点点更有说服力:

curl -s -X POST http://localhost:8080/api/order/create \ -H 'Content-Type: application/json' \ -H 'X-User-Id: 1001' \ -d '{"drug_id":101,"qty":2}'

返回的JSON要格式化成带缩进的样子再截图,用jq处理一下:curl -s ... | jq .。论文里贴这种截图,老师一眼就能看出接口是真实跑过的,而不是拿原型图凑数。

5.2 压测记录、E-R图和关键代码在论文里的呈现顺序

论文的技术实现部分,建议按“数据库设计(E-R图)→ 核心功能设计(订单状态图)→ 关键代码(下单事务)→ 系统测试(压测和功能测试)”这个顺序组织,这个顺序其实就是开发时真正经历的路径。

E-R图用draw.io或MySQL Workbench反向生成,导出PNG再插入文档时,图片分辨率不能太低,表格字段名要能看清。订单状态机可以画成横向流程图,状态值、触发动作、终态标注好,这比写三段文字解释状态流转效率高得多。

系统测试部分加一张压测结果表,参数和结果写具体才有说服力。

并发数请求总数平均响应时间吞吐率(QPS)失败数
20100085ms11800
501000210ms9500
1002000430ms6203

压测命令用Apache自带的ab就能完成:

ab -n 1000 -c 20 -T 'application/json' \ -p order_body.json \ http://localhost:8080/api/drug/list

-c 20表示20个并发,-p指定POST请求体。要注意压测环境要开生产配置(关闭调试模式、开启Opcache),否则压出来的数字低得没法看。论文里的压测结论不要写“系统性能优秀”这种话,改成“本系统在100并发下平均响应时间430ms,满足中小型药店系统日订单量千级场景的需求”,这个表述才和具体数据对得上。最后把压测日志、接口截图、E-R图按时间戳归档,论文和代码交付时就能形成对应关系,答辩追问到哪个表都能随手翻到设计源头。

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

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

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

立即咨询