做这样一个系统,最深的体会就是:一个在线问诊的完整链路,绕来绕去总跑不开"患者发起问诊、医生接诊回复、最后开出处方"这三件事。但真要把这三件事用代码落成一个能上线的东西,牵扯到的设计远比表面看到的要多。这篇文章就把我完成这套医院就诊管理系统的全过程——技术选型、后端建模、前端架构、核心业务流程、权限控制、部署上线——按实际开发的顺序捋一遍。项目最终配合的是Python Flask提供API,Vue做前端页面,中间走的是标准的前后端分离模式,适合正在做Web全栈课设、毕业设计,或者想从零搭一个中小型信息管理系统的同学参考。
1. 为什么是Flask+Vue:这套技术选型的真实考量
1.1 对比了Spring Boot、若依、Django之后的选择
开始动手之前,我在Spring Boot、Django和Flask之间纠结了挺久。很多人一提"医院管理系统"就默认要上Spring Boot,再加上若依这种后台脚手架,感觉这才是"正路"。但我仔细算了一笔账:这个系统的核心是问诊流程的管理,并发量不高,功能边界清晰,最需要的其实是开发速度和灵活度。
我列过一张对比表,选型时心里就清楚了:
| 方案 | 上手成本 | 部署重量 | 适合场景 |
|---|---|---|---|
| Spring Boot | 较高,Java生态配置多 | 需要JDK+Maven运行环境 | 大型企业级、高并发 |
| Django | 中等,自带后台可快速搭建 | 一套固定的MTV模式 | 内容站点、快速原型 |
| Flask | 低,路由和视图模型极简 | 轻量,一个Gunicorn就够 | 小型SaaS、内部系统、API服务 |
| 若依脚手架 | 高,代码量大,前后端分离但结构重 | 整体工程体量偏大 | 后台管理框架型项目 |
Flask的优势不是"功能强大",而是"恰到好处"。这个系统就三类角色:患者、医生、管理员。核心业务是问诊单的流转,不是复杂的权限矩阵,也不是海量数据的统计分析。Flask的路由装饰器、蓝图模块化、以及庞大的第三方扩展库,能让我花最少的时间把API写出来,把时间省给前端交互和问诊流程的打磨。
另外还有一个很现实的原因:Flask框架本身是纯Python实现,数据模型用SQLAlchemy来映射,对后来做数据分析和扩展都有好处。比如后期要加个简单的诊断统计,直接复用现有的ORM模型写脚本就行,不用额外接数据管道。
1.2 Vue放在这里的真正作用
我选了Vue来做前端,而且刻意没有用Flask的Jinja2模板渲染。原因不复杂:在线问诊的交互是典型的"多状态页面"——患者要能实时看到问诊单状态从"待接诊"变成"医生已接诊",医生那边要快速切换接诊队列,这是传统模板渲染很难做流畅的。
Vue的好处在于组件化和响应式。我把患者端的问诊详情页拆成了几个组件:病情描述卡片、聊天记录区域、处方展示卡片、就医指引卡片。状态一变,页面局部刷新,不用整个重新加载。Vue 3的组合式API(Composition API)写这类业务逻辑非常顺手,逻辑相关的东西放在一起,比Vue 2的选项式API清晰不少。
前后端分离的另一个隐性好处是,后端Flask只需要老老实实提供JSON接口,我前端页面怎么跳转、组件怎么复用,完全自己说了算。调试的时候开个浏览器DevTools直接看接口请求,问题定位起来特别快。
2. 后端骨架:Flask的模块化设计与数据库建模
2.1 项目目录结构设计
Flask项目最忌讳把代码全堆在app.py里,哪怕系统不大,我也从一开始就按模块拆好了。目录结构供参考:
flask_backend/ ├── app/ │ ├── __init__.py # 应用工厂,注册蓝图和扩展 │ ├── config.py # 配置项,包含数据库连接、SECRET_KEY │ ├── models/ │ │ ├── __init__.py │ │ ├── user.py # 用户、患者、医生模型 │ │ ├── department.py # 科室模型 │ │ ├── consultation.py # 问诊单模型 │ │ ├── prescription.py # 处方及明细模型 │ │ └── message.py # 聊天消息模型 │ ├── api/ │ │ ├── __init__.py │ │ ├── auth.py # 登录注册接口 │ │ ├── patient.py # 患者端接口 │ │ ├── doctor.py # 医生端接口 │ │ ├── admin.py # 管理端接口 │ │ └── common.py # 科室、公共数据接口 │ ├── utils/ │ │ ├── jwt_auth.py # JWT生成与校验 │ │ ├── decorators.py # 角色鉴权装饰器 │ │ └── response.py # 统一返回格式 │ └── extensions.py # db = SQLAlchemy()等扩展实例 ├── migrations/ # 数据库迁移文件 ├── requirements.txt └── run.py应用工厂模式是关键。extensions.py里单独存放db = SQLAlchemy()和ma = Marshmallow()这类扩展实例,然后在app/__init__.py里把它们绑定到应用上。这样写的好处是,需要跑单元测试时可以创建多个不同配置的应用实例,互不干扰。
2.2 数据模型的设计思路
数据库我用的MySQL,字符集统一utf8mb4。表结构设计是整个系统里最需要想清楚的部分,因为看病流程的状态流转,本质上就是数据表里几个字段的变化。核心表如下:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| users | 所有登录账号统一存放 | id, username, password_hash, role, real_name, phone |
| patients | 患者的扩展信息 | id, user_id, gender, birth_date, medical_history |
| doctors | 医生的扩展信息 | id, user_id, department_id, title, introduction, consultation_fee |
| departments | 科室信息 | id, name, description |
| consultations | 问诊单 | id, patient_id, doctor_id, status, symptom_desc, diagnosis, created_at |
| messages | 问诊聊天记录 | id, consultation_id, sender_id, content, msg_type, created_at |
| prescriptions | 处方主表 | id, consultation_id, doctor_id, patient_id, total_amount, note, created_at |
| prescription_items | 处方明细 | id, prescription_id, drug_name, spec, unit_price, quantity, dosage |
这里有个设计点我想单独说说:为什么不单独建患者表,而是搞了个users表加扩展表?因为不管是患者还是医生,登录认证的逻辑是一样的——账号、密码、角色。把公共字段放在一张表里,鉴权逻辑写一次就够。扩展信息单独拆出去,又不会让users表字段太多。实际开发时,一张user表配一个role字段,比患者表、医生表分开建要省很多重复代码。
问诊单状态字段status我设计成了整数枚举:0待接诊、1已接诊(问诊中)、2已完成、3已取消。为什么不直接用字符串?因为前端做状态判断时,字符串比较容易出错,比如写成"正在接诊"和"接诊中",数据不一致前端就看不到正确状态了。数据库里只存数字,含义在代码里定义成常量,两端都按这份约定来。
2.3 Flask API路由怎么组织
API层用蓝图划分,每个文件只管自己那类接口。比如doctor.py里是用Blueprint('doctor', __name__)来创建的,注册路由时统一加前缀:
doctor_bp = Blueprint('doctor', __name__, url_prefix='/api/doctor') @doctor_bp.route('/consultations/pending', methods=['GET']) @jwt_required @role_required('doctor') def pending_consultations(): doctor_id = current_user.doctor_profile.id consultations = Consultation.query.filter_by( doctor_id=doctor_id, status=0 ).order_by(Consultation.created_at.asc()).all() return success_response([c.to_dict() for c in consultations])所有接口统一走success_response()或error_response(),前端axios拦截器拿到数据后直接解包,不用每次都判断HTTP状态码和业务状态码的对应关系。这种统一格式写起来可能感觉多此一举,但当前端有几十个页面都要调接口时,统一封装的好处马上就出来了。
3. 前端Vue侧的页面架构:路由、状态管理与问诊流程交互
3.1 前端工程结构和路由规划
前端用的Vue 3加Vue Router 4,配合Pinia做状态管理。目录结构也不复杂,但路由规划是花了不少心思的。这个系统前端分四个大区域:访客区、患者区、医生区、管理区。路由设计如下:
| 路由路径 | 页面 | 访问角色 |
|---|---|---|
| /login、/register | 登录、注册 | 所有人 |
| / | 首页,展示科室、医院信息 | 所有登录用户 |
| /patient/consult | 发起问诊(选科室选医生) | 患者 |
| /patient/consultations | 我的问诊列表 | 患者 |
| /patient/consultations/:id | 问诊详情+聊天室 | 患者、医生 |
| /doctor/workbench | 医生接诊工作台 | 医生 |
| /doctor/prescription/:cId | 开处方页面 | 医生 |
| /admin/departments | 科室管理 | 管理员 |
| /admin/doctors | 医生审核与管理 | 管理员 |
最先写的是登录,登录后根据角色跳转到对应的工作台。这个跳转逻辑我放在路由守卫里,结合Pinia里保存的userInfo判断。组件层面,views目录放页面,components目录放复用组件。问诊聊天室是一个单独组件,患者和医生端共用,只通过role属性控制显示哪些按钮。比如患者端显示"等待医生接诊",医生端显示"接诊"按钮,聊天记录组件本身是同一套。
3.2 axios封装与登录态管理
axios封装是这个前端项目的关键。我在src/utils/request.js里做了一个带请求拦截器和响应拦截器的实例:
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('med_token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('med_token') router.push('/login') } ElMessage.error(error.message || '网络错误') return Promise.reject(error) } ) export default request这里有个细节:baseURL配的是/api而不是完整地址,是因为生产环境前后端共用同一个域名,靠Nginx把/api转发给Flask服务,这就避免了写死IP端口带来的跨域问题。开发环境再通过Vite的proxy把/api代理到Flask的8000端口,一套代码开发生产都跑得通,非常省心。
登录态我选了localStorage存token,而不是sessionStorage。原因是刷新页面时token不能丢,否则患者刷新一下聊天页就要重新登录,体验很糟。Pinia里则用userInfo对象记录用户名、角色、显示昵称,这些信息在页面导航上要频繁使用。
3.3 患者端和医生端的页面交互拆解
患者端的核心交互流程是:首页点"在线问诊"→选择科室→查看医生列表→选择医生→填写病情描述→提交订单。提交完成后,患者进入"我的问诊",看到这个问诊单的状态时点是"待接诊"。点击进入详情,就是和医生对话的聊天室。
医生端则完全不一样。医生登录后直接进接诊工作台,页面分成两部分:左边是待接诊列表,右边是当前正在处理的问诊会话。点"接诊"后,问诊单状态从0变1,患者那边同步看到状态变化,聊天室里医生端的输入框也解锁。
这两个页面的交互模式不同,但底层依赖的是同一个问诊单状态字段。前端之所以能做得这么顺畅,靠的就是Vue的响应式——医生接诊触发的接口返回后,前端把返回的status值更新到Pinia里,相关组件自动刷新。
4. 在线问诊的核心链路:从发起问诊到医生接诊再到处方查看
4.1 问诊单的创建与状态流转
在线问诊最核心的业务逻辑是问诊单的状态流转。从代码角度说,就是一套状态机的实现。我把状态定义放在后端模型上:
class Consultation(db.Model): __tablename__ = 'consultations' STATUS_PENDING = 0 # 待接诊 STATUS_ACCEPTED = 1 # 已接诊,问诊中 STATUS_FINISHED = 2 # 已完成 STATUS_CANCELLED = 3 # 已取消 id = db.Column(db.Integer, primary_key=True) patient_id = db.Column(db.Integer, db.ForeignKey('patients.id'), nullable=False) doctor_id = db.Column(db.Integer, db.ForeignKey('doctors.id'), nullable=False) status = db.Column(db.Integer, default=STATUS_PENDING, nullable=False, index=True) symptom_desc = db.Column(db.Text, nullable=False) # 病情描述 diagnosis = db.Column(db.Text) # 医生诊断结果 created_at = db.Column(db.DateTime, default=datetime.utcnow) accepted_at = db.Column(db.DateTime) finished_at = db.Column(db.DateTime)创建问诊单时,患者在前端表单填病情描述,提交后后端做几件事:确认患者存在、确认医生存在且属于所选科室、然后创建一条status=0的记录。这里特别值得注意的一点是创建后要返回完整的问诊单信息,包括model里的to_dict(),前端拿到后就跳转问诊详情页。
状态的合法流转是这样的:
| 状态名 | 数值 | 触发动作 | 什么角色能触发 |
|---|---|---|---|
| 待接诊 | 0 | 患者创建问诊单 | 患者 |
| 已接诊 | 1 | 医生点击接诊 | 医生 |
| 已完成 | 2 | 医生提交诊断和处方 | 医生 |
| 已取消 | 3 | 患者取消或管理员关闭 | 患者/管理员 |
我在接诊和完成两个接口里都加了状态校验,不是当前状态值就直接返回错误。像这样在接口层做一次判断,虽然看着啰嗦,但能防止前端误操作或者接口被直接调用时出现状态跳变。比如患者正在等,医生已经提交了诊断,这时候患者再点取消,接口判断当前状态不是0了,返回"当前状态不允许取消",就不会出现已完成的问诊单还能被撤销的问题。
4.2 医生端接诊的并发问题
接诊这个动作看起来简单——改状态、绑定医生——但实际做的时候我踩过并发问题的坑。设想这个场景:两位医生同时在待接诊列表里看到同一张问诊单,几乎同时点了"接诊"。如果用简单的查询+更新逻辑,两人都可能通过校验,都修改了这条记录的doctor_id,造成数据错乱。
解决方案我用的MySQL的UPDATE ... WHERE status=0条件更新。不是先查后改,而是直接执行带条件限制的更新语句,通过受影响行数判断是否成功:
result = db.session.execute( db.update(Consultation) .where(Consultation.id == consultation_id, Consultation.status == 0) .values(status=1, doctor_id=doctor_id, accepted_at=datetime.now()) ) db.session.commit() if result.rowcount == 0: return error_response('该问诊单已被其他医生接诊')这种写法利用数据库的行锁来保证并发下只有一个医生能更新成功。受影响行数为0,说明当前状态已经不是0——已经被抢了。这种"不加锁却防了并发"的写法,对这种中小型系统来说是最简洁可靠的方案。
4.3 聊天消息的设计方案
问诊过程中患者和医生要交流病情,所以需要消息功能。最初我考虑过WebSocket方案,用Flask-SocketIO,页面收到消息实时推送。但仔细评估后,这个项目的聊天频率很低——一次问诊来回可能也就十几条消息,对实时性要求并不高。最后选了轮询方案:前端每2秒请求一次该问诊单的消息列表,用消息id做增量拉取。如果系统规模大或者要做语音视频问诊,再升级到WebSocket不迟。
消息表结构很简单:
class Message(db.Model): __tablename__ = 'messages' id = db.Column(db.Integer, primary_key=True) consultation_id = db.Column(db.Integer, db.ForeignKey('consultations.id'), nullable=False, index=True) sender_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) content = db.Column(db.Text, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow)增量拉取的接口逻辑是:前端把当前已加载的最大消息id传过来,后端只返回比这个id大的消息。这种设计比整表拉取高效,也避免聊天室滚动时把全部历史都重新渲染一遍。
4.4 处方生成与患者查看
医生在问诊单处于"已接诊"状态时,可以填诊断结论,并开具处方。处方是一主多明细的结构,主表记录归属关系,明细表记录药品名、规格、价格、数量、用法用量。一次提交处方,后端是在一个事务里同时插入prescriptions和prescription_items的:
@doctor_bp.route('/consultations/<int:consultation_id>/prescription', methods=['POST']) @jwt_required @role_required('doctor') def create_prescription(consultation_id): data = request.get_json() items = data.get('items', []) if not items: return error_response('处方明细不能为空') consultation = Consultation.query.get(consultation_id) if not consultation or consultation.status != 1: return error_response('当前问诊状态无法开处方') total_amount = sum(item['unit_price'] * item['quantity'] for item in items) prescription = Prescription( consultation_id=consultation_id, doctor_id=consultation.doctor_id, patient_id=consultation.patient_id, total_amount=total_amount, note=data.get('note', '') ) db.session.add(prescription) for item in items: db.session.add(PrescriptionItem( prescription=prescription, drug_name=item['drug_name'], spec=item.get('spec', ''), unit_price=item['unit_price'], quantity=item['quantity'], dosage=item.get('dosage', '') )) consultation.status = 2 consultation.diagnosis = data.get('diagnosis', '') consultation.finished_at = datetime.now() db.session.commit() return success_response({'prescription_id': prescription.id})把"开处方"和"完成问诊"放在同一个事务里是刻意的:医生只要提交了诊断和处方,这次问诊就必须同时完成,不能出现处方开了但状态还停在已接诊的悬空状态。
前端展示处方时,患者端详情页会多出一个处方卡片,按药品明细列表展示,每项有单价和数量,底部汇总总价。因为涉及金额展示,我在前端做了BigDecimal风格的金额处理思路——后端返回的是整数分,前端自己除以100转为元再显示。这样避免浮点数计算误差,也符合支付系统里"以分为单位"的惯例。这个小细节是在对接模拟缴费时想明白的,建议做任何涉及金钱的系统都默认采用。
5. 权限与安全:患者、医生、管理员三种角色的权限控制
5.1 JWT认证与登录流程
登录接口接收用户名和密码,校验通过后签发JWT。签发时把用户id和role放进去,过期时间设为7天。接口端通过装饰器解析token,拿到当前用户信息。
def generate_token(user): payload = { 'user_id': user.id, 'role': user.role, 'exp': datetime.utcnow() + timedelta(days=7) } return jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')jwt_required装饰器做token的解析和校验,具体实现是取出Authorization请求头,去掉Bearer前缀,校验签名,然后把解析出的用户对象挂到current_user这个全局对象上。Flask里用g对象来存当前请求全局数据,装饰器里给g.user赋值,接口里直接读,这样很方便。不过要注意:Flask的g对象是跟随请求上下文存在的,多个并发请求不会互相串数据,这个可以放心用。
5.2 后端接口的角色鉴权
只有登录校验还不够,得防住"患者调医生接口"这类越权行为。Role-based的鉴权我直接做成装饰器,和jwt_required配合用:
def role_required(*roles): def decorator(f): @wraps(f) def wrapper(*args, **kwargs): user = getattr(g, 'user', None) if not user: return error_response('未登录', 401) if user.role not in roles: return error_response('无权限访问', 403) return f(*args, **kwargs) return wrapper return decorator实际接口上就是这样用:
@doctor_bp.route('/workbench', methods=['GET']) @jwt_required @role_required('doctor') def workbench(): ...这种装饰器权限控制的思路,适合角色固定、数量少的系统。遇到特别复杂、动态的权限模型,建议还是用现成的权限框架,但在这个系统里,三行装饰器的方案远比引入一堆权限框架来得好维护。
5.3 前端路由守卫
后端做了鉴权,前端还需要做路由守卫来拦掉"未登录直接输URL跳页面"的情况。Vue Router的beforeEach全局守卫,逻辑不复杂,但有两层判断。第一层,白名单外的页面要求必须有token;第二层,登录后每个页面的路由的meta.roles数组,判断当前用户角色是否匹配,不匹配就跳回自己的工作台:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('med_token') const userInfo = JSON.parse(localStorage.getItem('med_user_info') || '{}') if (!token && to.path !== '/login' && to.path !== '/register') { next('/login') return } if (token && to.meta.roles) { if (!to.meta.roles.includes(userInfo.role)) { next(userInfo.role === 'doctor' ? '/doctor/workbench' : '/') return } } next() })前端路由守卫只能优化体验,真正的安全防线在后端。这一点做前端时必须想清楚,不然一不小心就会做出"隐藏按钮但接口裸奔"的系统。
5.4 医疗数据安全处理的几个习惯
涉及问诊聊天记录、病情描述这类敏感数据,我做了几件额外的事:数据库里密码存的是generate_password_hash后的值,绝对不存明文;敏感接口的响应里手动过滤字段,不用to_dict()一股脑把整条record返回;前端展示聊天记录时分页加载,不把整个问诊的所有历史消息一次性拉下来。这些措施虽然不能说做到了等保级别,但对一个标准的管理信息系统来说,已经把能想到的风险点都覆盖了。
6. 部署上线:从开发环境到生产环境的折腾记录
6.1 跨域问题怎么处理的
开发阶段的跨域是最先遇到的坎。前端跑在Vite默认的5173端口,后端Flask监听在8000端口,直接请求必然被浏览器的同源策略拦掉。开发环境的方案是在Vite配置里做代理:
// vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8000', changeOrigin: true } } } })这样前端页面里请求/api/xxx,Vite开发服务器会转发给后端的8000端口,而且浏览器里看到的还是同源,就没有跨域问题了。进入生产环境后,前后端部署在同一个域名的不同路径下,Nginx转发,根本不会触发跨域限制。
6.2 Flask后端在生产环境怎么跑
Flask内置的开发服务器明确不能用于生产环境,我用了Gunicorn来启动:
gunicorn -w 4 -b 127.0.0.1:8000 run:app-w 4是开了4个worker进程。这个数字不是越大越好,要根据服务器CPU核数来定,经验公式是2*CPU核数+1。再配合supervisor做进程守护,进程挂了自动拉起,不然服务器一重启,后端就再也不会自己跑起来了。
迁移数据库用的Flask-Migrate。表结构改了之后,执行flask db migrate和flask db upgrade推送变更。这个流程比手改SQL安全多了,特别是表结构迭代了好几次之后,历史变更记录一目了然。
6.3 Nginx反向代理与前端history模式
生产环境的Nginx配置文件是这个项目的关键一环。前端打包后如果是history路由模式,直接部署会碰到404问题——因为刷新页面时Nginx去找/doctor/workbench对应的物理文件,找不到就404了。解决办法是配置try_files指向index.html:
server { listen 80; server_name your_domain.com; root /var/www/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /static/ { alias /var/www/html/static/; expires 30d; } }这里location /api/把API请求单向转发给Gunicorn,前端静态文件由Nginx直接服务。页面秒开,接口响应也稳定,整体架构非常干净。配套的,前端路由不能用createWebHashHistory,要用createWebHistory,不然URL里到处都是#,既不好看也不方便分享。
6.4 上线后遇到的几个实际问题
上线后最先暴露的是时区问题。Flask的datetime.utcnow()存进MySQL后,前端展示的时候和本地时间差了8小时。解决方式是统一在后端返回时间戳(epoch毫秒数),前端格式化时用本地时区转换。这个方案跨时区无坑,我从那之后所有项目的时间接口都返回时间戳。
然后是上传图片的需求。如果患者或者医生要上传检查报告图片,Flask需要配置一个上传目录并用send_from_directory提供访问。生产环境里更好用的方案是用MinIO或者云存储,但小项目不想引外部依赖的话,本地存储加Nginx静态映射也能跑。要注意的是上传目录的权限和文件名防注入,文件后缀白名单校验不能省。
还有个性能细节:问诊列表页需要关联查询科室名、医生名、状态。最开始直接遍历每个问诊单再去查关联表,一次性加载N条就产生N+1次SQL查询,数据库压力翻倍。改成join查询或SQLAlchemy的joinedload之后,列表页秒开。这个优化虽然简单,但在编码初期就注意,能省掉后期不少重构。
上线后做了一轮完整的流程验证,从患者注册到问诊结束,把每个接口和页面都过了一遍。整体跑通后终于松了口气。做这类系统的经验总结下来就是:选型稳、建模细、流程少、权限不偷懒。Flask加Vue的组合,在中小型信息管理系统这个体量上,确实是开发效率和后期维护成本之间最平衡的选择。
最后再分享一个小经验:这种带角色流转的系统,开发时一定先把"状态机"想清楚。哪个角色在哪个状态下能做什么操作,在代码里用常量定义出来,前后端约定好。状态一旦混乱,排查成本远远大于提前设计的成本。做这个在线问诊系统,我最庆幸的就是一开始把问诊单的状态定义清楚了,后面所有功能都是在这个状态机上生长的结果。