简介:面向中医诊所信息化场景的 PHP 开源后台管理项目,适合具备 PHP 与 MySQL 基础的 Web 开发者、医疗信息化学习者及有二次开发需求的团队,用于快速搭建患者、预约、药品库存等后台管理功能。压缩包共 2000 个文件、12.45MB,以 js、less、html、php、json、md、css 等为主,分别承载前端交互、样式页面、业务逻辑、配置说明与文档,目录结构较完整。已有 933 人学习下载。项目覆盖 MVC 分层、身份验证与 RBAC 权限、RESTful API、数据安全、错误日志及缓存优化等实践点,并附带预配置数据库,便于读者理解诊所管理系统的模块组织、接口设计与安全处理思路,也可作为课程设计、毕业项目或二次开发的基础模板。
1. 从一份诊所后台压缩包说起:PHP + MySQL 的老派管理系统怎么落地
一份 PHP 开源中医诊所后台管理压缩包,带数据库,最能打动人的不是界面,而是它把挂号、病历、中药处方、库存、收费这些医疗场景里的碎活塞进了一个可私有化部署的后台。中医诊所和综合门诊不一样,处方有炮制、先煎后下、帖数、忌口,库存还要按克扣减,这些字段如果系统里没有,后面二次开发会非常难受。这份资源适合想自己掌控数据、又愿意动手改 PHP 的开发者和小型诊所运维,不适合指望开箱即用、零维护的团队。拿到包先别急着上传服务器,先看 PHP 版本和数据库字符集,否则登录页都打不开。
2. 环境与数据库:把 PHP 诊所后台跑起来的四个关键参数
2.1 PHP 版本与扩展:先做兼容性扫描再谈部署
这类后台管理包大多不是新框架产物,常见组合是原生 PHP 加 MySQL,前端 jQuery 或 Bootstrap。它最大的变数不在业务代码,而在 PHP 版本。很多老包在 PHP 5.6 或 7.x 上能跑,直接扔进 PHP 8 就可能白屏,原因通常是mysql_*函数被移除、each()被废弃、短标签<?没开,或者构造函数同名写法不再兼容。我一般不会先改代码,而是先做一轮静态扫描,把风险点列出来再决定是降版本还是改代码。
#!/usr/bin/env bash set -euo pipefail APP_DIR="${1:-./app}" # 1. 逐文件语法检查,先把 parse error 找出来 find "$APP_DIR" -name '*.php' -print0 \ | xargs -0 -n1 php -l > /tmp/php_lint.log 2>&1 || true # 2. 扫描已移除或高风险的旧函数 grep -RInE '\b(mysql_(query|connect|fetch_array|real_escape_string)|ereg|split\(|each\(|create_function|get_magic_quotes_gpc)' \ "$APP_DIR" --include='*.php' | head -n 80这段脚本做两件事:先用当前 PHP 做语法检查,确认有没有直接解析失败的 PHP 文件;再扫旧函数调用点。APP_DIR参数指向解压后的代码目录,默认./app,实际按压缩包解压后的目录名改。head -n 80是防止输出刷屏,先看前 80 条就能判断改造量。如果mysql_*大量出现,不要去找来历不明的兼容扩展,生产环境更稳的做法是改成 PDO 或 mysqli;本地只想看表结构,可以临时用低版本 PHP 跑一遍,但不要把这个版本长期挂在公网。
扩展方面,至少确认pdo_mysql、mbstring、fileinfo、gd或imagick可用。处方图片、药品图片、报表导出都可能用到。php -m看一眼,缺什么补什么。别等上传处方照片失败才回头查fileinfo,这是常见的翻车点。
2.2 建库与导入:字符集、排序规则和时区一次定死
数据库是这份资源的核心部分,导入前先看 SQL 文件头部。用head -n 40 database/*.sql看它有没有自带CREATE DATABASE、USE、DEFINER。如果 SQL 里带了视图或触发器,DEFINER指向一个不存在的账号,导入会报错,需要把DEFINER=...去掉再导。字符集不要靠默认值,建库时直接定utf8mb4,否则中药名里的生僻字、患者备注里的特殊符号迟早出问题。
CREATE DATABASE IF NOT EXISTS clinic_bg DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'clinic_app'@'localhost' IDENTIFIED BY '替换成强密码'; GRANT SELECT, INSERT, UPDATE, DELETE ON clinic_bg.* TO 'clinic_app'@'localhost'; FLUSH PRIVILEGES;应用账号只给增删改查,不要给DROP和GRANT。导入命令显式指定字符集:
mysql -u root -p --default-character-set=utf8mb4 \ -e "CREATE DATABASE IF NOT EXISTS clinic_bg DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" mysql -u root -p --default-character-set=utf8mb4 \ clinic_bg < database/clinic.sql--default-character-set=utf8mb4是关键参数,少了它,SQL 文件里的中文可能按 latin1 解释,导入后药名变问号。导入完执行SHOW TABLE STATUS看几张核心表的Collation是不是utf8mb4_unicode_ci,不一致的表用ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci统一。时区也要在 PHP 和 MySQL 两边对齐,代码里用环境变量传时区,示例先写 UTC,部署时换成诊所实际时区标识,不要一边 UTC 一边本地时间,收费日结会直接错一天。
2.3 连接配置与入口文件:别把数据库密码写死
老包的数据库配置常见于config/database.php、includes/config.php或conn.php,具体文件名以压缩包为准。不管原来怎么写,我都建议改成环境变量读取,至少不要把生产密码提交进仓库。下面是一个 PDO 配置示例,老代码用 mysqli 的按同样思路补charset和错误模式。
<?php // config/database.php 示例,按实际文件名调整 return [ 'dsn' => 'mysql:host=127.0.0.1;port=3306;dbname=clinic_bg;charset=utf8mb4', 'user' => getenv('CLINIC_DB_USER') ?: 'clinic_app', 'pass' => getenv('CLINIC_DB_PASS') ?: 'change-me', 'options' => [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ], ];dsn里的charset=utf8mb4不能省,它决定连接层编码。ATTR_ERRMODE设成异常,排查时能看到具体 SQL 错误,而不是静默返回 false。ATTR_EMULATE_PREPARES=false让预处理真正交给 MySQL,减少参数拼接带来的注入面。密码用getenv读取,Web 服务器或 PHP-FPM 池里配置环境变量,不要把change-me带到线上。入口文件方面,先找根目录下哪个文件包含登录表单和session_start,确认后台入口是/admin/、/manage/还是根目录直入。入口确定后再配 Web 服务器,顺序反了会一直 404。
2.4 Web 服务器重写与目录权限:后台 404 和 500 的源头
Nginx 下最常见的问题是伪静态没配,首页能开,点后台菜单全 404。另一个是root指到了项目根目录,但入口文件在public下,导致 PHP 文件被当静态文件下载。下面这段配置按“入口在 public”写,实际按包结构改root。
server { listen 80; server_name clinic.local; root /var/www/clinic/public; index index.php index.html; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_pass unix:/run/php/php-fpm.sock; } # 禁止直接下载数据库脚本、备份和日志 location ~* \.(sql|bak|zip|log)$ { deny all; } }try_files是伪静态核心,找不到文件就交给index.php。fastcgi_pass按机器上的 PHP-FPM sock 路径改,别照抄。.sql、.bak、.zip的 deny 规则必须加,后台压缩包里经常带 SQL 文件,放在 Web 根下等于把患者表结构公开。目录权限用下面这组命令收口:
find /var/www/clinic -type d -exec chmod 755 {} \; find /var/www/clinic -type f -exec chmod 644 {} \; chmod -R 775 /var/www/clinic/uploads /var/www/clinic/runtime chown -R www-data:www-data /var/www/clinic目录 755、文件 644,上传和运行时目录给 775,不要图省事chmod -R 777。runtime目录不存在就跳过,uploads按实际上传目录名改。权限改完清一次 PHP opcache 和浏览器缓存,再试登录。后台入口和初始账号看压缩包里的安装说明或 SQL 注释,网上流传的admin/123456不要拿来就用,先改密码再录数据。
3. 中医诊所业务表怎么读:患者、处方、库存、收费的字段边界
3.1 患者与病历:身份证、手机号、过敏史该不该加密
患者表和病历表是后台的地基。常见字段包括患者编号、姓名、性别、出生日期、手机号、身份证号、过敏史、主诉、现病史、舌象、脉象、诊断、证型。中医病历还多一层“证型”和“治法”,这两个字段不要塞进诊断备注里,否则后面按证型统计病种时没法查。表名可能是patient、medical_record,也可能带前缀,按实际调整。
隐私字段的处理不用一上来搞复杂,但至少做到三点:后台列表页手机号中间四位掩码;身份证号限制只有医生和管理员角色能看完整值;导出功能单独加权限。身份证如果要保留完整值,存两列更稳,一列密文或哈希用于比对,一列后四位用于展示。过敏史不要只存自由文本,给一个“无/有”标记加文本详情,否则筛选“有过敏史患者”只能靠LIKE '%无%',很容易把“无过敏”也筛出来。病历的visit_time用DATETIME,不要用字符串,日结和复诊提醒都靠它。
3.2 中药处方:剂量、煎法、频次字段的建模坑
中药处方最容易在字段设计上翻车。同一味药有生黄芪、炙黄芪、炒白术、麸炒白术,如果药品表只有一个name,后面库存和收费会混。可接受的做法是medicine表保留标准名,再加processing字段表示炮制方式,处方明细引用药品 ID;如果老包已经把炮制品建成独立药品,不要强行合并,先保证库存能对上。剂量用DECIMAL(10,2),不要用FLOAT,克数累计多了会出现0.30000000000000004这种值。
| 业务对象 | 常见表名 | 关键字段 | 注意点 |
|---|---|---|---|
| 患者 | patient | patient_id、name、phone、id_card、allergy | 手机号展示掩码,身份证限制查看角色 |
| 病历 | medical_record | record_id、patient_id、doctor_id、visit_time、chief_complaint、tongue、pulse、diagnosis、syndrome | 证型单独字段,便于统计 |
| 处方主表 | prescription | prescription_id、record_id、doctor_id、doses、decoction_method、status、total_amount | 帖数、煎法、状态要独立 |
| 处方明细 | prescription_item | item_id、prescription_id、medicine_id、dose_g、unit、special_instruction、quantity | 先煎后下等特殊用法逐味存 |
| 药品 | medicine | medicine_id、name、processing、spec、unit、stock_qty、cost_price、retail_price | 成本价和零售价分列 |
| 库存流水 | inventory_log | log_id、medicine_id、change_qty、biz_type、biz_id、created_at | 只增不改,作为对账依据 |
| 收费 | charge | charge_id、patient_id、prescription_id、receivable、received、discount、pay_method、status | 应收、实收、减免分开 |
煎法字段不要只存“水煎服”。先煎、后下、包煎、另煎、烊化、冲服这些特殊用法要落到明细行,每味药一行备注。频次和帖数放主表,例如“每日一剂,分早晚温服,共 7 帖”。处方状态至少要有草稿、已收费、已发药、已退费,状态流转不要用物理删除,退费留痕比删记录重要。
3.3 药品库存与收费:一条处方如何联动库存和应收
库存扣减时机要先定规矩。我一般按“发药扣库存”做,收费只生成应收和实收,不直接动库存。这样患者先缴费后取药、或者先诊疗后结算都能兼容。发药时按处方明细汇总每味药的总消耗量,单位统一到克,特殊单位(毫升、支)单独换算。库存扣减必须和库存流水在同一个事务里,流水记录biz_type='prescription'和biz_id,退药时反向插一条正数流水,不要改原流水。
收费表和处方表用prescription_id关联,应收金额按明细零售价乘数量汇总,减免单独存。支付方式至少分现金、扫码、银行卡、挂账,挂账要有余额和结清状态。日结报表按DATE(created_at)分组,时区不一致会漏单。库存为负不要只靠页面提示,数据库层加stock_qty >= 0的检查或更新条件,代码里再兜一层。中药库存按克管理,饮片损耗和盘点差异用盘点单调整,不要直接改stock_qty,否则对账时找不到原因。
3.4 角色权限:医生、药房、收费员的最小权限矩阵
后台权限不要只做菜单隐藏。老包常见做法是登录后根据role_id显示不同菜单,但接口没校验,收费员直接拼 URL 也能打开药品成本页。最小权限矩阵可以按下面这张表落地,后端每个入口文件开头做角色判断,SQL 查询限制字段。
| 功能 | 医生 | 药房 | 收费员 | 管理员 |
|---|---|---|---|---|
| 查看患者基本信息 | 是 | 是 | 是 | 是 |
| 查看完整身份证 | 是 | 否 | 否 | 是 |
| 开处方 | 是 | 否 | 否 | 是 |
| 审核处方 | 是 | 是 | 否 | 是 |
| 发药扣库存 | 否 | 是 | 否 | 是 |
| 收费与退费 | 否 | 否 | 是 | 是 |
| 查看药品成本价 | 否 | 是 | 否 | 是 |
| 导出报表 | 否 | 否 | 是 | 是 |
| 用户与角色管理 | 否 | 否 | 否 | 是 |
医生可以看自己开的处方,药房看待发药队列,收费员只看收费相关字段。成本价字段在收费员查询里直接不SELECT,比前端隐藏可靠。退费权限单独给,退费必须填原因并写操作日志。
4. 二次开发上手:从登录鉴权到报表导出的可抄改代码
4.1 登录与 Session:老系统的鉴权链路怎么补
老后台常见的问题是登录只判断$_SESSION['admin']是否存在,没有角色、没有过期时间、没有会话固定防护。先在公共文件里补一个require_login(),所有后台入口第一行调用。Session 配置在session_start前设置,httponly和samesite能挡掉一部分脚本读取和跨站请求。
<?php // includes/auth.php session_start([ 'cookie_httponly' => true, 'cookie_samesite' => 'Lax', 'use_strict_mode' => true, ]); function require_login(): array { if (empty($_SESSION['user_id'])) { header('Location: /login.php'); exit; } return [ 'id' => (int)$_SESSION['user_id'], 'role' => (string)($_SESSION['role'] ?? 'guest'), ]; } function require_role(array $user, array $allowed): void { if (!in_array($user['role'], $allowed, true)) { http_response_code(403); exit('无权限'); } }cookie_httponly禁止 JavaScript 读 Cookie,cookie_samesite=Lax降低跨站请求风险,use_strict_mode防会话固定。HTTPS 环境再加cookie_secure => true。require_role的$allowed传角色数组,例如['doctor','admin']。如果老库密码是 32 位 md5,登录逻辑先兼容再升级:
<?php function verify_password_compat(string $plain, string $stored): bool { if (password_verify($plain, $stored)) { return true; } if (preg_match('/^[a-f0-9]{32}$/i', $stored) && hash_equals(strtolower($stored), md5($plain))) { return true; // 登录成功后立刻用 password_hash 重写该用户密码 } return false; }password_verify处理新哈希,md5 分支只用于老用户首次登录。登录成功拿到用户 ID 后执行UPDATE user SET password_hash = ? WHERE user_id = ?,把 md5 换掉。不要长期保留 md5 分支,也不要把 md5 当成新密码存储方式。
4.2 处方保存事务:库存扣减和收费记录的原子性
处方保存涉及主表、明细表、库存、流水,任何一步失败都不能留下半条数据。下面示例把发药扣库存放在一个事务里,先用SELECT ... FOR UPDATE锁住药品行,再用带条件的UPDATE二次防负库存。
<?php function save_prescription(PDO $pdo, int $patientId, int $doctorId, array $items): int { if (!$items) { throw new InvalidArgumentException('处方明细为空'); } $pdo->beginTransaction(); try { $stmt = $pdo->prepare( "INSERT INTO prescription (patient_id, doctor_id, status, created_at) VALUES (?, ?, 'draft', NOW())" ); $stmt->execute([$patientId, $doctorId]); $prescriptionId = (int)$pdo->lastInsertId(); $lock = $pdo->prepare( "SELECT medicine_id, stock_qty FROM medicine WHERE medicine_id = ? FOR UPDATE" ); $insertItem = $pdo->prepare( "INSERT INTO prescription_item (prescription_id, medicine_id, dose_g, quantity) VALUES (?, ?, ?, ?)" ); $updateStock = $pdo->prepare( "UPDATE medicine SET stock_qty = stock_qty - ? WHERE medicine_id = ? AND stock_qty >= ?" ); $log = $pdo->prepare( "INSERT INTO inventory_log (medicine_id, change_qty, biz_type, biz_id, created_at) VALUES (?, ?, 'prescription', ?, NOW())" ); foreach ($items as $it) { $medicineId = (int)$it['medicine_id']; $qty = (float)$it['quantity']; // 本次消耗总克数 if ($qty <= 0) { throw new RuntimeException('数量必须大于 0'); } $lock->execute([$medicineId]); $row = $lock->fetch(); if (!$row || (float)$row['stock_qty'] < $qty) { throw new RuntimeException("库存不足: {$medicineId}"); } $insertItem->execute([ $prescriptionId, $medicineId, (float)$it['dose_g'], $qty, ]); $updateStock->execute([$qty, $medicineId, $qty]); if ($updateStock->rowCount() !== 1) { throw new RuntimeException('库存扣减失败'); } $log->execute([$medicineId, -$qty, $prescriptionId]); } $pdo->commit(); return $prescriptionId; } catch (Throwable $e) { $pdo->rollBack(); throw $e; } }$items每项至少包含medicine_id、dose_g、quantity。示例里quantity直接表示本次消耗总克数;如果表里拆了“单帖剂量”和“帖数”,先在循环外算$qty = $doseG * $doses再传进来。FOR UPDATE锁行防并发超卖,UPDATE的stock_qty >= ?是第二道保险。rowCount() !== 1说明更新没命中,直接抛异常回滚。库存流水只插不改,退药时用正数流水冲回。
4.3 报表导出:把日结和药品消耗导成 CSV
收费日结和药品消耗是诊所每天都要看的表。导出 CSV 时加 UTF-8 BOM,Excel 打开中文才不乱码;查询用参数绑定,不要把日期拼进 SQL。导出接口先做角色校验,只允许收费员和管理员访问。
<?php function export_daily_charge(PDO $pdo, string $date): void { $stmt = $pdo->prepare( "SELECT c.charge_id, p.name, c.receivable, c.received, c.pay_method, c.created_at FROM charge c JOIN patient p ON p.patient_id = c.patient_id WHERE DATE(c.created_at) = ? AND c.status = 'paid' ORDER BY c.charge_id" ); $stmt->execute([$date]); header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="daily_charge_' . $date . '.csv"'); $out = fopen('php://output', 'w'); fwrite($out, "\xEF\xBB\xBF"); // Excel 识别 UTF-8 fputcsv($out, ['收费单号', '患者', '应收', '实收', '支付方式', '时间']); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { fputcsv($out, [ $row['charge_id'], $row['name'], $row['receivable'], $row['received'], $row['pay_method'], $row['created_at'], ]); } fclose($out); }$date用Y-m-d格式,参数绑定后由 MySQL 处理。BOM 是给 Excel 看的,不加的话中文列名可能乱码。数据量大时不要fetchAll,逐行fetch写输出,避免内存爆。文件名只拼日期,不要让用户传文件名。药品消耗报表按inventory_log的负数流水汇总,配合biz_type过滤处方发药,退药正数自然抵消。
5. 避坑与排查:PHP 诊所后台最常见的五个翻车点
5.1 环境与入口类故障
现象:登录页白屏,HTTP 500,日志里出现Call to undefined function mysql_connect()。原因多半是代码还在用 PHP 5 时代的mysql_*函数,而运行环境已经是 PHP 7 或 8,这类扩展早被移除。解决不要去找兼容扩展硬撑,先用 2.1 的扫描脚本定位调用点,集中改成 PDO 或 mysqli;本地只想看表结构可以临时用低版本跑,但公网环境不要挂旧版本。改完用php -l再扫一遍,确认没有语法错误。
现象:首页能打开,点后台任何菜单都 404。原因通常是伪静态没配,或者root指错目录。先确认入口文件到底在根目录还是public,再看 Nginx 有没有try_files;Apache 环境检查.htaccess是否被AllowOverride All放行。改完配置nginx -t再 reload,不要直接重启。
5.2 数据与并发类故障
现象:中药名导入后变成问号,患者备注里的生僻字丢失。原因多为 SQL 文件是utf8mb4,导入命令没带--default-character-set=utf8mb4,或者连接 DSN 少了charset=utf8mb4。解决是建库、导入、连接三处统一字符集;已有乱码先备份,再用CONVERT谨慎修复,不要直接在生产库上试。
现象:两笔处方同时发药,库存扣成负数。原因是先查库存再更新,中间没有事务和行锁。解决按 4.2 的做法,事务包住主表、明细、库存和流水,SELECT ... FOR UPDATE锁行,UPDATE带stock_qty >= ?条件,流水只增不改。上线前用两个会话并发跑一遍,确认第二个会话会等待或报库存不足。
5.3 权限与上传类故障
现象:收费员能打开药品成本页,医生能改收费金额。原因是只在前端隐藏菜单,后端入口没做角色校验。解决是每个后台入口第一行调用require_role,查询层按角色限制字段,成本价对收费员直接不查。权限矩阵按 3.4 的表落地,改完用不同角色账号逐个 URL 试。
现象:处方图片上传失败,或者上传目录能被直接下载。上传失败先看upload_max_filesize、post_max_size和目录权限;能被下载说明上传目录在 Web 根下且没有 deny 规则。解决是把上传目录移出 Web 根或用 Nginx 限制访问,文件名重命名,类型白名单校验,别只靠前端accept。
6. 上线前的验证与加固:我用一套检查表收尾
上线前我不会只看页面能不能点,而是做一次数据对账和恢复演练。库存对账 SQL 如下,inventory_log只增不改,期初库存要单独加进汇总,否则差异永远不为零。
SELECT m.medicine_id, m.name, m.stock_qty, COALESCE(SUM(l.change_qty), 0) AS ledger_qty, m.stock_qty - COALESCE(SUM(l.change_qty), 0) AS diff FROM medicine m LEFT JOIN inventory_log l ON l.medicine_id = m.medicine_id GROUP BY m.medicine_id, m.name, m.stock_qty HAVING diff <> 0;diff不为零的先查有没有手工改过stock_qty,再查盘点单和退药流水。备份用mysqldump --single-transaction,导完立刻在临时库恢复一遍,确认表数量和关键行数对得上。
mysqldump --single-transaction --routines --default-character-set=utf8mb4 \ -u clinic_app -p clinic_bg | gzip > /backup/clinic_$(date +%F_%H%M).sql.gz恢复演练不要在生产库做,建一个clinic_restore_test临时库导入,跑一遍日结查询。检查表里我固定看四项:PHP 错误日志有没有新报错、慢查询里有没有全表扫描、上传目录能不能被直接访问、不同角色能不能越权打开页面。血泪经验是,老后台的坑往往不在业务代码,而在环境版本和字符集。从那以后我每次拿到这类 PHP 诊所后台包,都先把兼容性扫描、字符集导入、权限矩阵和备份恢复走一遍,再谈功能改不改。希望帮到你。
本文还有配套的精品资源,点击获取