多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

JavaScript全栈医院管理系统实践:从环境搭建到事务与幂等设计

JavaScript全栈医院管理系统实践:从环境搭建到事务与幂等设计 简介这是一套医院日常管理系统的Web源码基于JavaScript技术栈构建按患者、医生、管理三个模块划分业务患者登录后可在线预约门诊、查看约诊记录、维护个人资料医生账号可添加或移除患者、开具电子处方、答复预约申请管理员则拥有最高权限能查看全部患者、医生和预约数据并对医生账号进行增删。压缩包约50.49MB含近2000个文件以js、css、svg、ts、scss等为主分别承载前端逻辑、页面样式、图标资源与类型声明另配html、php、sql等文件便于还原完整可运行的项目环境。目前已有158人学习/下载。包内使用AdminLTE后台管理模板目录层次清晰适合按模块查找代码通过梳理预约、处方、后台管控等链路既能理解医院信息系统的基本设计思路也能为二次开发或课程设计提供直接参考。1. 先聊聊这个 hospital-management-esystem 到底能干什么最近在本地跑了一个叫 hospital-management-esystem 的医院管理系统它的定位很直接把挂号、收费、药房、病历这几件日常高频动作全部塞进同一套页面流里前端用 JavaScript 做交互后端也用 JavaScript 跑接口前后端语言统一调试和维护门槛比想象中低。适合中小型诊所、科室内部管理系统甚至是教学用的模拟项目X只要不是三甲那种千级并发这套结构完全够用。我用一套伪造的测试数据把它完整跑起来后第一个感觉是它能解决的不只是“记录”而是把医疗业务里的状态流转理清了。比如挂号之后队列自动推进医生开完处方药房库存实时扣减收费记录贯穿看诊全程。这些逻辑在传统表格工具里要反复人工核对在系统里则变成一条清晰的链。这篇笔记就围绕这套系统的落地说清楚环境怎么搭、核心模块怎么实现的、哪些地方最容易翻车以及值得直接抄走的几个边界处理方案。2. 为什么用 JavaScript 全栈架构选型与落地环境2.1 模块划分与数据流这套 hospital-management-esystem 不是那种前后端分离的微服务巨型工程而是典型的单仓库全栈项目后端跑在 Node.js Express 上前端用的是 Vue 2 生态。第一次打开目录时我觉得结构很干净按业务域拆成了几个独立路由patient患者建档、资料修改、查询registration挂号、排班、队列状态prescription处方录入、审方pharmacy库存台账、出库入库记录billing收费明细、发票记录数据流是单向的挂号请求写入 registration 表然后生成待诊队列医生接诊后创建处方记录处方关联 patient_id 和 doctor_id提交处方时后端会同时操作 prescription 表和 pharmacy 表库存扣减失败则整单回滚。这个设计在业务上是对的因为处方和库存必须强一致不能出现“处方开了但药房没货”的脏数据。我通常会先看一遍根目录下的 .env 模板确认数据库连接、端口、密钥字段是否齐全。这套项目默认用的是 MySQL 8ORM 是 sequelize表结构由 migration 文件同步没有直接用同步方法这点值得表扬。启动环境前需要先建一个空库比如CREATE DATABASE hospital_esystem DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;这里有个细节utf8mb4 不是可选项而是必须项。因为患者姓名、地址、过敏史这些字段可能包含生僻字或者 emoji 表情符号如果用老旧的 utf8_general_ci入库时会出现 Incorrect string value 错误。我早期做某医疗项目时就踩过这个坑后来所有新项目默认强制 utf8mb4。2.2 环境准备与启动顺序启动前需要安装 Node.js 14 以上和 npm然后用 npm install 拉取依赖。安装过程我会加一行参数确保把 devDependencies 也装全因为 sequelize-cli 和 nodemon 都在里面npm install --includedev接着修改 .env 配置。常见做法是把本地配置写成这样DB_HOST127.0.0.1 DB_PORT3306 DB_NAMEhospital_esystem DB_USERroot DB_PASSWORD你的密码 JWT_SECRET一串足够长的随机字符串 PORT3000JWT_SECRET 是登录鉴权的核心参数必须随机生成不能写在代码里。我一般用 openssl rand -hex 32 生成一段 64 字符的随机值如果使用固定默认值生产环境等于裸奔。数据库迁移和种子数据是启动前的关键步骤建议按顺序执行npx sequelize-cli db:migrate npx sequelize-cli db:seed:all迁移命令会按 migration 文件的时间戳顺序建表种子命令会写入默认用户、科室字典和基础药品目录。这里有一个新手常犯的错误先跑 seed 再跑 migrate结果表不存在直接失败。正确顺序一定是先建表再灌数据。seed 里通常会有一个 admin 账号密码是 hash 后的字符串默认明文在 README 里可以用系统登录页测试一下我就用默认账密直接进去了。启动开发服务用npm run dev这个脚本底层是 nodemon 监听 src 目录修改代码后自动重启。要注意的是如果电脑上同时装了 MySQL 8 和 MariaDBsequelize 连接可能出现认证协议错误这时需要在数据库执行ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY 你的密码;这是 MySQL 8 默认 caching_sha2_password 驱动和旧版 Node 模块不兼容导致的常见做法是转回 native password但如果你的 Node 已经升级到 16其实可以直接用新版驱动不建议动数据库认证协议。3. 核心模块实现患者管理与挂号流程3.1 患者登记的数据表设计与提交接口患者表是整个系统的地基。在设计上它的字段除了姓名、性别、出生日期还必须有身份证号唯一索引和过敏史备注字段。唯一索引的作用不只是查重用更是为了防止重复建档——日常医院场景里同名同姓太常见如果只按姓名去重会产生连环错误。数据表的创建脚本里关键部分长这样// models/patient.js - 患者模型定义 module.exports (sequelize, DataTypes) { const Patient sequelize.define(Patient, { name: { type: DataTypes.STRING(50), allowNull: false, comment: 患者姓名 }, id_card: { type: DataTypes.STRING(18), allowNull: false, unique: true, comment: 身份证号唯一索引 }, phone: { type: DataTypes.STRING(20), validate: { is: /^1[3-9]\d{9}$/ }, comment: 手机号简单格式校验 }, allergy_history: { type: DataTypes.TEXT, allowNull: true, comment: 过敏史自由文本 } }, { tableName: patients, timestamps: true, underscored: true }); return Patient; };这段代码定义了患者表的四个核心字段其中 id_card 设置了 unique: true数据库层面保证不会重复建档phone 字段用了 sequelize 的 validate 正则只让 11 位手机号通过。timestamps 开启后会自动生成 created_at 和 updated_atunderscored 让字段名变成下划线风格和 MySQL 约定保持一致。表结构设计完后对应的注册接口一般放在 routes/patient.js 里接收 POST 请求并调用 findByCreate 方法// routes/patient.js - 患者建档接口 router.post(/register, async (req, res) { try { const { name, id_card, phone, allergy_history } req.body; // 先查身份证号是否已存在 const existing await Patient.findOne({ where: { id_card } }); if (existing) { return res.status(409).json({ message: 该身份证号已建档 }); } const newPatient await Patient.create({ name: name.trim(), id_card, phone, allergy_history: allergy_history || }); res.status(201).json({ id: newPatient.id, message: 建档成功 }); } catch (err) { res.status(500).json({ message: 建档失败, error: err.message }); } });这里有三个逻辑要点。第一查重用的是 findOne 而非 findAndCountAll因为只需要判断存不存在不需要总数第二name 做了 trim 处理避免用户手滑输入空格第三allergy_history 默认为空字符串防止 NULL 值导致前端渲染时出现 undefined。这些细节在真实业务里都很重要。3.2 挂号队列生成的业务逻辑挂号模块比患者建档更考验代码设计因为它涉及队列状态、医生排班和时间戳边界。系统里的挂号表 registration 包含以下状态pending待诊、consulting就诊中、finished已完成、cancelled已取消。队列生成逻辑不是简单往表里插入记录而是要同时检查医生当日排班和当前队列人数。核心实现通常会放在 services/registration_service.js 里用一个事务把挂号创建和队列编号生成合并// services/registration_service.js - 挂号队列服务 const { Registration, Doctor, sequelize } require(../models); async function createRegistration({ patient_id, doctor_id, date, period }) { const result await sequelize.transaction(async (tx) { // 1. 锁定医生当日排班记录防止并发重复挂号 const doctor await Doctor.findOne({ where: { id: doctor_id }, lock: tx.LOCK.UPDATE, transaction: tx }); if (!doctor) throw new Error(医生不存在); // 2. 查询当前队列中未完成的数量 const count await Registration.count({ where: { doctor_id, date, period, status: [pending, consulting] }, transaction: tx }); // 3. 队列号 当前数量 1简化场景下不使用时间段分组 const queueNo count 1; const reg await Registration.create({ patient_id, doctor_id, date, period, queue_no: queueNo, status: pending }, { transaction: tx }); return { queueNo, regId: reg.id }; }); return result; }这段代码最大的价值在于事务和行锁。doctor 表被 lock 后同一时间只有一个挂该医生号的请求能通过查询从根本上避免了两个病人同时挂到同一个队列号。count 查询和 create 操作放在同一事务里不会出现先查到 1 然后两个人同时插入 2 的脏数据。参数说明date 参数要传日期字符串如 2025-02-10period 是上午/下午字段。实际业务中可能还要加上预约时段但当前系统用这两个字段已经能把队列拆开。接口层直接调用这个 service不用关心事务细节。我一般会建议把 queue_no 设计成“医生 日期 时段”维度自增而不是全医院统一自增因为患者关注的是“这个医生今天排到第几个”而不是“全医院今天看诊序号”。如果按全医院自增患者看到数字跳得很猛体验很差。4. 处方与药房管理数据建模和库存扣减逻辑4.1 处方单结构设计处方模块的数据结构是典型的一对多关系一张处方主表对应多行药品明细。主表存患者 ID、医生 ID、诊断描述、开单时间明细表存药品 ID、数量、用法用量、单价、小计金额。分开两张表是最合理的设计如果想把明细直接塞进主表的一个 JSON 字段后续统计和审核会非常痛苦。明细表有一个关键字段 dosage记录每天服用几次、一次几片比如 “每日3次每次1片”。它是给患者看的文本不能拆成结构化字段因为不同剂型的说明差异太大。药品的 price 字段必须从药品目录表冗余进来不能在明细表里用外键联查因为历史处方需要保留当时的单价如果药品调价旧处方不能跟着变。创建处方的接口里事务范围至少包含三个动作创建主记录、批量创建明细、扣减库存。下面是一个简化的创建逻辑// services/prescription_service.js - 创建处方并扣库存 const { Prescription, PrescriptionItem, DrugInventory, sequelize } require(../models); async function createPrescription({ patientId, doctorId, diagnosis, items }) { const txResult await sequelize.transaction(async (tx) { // 1. 创建处方主表 const pres await Prescription.create({ patient_id: patientId, doctor_id: doctorId, diagnosis, status: active }, { transaction: tx }); // 2. 循环写入药品明细同时扣减库存 for (const item of items) { const drug await DrugInventory.findByPk(item.drug_id, { transaction: tx, lock: tx.LOCK.UPDATE }); if (!drug) { throw new Error(药品ID ${item.drug_id} 不存在); } if (drug.stock_qty item.quantity) { throw new Error(药品 ${drug.name} 库存不足当前仅剩 ${drug.stock_qty}); } // 扣减库存 await drug.decrement(stock_qty, { by: item.quantity, transaction: tx }); // 写入明细 await PrescriptionItem.create({ prescription_id: pres.id, drug_id: item.drug_id, drug_name: drug.name, price: drug.selling_price, quantity: item.quantity, dosage: item.dosage, subtotal: drug.selling_price * item.quantity }, { transaction: tx }); } return pres; }); return txResult; }这段代码做了两件容易忽略的事一是把 drug_name 和 price 冗余到了明细表这样后续处方打印不需要再联查药品表二是库存扣减时先查药品行并加锁然后直接用 decrement 方法做原子操作。这个写法比先读 stock_qty 再 update 更安全因为数据库的行锁避免了并发超卖。这里也要注意一个边界问题事务块里如果 items 很大逐个操作数据库会有性能压力。常见做法是分批批量插入明细但医院处方通常只有几种药逐个操作完全够用。4.2 库存台账更新与幂等设计药房库存管理不能只看到 stock_qty 被扣减还需要留下流水。每次出库入库都写一份 stock_movements 记录字段包括药品 ID、变动数量、变动类型inbound/outbound/return、关联单据号、操作人、操作时间。这个台账的价值在审计和定位故障哪天库存对不上只需要查这个流水表就能倒推是哪张单子出了问题。在扣减库存时如果只更新总数而不写流动表一旦数据错误排查范围会扩大到整张库存表完全没有头绪。所以我习惯把库存扣减和台账写入放在同一个事务里// services/pharmacy_service.js - 扣减库存并写台账 async function adjustStock({ drugId, changeQty, type, refNo, operatorId }) { const result await sequelize.transaction(async (tx) { const drug await DrugInventory.findByPk(drugId, { transaction: tx, lock: tx.LOCK.UPDATE }); if (!drug) throw new Error(药品不存在); const newStock drug.stock_qty changeQty; if (newStock 0) throw new Error(库存不足); await drug.update({ stock_qty: newStock }, { transaction: tx }); await StockMovement.create({ drug_id: drugId, change_qty: changeQty, type, ref_no: refNo, operator_id: operatorId, before_qty: drug.stock_qty, after_qty: newStock }, { transaction: tx }); return newStock; }); return result; }changeQty 正数代表入库负数代表出库。before_qty 和 after_qty 是冗余字段这样前台展示历史流水时不需要再去查当时的库存值。幂等设计体现在 ref_no 上同一张处方在重试请求时必须用相同的 ref_no并在写入台账前对 ref_no 加唯一约束避免重复扣减。我在一次模拟项目X里就遇到过这样的问题前端提交处方时网络中断用户点了一次重试按钮结果处方创建了两条库存扣了两次。如果没有唯一约束这个错误很难发现。后来在 business_ref_no 字段上加了 unique index并在插入时捕获 SQL ER_DUP_ENTRY 异常重试请求直接返回已存在的处方单问题迎刃而解。5. 避坑排查这套系统最容易翻车的几个点5.1 高频踩坑记录现象 1导入种子数据后管理员账号登录提示密码错误。原因seed 文件中的密码哈希可能是用旧版本 bcrypt 计算的长度 60 字符串而 Node 侧的 bcrypt 版本升级后对某些哈希的解析兼容性不一致或者 seed 哈希里混入了换行符。解决不用依赖 seed直接用脚本生成 hash。在系统目录下跑一段 Node 命令生成新哈希然后手动 update 数据库记录。常见做法是这样node -e const bcryptrequire(bcrypt); console.log(bcrypt.hashSync(你的密码, 10))把打印出的 hash 字符串填进数据库 users 表的 password_hash 字段注意不要带多余空格。从那以后我每拿到一套新系统都会先跑这个命令检查 admin 密码而不是直接信任 seed 数据。现象 2我同时打开两个浏览器窗口挂同一个医生结果队列号全是 2。原因挂号 service 中虽然用了事务但事务内部是先 findOne 医生再 count如果没有对应行锁两个请求能同时读到 count 1然后各自创建 queue_no 2。解决确认 Doctor.findOne 查询里确实加了 lock 参数。有些经验不足的开发者把 lock: tx.LOCK.UPDATE 写在了普通查询里实际不生效。验证方法是在 MySQL 命令行查看进程列表挂起一个断点看第二个请求是否处于等待状态。这个坑很隐蔽不加锁从表面看也能跑通但压力测试一来就完蛋。现象 3前端传过来的药品数量明明是字符串 “1”库存扣减却时报错。原因sequelize 在接收字符串值时会按字段类型做转换但如果没有声明 type 为 INTEGER而是默认 VARCHAR那么 item.quantity 在 JS 里是字符串前端做了拼接比如 “1” “1” “11”扣了 11 盒药。解决在 DrugInventory 模型的 stock_qty 字段明确声明 DataTypes.INTEGER并且在接口层用 Number(item.quantity) 做一次强制转换。更稳妥的手段是在路由中间件里写一个函数把传入的 id、quantity 全部转数字。现象 4所有接口在本地正常部署到服务器后有些图片不显示。原因上传图片的路径是绝对路径比如 C:\uploads\avatar.png在服务器上当然不存在。解决代码里所有静态资源引用改成相对路径 /uploads并在 Express 中挂载静态目录app.use(/uploads, express.static(path.join(__dirname, uploads)));这个坑虽然是老生常谈但我亲眼看到过同事因为手滑多写了一个盘符前缀让整个图片模块在服务器上全军覆没。现象 5生产环境数据库连接数被占满系统卡死。原因sequelize pool 默认最大连接数是 10如果服务重启频繁或者连接没释放连接池会被耗尽。解决在 config/config.js 里显式配置 pool 参数pool: { max: 30, min: 5, acquire: 30000, idle: 10000 }同时检查代码里是否有未关闭的事务特别是手动 sequelize.transaction 时如果忘记 await连接就会泄漏。这个排查起来相当蛋疼我的一般做法是用 Node 的 process.on(unhandledRejection) 日志追踪process.on(unhandledRejection, (reason) { console.error(Unhandled Rejection:, reason); });5.2 高效排查工具与日志技巧hospital-management-esystem 默认只在控制台输出请求日志这在实际定位问题上不够用。我建议把日志分级输出到文件分别记录 error.log 和 access.log。常见做法是引入 winston 库简单配置如下// utils/logger.js - 分级日志 const winston require(winston); const logger winston.createLogger({ level: info, format: winston.format.combine( winston.format.timestamp(), winston.format.printf(({ timestamp, level, message }) { return ${timestamp} [${level}] ${message}; }) ), transports: [ new winston.transports.File({ filename: logs/error.log, level: error }), new winston.transports.File({ filename: logs/combined.log }) ] }); if (process.env.NODE_ENV ! production) { logger.add(new winston.transports.Console()); } module.exports logger;logger 模块可以在每个 route 入口调用比如logger.info(createRegistration success: patient ${req.body.patient_id}, doctor ${req.body.doctor_id});排查问题时先看 error.log 再查数据库慢查询日志能省掉大量瞎蒙时间。还有一点经验接口返回给前端的 error 信息不要带内部堆栈尤其不要把 SQL 错误原样抛出去否则数据库结构会暴露给客户端。把 error.message 塞进日志前端只给一个“服务器内部错误”。6. 进阶技巧给预约接口加限流和幂等控制日常医院管理系统的核心接口是挂号和预约这类接口天然存在重复点击风险。除了前面说的用业务单号唯一约束来保证重复请求不重复创建还需要在入口加一层限流防止刷号和恶意请求。我这里给出一个基于内存的简单限流方案不引入 Redis适合单机部署的小规模场景。先封装一个简单的 RateLimiter 类用 Map 记录每个用户的访问时间戳// utils/rateLimiter.js - 滑动窗口限流 class RateLimiter { constructor(windowMs, max) { this.windowMs windowMs; this.max max; this.hits new Map(); } check(key) { const now Date.now(); const windowStart now - this.windowMs; let timestamps this.hits.get(key) || []; // 只保留当前时间窗口内的请求记录 timestamps timestamps.filter(t t windowStart); if (timestamps.length this.max) { return false; } timestamps.push(now); this.hits.set(key, timestamps); return true; } } module.exports RateLimiter;使用方式是在 Express 路由上给 /register 加中间件const rateLimiter new RateLimiter(60000, 5); router.post(/register, (req, res, next) { const key req.body.phone || req.ip; if (!rateLimiter.check(key)) { return res.status(429).json({ message: 操作太频繁请稍后再试 }); } next(); }, registrationHandler);这个限流器的参数是 60 秒内最多 5 次。如果患者因为网络重试点击了 10 次后面的请求会被安全挡住。这里要注意一个边界生产环境如果有多个 Node 实例内存限流会把同一用户的请求分散到不同进程导致限制失效。这时就需要把滑动窗口数据放到 Redis 中用 Lua 脚本保证原子性。对于 hospital-management-esystem 这种单机部署的场景内存版已经够用。幂等控制的完整建议是两层合起来用第一层限流挡住短时间内的重复点击第二层在数据库层用业务单号唯一约束挡住跨时间片的重试。我用模拟项目X验证过给 registration 表加一个 appointment_no 字段设置 unique index前端在跳转支付页前生成一个 UUID请求失败后重试同一个 UUID数据库只会创建一条挂号记录。这个习惯救了我很多次。除了限流另一个值得投入的进阶方向是给处方接口加操作审计。在中间件里记录操作人 ID、路由、请求体摘要和时间形成审计日志。这套医院管理系统原本就带有一个操作日志表但默认只记录登录事件没有覆盖处方创建和库存调整。我后来把审计中间件挂到了所有写操作路由上出问题能精确追溯到是哪位医生、什么时间、改了什么数据对医疗系统的合规性帮助很大。最后分享一个我个人的小习惯每次部署前强制在测试环境跑一遍“挂号 → 开方 → 扣库存 → 退药回补库存”的完整链路用自动化脚本断言每个环节的流水记录数量。退药回补往往是最容易被忽略的环节如果不写自动化断言退药失败时库存会悄悄变少等到盘点时才发现那种感觉真的很难受。从那以后我每套系统都强制走一遍完整链路再上线希望帮到你。本文还有配套的精品资源点击获取
返回列表