资讯动态

Node.js + Vue 家庭医生管理系统实践:从权限设计到部署避坑

发布时间:2026/10/14 14:39:39 来源:尧图企业网站定制
一个家庭医生管理系统看起来无非就是“增删改查”几个模块堆起来但真正落地的时候业务边界、角色权限、前后端数据流转这些环节每一个都能让你折腾到半夜。这篇文章就围绕我实际开发一套 Node.js Vue 家庭医生管理系统的过程聊聊模块规划、后端接口设计、前端状态管理以及那些文档里不会写清楚的部署和环境坑。这套系统的核心价值不在于技术多新而在于把所有家庭医生服务场景比如签约、建档、随访、慢病跟踪、在线沟通串成一条完整的业务链路。文中的代码片段都是可直接用的架构思路也适用于其他医疗或预约类管理系统。1. 家庭医生管理系统的业务边界与模块规划很多人拿到这类项目第一反应是直接开写结果做到一半发现需求边界完全失控。家庭医生管理系统跟普通挂号系统最大的区别在于它是一个“长期关系”的管理系统核心不是单次预约而是持续的健康跟踪。1.1 角色模型设计从实际使用场景出发这个系统至少要拆出三类角色超级管理员管理医生账号、审核签约申请、查看全站数据看板。家庭医生处理签约申请、录入健康档案、制定随访计划、开处方。患者/居民提交签约申请、查看自己的档案和随访建议、与医生进行在线沟通。有些系统会再加一个“护士/助理”角色但初期版本不建议加角色越多权限逻辑越复杂第一版应该做减法。权限控制我放在后端的中间件里统一做前端只负责隐藏入口真正的安全校验必须落在服务端。1.2 核心模块拆分按业务优先级我整理出六个核心模块模块核心功能关键数据表签约管理签约申请、家庭医生审批、解约contract, contract_log健康档案基本信息、既往病史、过敏史、体检记录patient_profile, medical_record随访计划周期生成、任务提醒、完成反馈follow_up_plan, follow_up_task慢病管理血压/血糖记录、趋势图表、异常预警chronic_sign, chronic_alert在线咨询图文聊天、常见问题回复message, consultation系统管理医生排班、公告、数据统计schedule, announcement, stats这里有一个非常容易忽略的细节签约状态和随访计划状态必须联动。比如患者解约之后所有待执行的随访计划应该自动失效而不是继续给医生发提醒。这个逻辑不做好后面上线会被投诉死。2. Node.js 后端接口设计、JWT 认证与数据模型的落地细节后端我用的是 Node.js Express MySQL。选 Express 而不是 Koa核心原因是我需要它的中间件生态足够成熟——文件上传multer、跨域cors、JWT 校验jsonwebtoken都是现成的接入成本极低。2.1 目录结构与接口分层项目的后端目录结构如下这是我个人偏好的按模块划分方式server/ ├── app.js ├── config/ │ └── db.js ├── middleware/ │ ├── auth.js │ └── errorHandler.js ├── modules/ │ ├── auth/ │ │ ├── auth.controller.js │ │ ├── auth.service.js │ │ └── auth.route.js │ ├── contract/ │ ├── record/ │ └── followup/ ├── utils/ │ └── response.js └── sql/ └── init.sql接口统一走 RESTful 规范返回格式固定为{ code: 0, message: success, data: { ... } }在utils/response.js里做统一封装exports.success (res, data, message success) { res.json({ code: 0, message, data }); }; exports.fail (res, message error, code 1) { res.json({ code, message, data: null }); };这样做的好处是前端不需要在 axios 响应里判断不同的结构只要看res.code 0就能确定接口是否正常异常统一走 message 展示。2.2 JWT 身份认证的完整流程身份认证是整个系统的安全基石。我的实现思路是登录成功之后发放 access_token有效期 2 小时同时把用户角色塞进 token 的 payload后续请求通过中间件验证。const jwt require(jsonwebtoken); const config require(../config/db); exports.generateToken (user) { return jwt.sign( { id: user.id, role: user.role }, config.secret, { expiresIn: 2h } ); }; exports.verifyToken (req, res, next) { const header req.headers.authorization; if (!header || !header.startsWith(Bearer )) { return res.status(401).json({ code: 401, message: 未登录或登录已过期 }); } const token header.split( )[1]; try { const decoded jwt.verify(token, config.secret); req.user decoded; next(); } catch (err) { return res.status(401).json({ code: 401, message: token 无效或已过期 }); } };角色鉴权单独抽一个中间件支持传多个角色exports.authorize (...roles) { return (req, res, next) { if (!roles.includes(req.user.role)) { return res.status(403).json({ code: 403, message: 无权限访问 }); } next(); }; };在实际业务中我发现很多开发者只在前端做路由守卫后端所有接口裸奔这是非常危险的。后端的每个受保护接口必须显式装饰authorize中间件不能靠自觉。2.3 数据模型的几个关键设计数据库表的设计是系统能否支撑业务演进的关键我挑三个值得展开的地方**签约表contract**状态机要清晰。我用status字段区分待审批、已签约、已解约三种状态并用contract_log表记录每次状态变更的操作用户、原因和时间。健康档案中敏感字段很多所有涉及患者姓名的查询接口默认开启脱敏前端列表展示时只显示姓名加星号详情页才展示全名。随访任务生成逻辑很值得注意。我在follow_up_plan表存了计划周期时间用定时任务每天扫表把需要执行的随访任务批量插入follow_up_task表。MySQL 这边一条 SQL 搞定INSERT INTO follow_up_task (plan_id, patient_id, doctor_id, task_date, status) SELECT p.id, p.patient_id, p.doctor_id, CURDATE(), 0 FROM follow_up_plan p WHERE p.status 1 AND p.next_task_date CURDATE() AND NOT EXISTS ( SELECT 1 FROM follow_up_task t WHERE t.plan_id p.id AND t.task_date CURDATE() );慢病数据表的设计直接决定前端图表好不好画。血压、血糖这类数据我建议单独建表而不是塞进 JSON 字段因为后续统计趋势图需要按时间做范围查询CREATE TABLE chronic_sign ( id INT PRIMARY KEY AUTO_INCREMENT, patient_id INT NOT NULL, type ENUM(blood_pressure, blood_sugar, heart_rate) NOT NULL, value VARCHAR(50) NOT NULL, measure_time DATETIME NOT NULL, note VARCHAR(255), INDEX idx_patient_time (patient_id, measure_time) );value 字段之所以设计成 VARCHAR是因为血糖有5.6这种小数、血压有120/80这种组合值统一字符串存储最省事做数值比较的时候再用CAST转换。3. Vue 前端登录态管理、动态路由与医患交互的实现逻辑前端用的是 Vue 3 Vue Router 4 Pinia Element Plus。之所以选 Vue 3 是因为组合式 API 在维护复杂业务逻辑时确实比选项式 API 清爽而且 Element Plus 对 Vue 3 支持已经很成熟表格、表单、对话框这些后台管理的高频组件全都有。3.1 登录态与请求拦截前端登录成功之后拿到 token存到 localStorage 是常规操作但要注意不能只存 token用户基本信息建议也缓存一份避免每次刷新页面都要重新拉取用户信息。axios 封装是重点我把核心逻辑放在拦截器里import axios from axios; import { useUserStore } from ../stores/user; import router from ../router; const http axios.create({ baseURL: /api, timeout: 10000 }); http.interceptors.request.use(config { const userStore useUserStore(); if (userStore.token) { config.headers.Authorization Bearer ${userStore.token}; } return config; }); http.interceptors.response.use( response { const res response.data; if (res.code ! 0) { if (res.code 401) { userStore.logout(); router.push(/login); } return Promise.reject(new Error(res.message)); } return res.data; }, error { if (error.response error.response.status 401) { userStore.logout(); router.push(/login); } return Promise.reject(error); } );这里最容易踩的坑是token 过期之后前端收到的响应码可能是 200 而不是 401因为很多后端开发者习惯把所有业务逻辑的异常都包在 200 里。我在开发这套系统时专门做了约定认证失败必须返回 HTTP 401业务异常返回 HTTP 200 code 1这样前端拦截器才能准确区分。3.2 动态路由与权限控制不同角色进入系统后看到的菜单完全不同我用的是动态路由方案。登录成功拿到用户角色后前端根据角色动态注册路由表import { createRouter, createWebHistory } from vue-router; const routes [ { path: /login, component: () import(../views/Login.vue) }, { path: /, component: () import(../layout/Index.vue), redirect: /dashboard, children: [] } ]; const doctorRoutes [ { path: /patient, component: () import(../views/doctor/PatientList.vue), meta: { title: 患者管理 } }, { path: /followup, component: () import(../views/doctor/FollowUp.vue), meta: { title: 随访任务 } } ]; const adminRoutes [ { path: /doctor-manage, component: () import(../views/admin/DoctorManage.vue), meta: { title: 医生管理 } } ];路由守卫里做全局判断router.beforeEach((to, from, next) { const userStore useUserStore(); if (to.path /login) return next(); if (!userStore.token) return next(/login); next(); });需要注意动态路由采用router.addRoute方式挂载不要在定义路由表时就全部注册而是在用户信息拉取完之后再按角色挂载否则权限控制形同虚设。我见过很多项目把全部路由都写上只是靠菜单隐藏来“伪装”权限这种做法前端调试时没问题但一旦有人手动输入 URL 就能直接越权访问。3.3 医患在线沟通的交互实现在线咨询模块是整个系统里交互体验最复杂的部分。我采用了轮询方案而不是 WebSocket原因是轮询的部署成本低、兼容性好而且家庭医生场景下的消息频率并不高1~2 秒轮询一次完全够用。前端聊天逻辑放在组合式 API 中import { ref, onMounted, onUnmounted } from vue; import { getMessageList, sendMessage } from /api/consult; export function useChat(consultationId) { const messages ref([]); const inputText ref(); let timer null; const fetchMessages async () { const list await getMessageList(consultationId); messages.value list; }; const handleSend async () { if (!inputText.value.trim()) return; await sendMessage(consultationId, inputText.value.trim()); inputText.value ; await fetchMessages(); }; onMounted(() { fetchMessages(); timer setInterval(fetchMessages, 2000); }); onUnmounted(() { if (timer) clearInterval(timer); }); return { messages, inputText, handleSend }; }这个方案在并发量小的业务场景非常稳定也不需要考虑断线重连和心跳包的问题是实用主义的最优解。4. 前后端联调中的会话保持与接口字段对齐实战前后端联调是整个项目中最磨人的阶段绝大多数隐形 bug 都出现在这一环节。跨域问题、字段命名不统一、日期格式差异、空值判断缺失每一样都能让前后端工程师互相“甩锅”半天。4.1 跨域与代理配置开发环境下我用的 Vite 代理生产环境用 Nginx 转发这两个配置必须始终保持一致。Vite 配置在vite.config.jsexport default defineConfig({ server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } } });而生产环境 Nginx 配置的核心就一段转发location /api/ { proxy_pass http://127.0.0.1:3000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }在联调阶段我发现一个高频翻车点代理配置里changeOrigin和proxy_pass结尾的斜杠。/api/转发到http://127.0.0.1:3000/时请求路径/api/contract/list会变成/contract/list也就是后端路由不包含/api前缀。如果后端接口定义时包含了/api那转发路径也要带上/api否则 404。4.2 日期时间格式统一前后端联调时日期格式是个最容易碰出火花的细节。MySQL 的DATETIME存的是2024-03-15 14:30:00但前端图表组件有时候需要时间戳有时候需要YYYY-MM-DD。我统一的方案是后端返回所有日期时间字段统一返回YYYY-MM-DD HH:mm:ss字符串不做时区转换。前端展示直接用字符串展示需要比较时new Date(str).getTime()转时间戳。前端传参提交表单时一律YYYY-MM-DD格式的字符串。坚持“后端不处理时区前端不处理格式化”的原则整条链路会清爽很多。千万别让后端返回时间戳否则前端每次渲染都要dayjs(timestamp).format(YYYY-MM-DD)看着就累。4.3 字段命名对齐的工程化手段后端的命名习惯是user_name前端的习惯是userName如果不做统一规范联调时前端要把一半时间花在字段映射上。我给出的解决方案很简单却非常有效后端全部返回camelCase字段不在数据库层面做映射而是在 SQL 查询里使用别名处理。后端约定所有列表接口返回{ list, total }结构前端分页组件直接对接。所有 POST 请求体字段名和 GET 请求查询参数名保持完全一致。字段对齐还有一种做法是开放GET /schema/xxx接口返回字段描述但这套流程做起来太重。小团队只要约定好命名规范效率远高于引入各种代码生成工具。5. Node.js 环境安装与 npm 报错开发机上的真实排查经历开发这套系统时我也在环境配置上吃了不少苦头尤其是npm.ps1报错问题。这个报错在外网搜索量极高几乎每个 Windows 开发者都见过。它长这样npm.ps1 无法加载文件 D:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本5.1 为什么会出现这个报错这个报错的本质是 Windows PowerShell 的执行策略默认是Restricted禁止运行任何脚本文件而 npm 的命令行入口是一个.ps1脚本所以 PowerShell 直接拒绝运行。这个跟 Node.js 本身是否安装成功没有任何关系很多开发者误以为是 Node.js 安装失败。5.2 解决办法最简单的方式是改 PowerShell 的执行策略以管理员身份打开 PowerShell 执行Set-ExecutionPolicy -ExecutionPolicy RemoteSignedRemoteSigned的意思是本地脚本可以直接运行从互联网下载的脚本必须有数字签名才能运行。执行之后输入Y确认即可。如果不想改全局策略也可以临时用一个命令绕过powershell -ExecutionPolicy Bypass -Command npm install或者直接用npm.cmd替代npm因为.cmd不是.ps1不受 PowerShell 执行策略限制npm.cmd install我的建议是直接用Set-ExecutionPolicy一劳永逸因为后续还会遇到很多 npm 脚本的执行问题。5.3 Node.js 版本管理一个必须提前解决的问题开发过程中需要切换 Node.js 版本是常有的事。某些旧版本的项目需要 Node 14而这个家庭医生系统用的是 Vue 3 Vite至少要 Node 16 以上。我在开发机上装了nvm-windows它是 nvm 的 Windows 移植版安装完之后可以用一条命令切换全局 Node 版本nvm install 18.18.0 nvm use 18.18.0这里有一个很关键的坑nvm 安装的 Node.js 跟官方安装包的目录结构不一样npm 全局安装的包需要重新装一遍。切换版本后如果执行npm -v失败先检查环境变量里的NODE_PATH是否指向了正确的版本目录。所以说团队协作时建议统一 Node 版本记录在.nvmrc文件里避免“我这边跑得好好的”这类经典问题反复出现。6. 生产部署与运维的实操总结开发完成之后部署是一个完全独立的战场。前端打包、后端进程管理、数据库备份、日志处理任何一环出问题都能让你在深夜被电话叫醒。6.1 前端构建与部署Vue 项目打包要特别注意环境变量。我开发时维护了三个环境文件.env.development # VITE_API_URL/api .env.production # VITE_API_URLhttps://api.yourdomain.com/api打包命令很简单npm run build产物在dist/目录直接上传到服务器的 Nginx 站点目录然后重启 Nginx 即可。6.2 后端进程守护的选型与配置Node.js 服务端进程用 PM2 守护是最省心的选择。我之前也试过直接用nohup node app.js 来跑结果一旦进程因为未捕获异常退出整个人会被搞到崩溃。PM2 自带崩溃重启这个能力在凌晨三点是救命稻草。在项目根目录下载依赖后npm install pm2 start app.js --name family-doctor --max-memory-restart 512M开机自启配置Windows/Linux 都要做pm2 startup pm2 save--max-memory-restart 512M这个参数值得单独说一下Node 进程的内存占用要控制在合理范围超过了会自动重启能有效避免内存泄漏导致的雪崩。6.3 日志与数据库备份日志是排查线上问题的唯一线索。PM2 的日志默认写到~/.pm2/logs/建议配合按天切割避免单日志文件无限膨胀。数据库备份我用的是 cron 脚本每天凌晨自动备份0 2 * * * mysqldump -uroot -p*** family_doctor /backup/doctor_$(date \%Y\%m\%d).sql --single-transaction保留最近 7 天的备份其余清理0 3 * * * find /backup -name doctor_*.sql -type f -mtime 7 -exec rm {} \;这个系统虽然业务不算深但“家庭医生”场景下的数据敏感度非常高备份策略不能马虎。7. 实测踩坑记录那些不起眼但致命的小问题整个项目做下来我遇到的很多问题都不是那种“查一下文档就能解决”的而是藏在细节里不跑到真实场景根本发现不了。7.1 时间字段的初版设计缺陷第一版做随访计划日期时我直接用了DATE类型存next_task_date。后来发现一个问题计划生成的时候需要精确到小时但DATE类型存不下。这导致当月第一天生成的计划在当天晚上就插入了第二天的任务整个提醒逻辑全部错乱。改成DATETIME之后还要注意数据库的时区SET time_zone 08:00;如果在连接字符串里设置了timezone一定要跟服务器一致否则定时任务会全部错位。7.2 列表大数字段导致页面卡顿用户列表接口直接返回所有字段结果在健康档案很多的患者上页面渲染明显卡顿。解决方案很简单列表接口只返回 10 个核心字段id、姓名、性别、年龄、签约医生、签约状态等。详情接口再返回完整字段。大文本字段病史、过敏史、备注拖到详情页加载。一次性返回所有字段在数据量小的时候没问题但数据量上来之后接口响应时间和内存占用都会指数级上升。7.3 退出登录后路由守卫失效做退出登录时我最初只在 Pinia 里清空了 token然后直接router.push(/login)。后来发现一个 bug用户退出登录后如果在地址栏手动输入一个内部页面路径居然还能直接进入。原因是路由守卫只判断了userStore.token但router.push(/login)执行的时候Pinia 的 token 清空了路由守卫只判断 token 的时候发现没有 token 就放行去 login 了但用户已经在 login 页这是正常逻辑。问题是用户直接手动输入内部路径时如果在跳转过程中 token 还没清空就会被误放行。最后的解决方案是退出登录时同时调用一个后端接口让服务端把 token 加入黑名单并清空localStorageconst logout async () { await http.post(/auth/logout); localStorage.removeItem(token); userStore.$reset(); router.replace(/login); };路由守卫的判断逻辑也需要加强必须在setTimeout里等 token 清空完成后再跳转否则会出现竞态问题。这其实是一个很经典的前后端会话一致性难题。7.4 响应体里的 null 值导致前端报错前端在渲染健康档案时某个患者的“过敏史”字段为NULL结果页面渲染patient.profile.allergy_history.length直接抛错。后端的处理方案是所有可为空的字符串字段默认返回空字符串所有可为空的数组字段默认返回[]exports.normalizeRecord (row) { return { ...row, allergy_history: row.allergy_history || , tags: row.tags ? JSON.parse(row.tags) : [] }; };这个规范越早定越好等到联调阶段再来补 NULL 判断代码会被补丁搞到惨不忍睹。8. 从这套系统中沉淀的通用经验家庭医生管理系统只是众多后台管理系统中的一种但它的业务复杂性覆盖了账号体系、权限控制、长周期任务调度、实时交互、敏感数据处理等多个维度。做完这个项目我有几个体会特别深。第一权限控制必须在后端落地前端只是锦上添花。很多团队把权限写在路由守卫和菜单显隐上后端接口完全开放这是典型的自欺欺人。我在做这个项目时后端每个跟患者数据相关的接口都占用了authorize中间件做角色校验虽然代码量增加了但安全性有质的提升。第二前后端协作的规范比技术选型更重要。我们统一了返回结构、错误码、日期格式、字段命名联调阶段几乎没有“字段对不上”的问题整个联调过程非常顺畅。这些规范应该在项目启动的第一天就定下来而不是等到联调阶段再补。第三生产环境的部署方案要提前规划。开发阶段用npm run dev跑得飞起但部署时域名、HTTPS 证书、Nginx 配置、PM2 开机自启、数据库备份、日志切割每一项都要花时间。等到项目做完了再研究部署那几天基本是通宵加班。第四代码里要留够监控和日志。仪表盘统计功能不光是给客户看的也是给自己定位问题用的。我在健康档案模块加了一个简易操作日志表记录每次新增和修改出问题的时候能快速排查是谁在什么时候动了什么数据。这比事后翻 MySQL binlog 舒服太多了。第五不要把简单问题复杂化。在线咨询模块完全可以用 WebSocket但轮询 2 秒一次在这个业务场景下已经足够动态路由可以用更复杂的权限模型但按角色拼装路由表已经足够。技术选型要考虑团队维护成本和业务实际需求而不是追求技术上的炫技。所谓“没有最好的架构只有最合适的架构”在这类项目中体现得淋漓尽致。我目前还在持续优化这套系统下一步计划加入体检报告 OCR 自动识别归档以及基于历史慢病数据的趋势预测提醒。技术方案演进是无止境的但每解决一个真实问题这套系统的价值就增加一分。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑