第 16 章 综合实战二:优医问诊 App(Flutter + Spring Boot 全栈)
第 15 章的记账本只有「一进一出」,业务偏简单。这一章我们换一个真实的中大型业务:医疗在线问诊。完整实现「登录 → 找医生 → 下单 → 支付 → 问诊室即时通讯 → 处方 → 购药 → 物流」的每一条用户旅程——不是挑几个页面演示,而是每条流程都能在这里走完。
project/consult-backend 与 project/consult_app。源码位置:已上传 Gitee 开源仓库 gitee.com/linghurongjun/flutter-fullstack-projects(
consult-backend/ 为后端、consult_app/ 为客户端,与本章代码一一对应,可直接 clone 运行;第 15 章记账本对应 ledger-backend/ 与 ledger_app/)。验证方式:后端 49 项冒烟测试全部通过(含 401 未登录 / 404 越权 / 409 状态冲突分支);客户端
flutter analyze 零问题、Web release 构建成功;并用真实浏览器自动化把「登录 → 首页 → 找医生 → 下单 → 支付 → 问诊室 → 处方 → 购药 → 物流」完整跑通,本章 19 张截图全部来自这次真实运行。
16.1 完整跑一遍:19 张真实截图看全流程
下面所有截图都来自 Playwright 驱动 Chrome 运行 flutter build web 产物、连接真实后端的实际运行结果(演示账号 demo / demo1234)。
① 账号与首页



② 健康百科 · 消息通知 · 就诊人(健康档案)




③ 找医生与下单





④ 问诊室与问诊订单



⑤ 处方开药 · 药品订单 · 物流




simulated=true,客户端气泡上标注「演示回复」,不伪装成真人;② 未接入真实支付渠道与地图,收银台仅修改订单状态,页面明确标注「演示支付」,不会产生任何扣款;
③ 问诊室消息采用 HTTP 增量轮询(每 2 秒带
afterId 拉取),部署无额外依赖,接口契约与 WebSocket 版本可以互换。
16.2 页面地图:21 条路由
登录 /login ──▶ 注册 /register
│
▼
主框架 StatefulShellRoute(底部 4 Tab,IndexedStack 保留各 Tab 状态)
├── Tab1 首页 /home ─┬─▶ 医生列表 /doctors?depId= ──▶ 医生详情 /doctor/:id
│ ├─▶ 极速问诊 /consult/fast ──▶ 选择就诊人
│ └─▶ 百科横滑卡 ──▶ 百科列表 /article ──▶ /article/:id
├── Tab2 健康百科 /article
├── Tab3 消息通知 /notify(未读角标)
└── Tab4 我的 /user ─┬─▶ 就诊人 /patient(管理 / 选择两种模式)
├─▶ 问诊订单 /consult/orders ──▶ 问诊详情 /consult/order/:id
├─▶ 问诊室 /room/:orderId
└─▶ 药品订单 /orders ──▶ /order/detail/:id ──▶ /order/logistics/:id
独立问诊流程:/consult/dep(选科室)──▶ /consult/illness(填病情)──▶ /consult/pay(预支付)
处方购药: /order/pay/:prescriptionId ──▶ 支付 ──▶ /orders(待发货/待收货)──▶ 物流
问诊下单是跨页面收集信息的典型场景:科室 → 病情 → 预支付三步的数据用 Riverpod 的 consultDraftProvider(StateNotifier 模式)集中保存,缺失必填项时 missingTip 直接给出可展示的中文提示,而不是在各页零散判断。
16.3 后端:9 组 43 个接口与两台状态机
后端 Spring Boot 3.5 + Java 17 + JPA + H2 文件库 + JWT + springdoc + Bean Validation,跑在 8081 端口(与第 15 章的 8080 并存)。所有接口除登录注册外都要求 Authorization: Bearer <token>,完整字段级说明见 project/docs/API-consult.md,或启动后打开 Swagger:http://localhost:8081/swagger-ui.html。
- 基础地址:
https://aichilizi.com/api(与手册同域,无需启动本地后端即可直接调) - 健康检查:https://aichilizi.com/api/health
- 接口文档:https://aichilizi.com/swagger-ui/index.html
- 拼法:下表路径均省略了基础地址,例如
POST /api/auth/login→https://aichilizi.com/api/auth/login
| 分组 | 接口 | 说明 |
|---|---|---|
| 认证(6) | POST /api/auth/register、POST /api/auth/login、POST /api/auth/code(发验证码)、POST /api/auth/login-code、GET /api/auth/me、PUT /api/auth/profile | 支持用户名或手机号登录;密码只存 BCrypt;验证码登录演示模式下校验固定码 |
| 科室 / 医生(5) | GET /api/departments(两级树)、GET /api/doctors(科室+关键词+分页)、GET /api/doctors/{id}、POST /api/doctors/{id}/follow、GET /api/doctors/following | 关注是幂等切换,返回最新关注状态 |
| 健康百科(3) | GET /api/knowledge(type:推荐/减脂/饮食)、GET /api/knowledge/{id}、POST /api/knowledge/{id}/collect | 详情带富文本 content,阅读量自增 |
| 就诊人(5) | GET /api/patients、GET /api/patients/{id}、POST /api/patients、PUT /api/patients/{id}、DELETE /api/patients/{id} | 身份证格式后端校验;设默认就诊人自动清除他人默认标记 |
| 问诊(11) | GET /api/consult/pre(预支付试算)、POST /api/consult/orders、GET /api/consult/orders(type/status 筛选)、GET /api/consult/orders/{id}、POST .../pay、POST .../cancel、DELETE .../{id}、POST .../finish(结束并开处方)、POST .../evaluate、GET .../messages、POST .../messages | 金额只在后端算;status 下推到 SQL 过滤;每单只能评价一次(409) |
| 处方 / 药品(8) | GET /api/prescriptions/{id}、GET /api/medicine/pre(下单预览)、GET /api/addresses、POST /api/medicine/orders、GET /api/medicine/orders(status 筛选)、GET /api/medicine/orders/{id}、POST .../pay、GET .../logistics | 处方来自问诊结束(finish)自动开具;药品订单状态机独立于问诊 |
| 消息通知(3) | GET /api/notices、GET /api/notices/unread、POST /api/notices/read | 未读数驱动底部 Tab 角标 |
| 文件上传(1) | POST /api/upload(multipart,≤5MB) | 病情图片、问诊室图片消息共用;GET /files/** 静态放行 |
| 健康检查(1) | GET /api/health | 客户端探测服务器是否可达 |
16.3.1 两台订单状态机
医疗问诊里「按钮能不能点」全部由状态机推导。状态编码写成枚举,比散落在文档里更可靠:
// domain/ConsultStatus.java —— 问诊订单状态机(编码 1~5)
// 下单 ─▶ 1 待支付 ─支付─▶ 2 待接诊 ─患者首条消息触发接诊─▶ 3 问诊中 ─患者点结束─▶ 4 问诊完成(同时开处方)
// │ │
// └────取消────▶ 5 已取消 ◀──┘(仅待支付可取消)
public enum ConsultStatus {
PENDING_PAY(1, "待支付"), WAITING(2, "待接诊"), IN_PROGRESS(3, "问诊中"),
FINISHED(4, "问诊完成"), CANCELED(5, "已取消");
// ...
}
// domain/MedicineOrder.java —— 药品订单状态机(编码 10~14)
public enum Status {
PAY(10, "待支付"), SEND(11, "待发货"), TAKE(12, "待收货"),
COMPLETE(13, "已完成"), CANCEL(14, "已取消");
// ...
}
// service/ConsultService.java —— 每个动作先校验前置状态,非法转移一律 409
@Transactional
public Views.ConsultOrderView pay(Long id) {
ConsultOrder order = findOwned(id, currentUser.id());
if (order.getStatus() != ConsultStatus.PENDING_PAY) {
throw new ApiException(HttpStatus.CONFLICT, "仅待支付订单可以支付");
}
order.pay(); // 状态 → WAITING,记录支付时间
return toView(order, currentUser.id());
}
// 患者进问诊室拉消息时,若订单还在「待接诊」就自动接诊:
// 状态 2 → 3,并插入医生问候语 + 「医生已接诊」系统提示
public List<Views.MessageView> messages(Long id, Long afterId) {
ConsultOrder order = findOwned(id, currentUser.id());
if (order.getStatus() == ConsultStatus.WAITING) {
order.start(); // 状态机迁移
saveSystemMessage(order, "医生已接诊,本次问诊 24 小时内有效");
saveDoctorGreeting(order);
}
return messageRepository.findByOrderIdAndIdGreaterThanOrderByIdAsc(id, afterId == null ? 0 : afterId)
.stream().map(this::toMessageView).toList();
}
- 费用只在后端算:
预支付 /consult/pre与下单都用服务端价目(问诊类型 1 找医生 / 2 极速 / 3 开药),客户端只传选择,不传价格;下单时锁定金额,医生后续调价不影响历史单。 simulated=true是给客户端的诚实标记,不是装饰字段——界面上必须显示「演示回复」。- 所有订单查询统一带
userId条件(findByIdAndUserId):拿到别人的订单号也读不到,从根上防越权(冒烟测试里有专门的 404 用例)。 - 问诊订单列表的
status过滤必须下推到 SQL:先分页再在内存里筛,会导致 total 与 items 对不上(这是本次重构时真实踩过并修掉的坑)。
16.3.2 延迟自动回复:让演示流程闭环
// 患者消息立即落库返回;回复由单线程调度器延迟 1.2 秒写入,客户端轮询自然"看到"它
public Views.MessageView sendMessage(Long orderId, Requests.MessageReq req) {
ConsultOrder order = findOwned(orderId, currentUser.id());
if (order.getStatus() != ConsultStatus.IN_PROGRESS) {
throw new ApiException(HttpStatus.CONFLICT, "问诊已结束,无法继续发送消息");
}
ChatMessage saved = chatMessageRepository.save(new ChatMessage(
order.getId(), currentUser.id(), SenderType.PATIENT, req.msgType(), req.content()));
scheduler.schedule(() -> safeReply(order.getId(), order.getDoctorId(), req.content()),
autoReplyDelayMs, TimeUnit.MILLISECONDS);
return toMessageView(saved);
}
回复内容按关键词匹配(发热 / 咳嗽 / 头痛 / 腹痛 / 皮疹 / 失眠),命中给通用家庭处理建议与「什么情况必须线下就诊」的红线,未命中则追问三点补充信息。没有任何诊断结论,所有回复都以「不构成医疗建议、请遵医嘱」收尾。
16.4 客户端关键实现
16.4.1 Dio 拦截器:token、错误、401 一次搞定
// lib/core/api_client.dart(节选,完整实现 90 行)
final dioProvider = Provider<Dio>((ref) {
final baseUrl = ref.watch(serverUrlProvider); // 「我的」页改地址后自动重建
final dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 8),
receiveTimeout: const Duration(seconds: 15),
contentType: 'application/json; charset=utf-8',
));
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
final token = ref.read(sessionProvider).token; // read:不订阅,避免每次 token 变化都重建
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
onError: (e, handler) {
final path = e.requestOptions.path;
// 登录接口的 401 是「密码错误」,不能当成登录过期
final isAuthPath = path.startsWith('/auth/');
if (e.response?.statusCode == 401 && !isAuthPath) {
ref.read(sessionProvider.notifier).clear(); // 清 token → 路由自动跳登录页
}
// 把后端的 {message, errors} 统一转成 ApiFailure(含字段级校验错误)
final data = e.response?.data;
if (data is Map) {
final errors = <String, String>{};
final rawErrors = data['errors'];
if (rawErrors is Map) {
rawErrors.forEach((k, v) => errors['$k'] = '$v');
}
handler.next(DioException(
requestOptions: e.requestOptions,
response: e.response,
error: ApiFailure(data['message'] as String? ?? '请求失败',
statusCode: e.response?.statusCode, fieldErrors: errors),
));
return;
}
handler.next(e);
},
));
ref.onDispose(dio.close);
return dio;
});
ApiFailure.fieldErrors 直接回填到表单:比如病情描述少于 5 个字,后端 Bean Validation 返回 {"illnessDesc": "病情描述 5~500 字"},输入框下方就显示这句话——校验规则只写一处(后端),前端不用同步维护。
16.4.2 增量轮询:每 2 秒只拉新消息
// lib/features/room/room_page.dart(节选)
Timer? _timer;
int _afterId = 0; // 已拉到的最大消息 id
@override
void initState() {
super.initState();
_bootstrap(); // 首次:拉订单 + 全量消息
_timer = Timer.periodic(const Duration(seconds: 2), (_) => _pullMessages());
}
Future<void> _pullMessages() async {
if (!mounted) return;
try {
final list = await ref.read(apiProvider)
.messages(widget.orderId, afterId: _afterId); // 只要 id > _afterId 的
if (!mounted || list.isEmpty) return;
setState(() {
_messages.addAll(list);
_afterId = list.last.id; // 指针前移
});
_scrollToBottom();
} on ApiFailure {
// 轮询失败静默重试,下一次 2 秒后再来,不打扰用户
}
}
afterId让每次轮询只传新消息,10 条历史对话也不会重复下载——这是轮询方案能用的前提。- 发送消息用返回的实体直接上屏(乐观更新),不等轮询,手感才不卡;发送成功后额外延迟 1.4 秒再拉一次,让「演示回复」尽快出现。
- 轮询在
dispose()里必须_timer?.cancel(),否则退出页面后仍在发请求。
16.4.3 Riverpod 分层:这个项目里 20 个 Provider 各司其职
// lib/data/providers.dart(节选,完整 20 个)
// 只读、进入页面即请求、自动缓存 → FutureProvider(family 版本带参数)
final doctorsProvider = FutureProvider.family<PageResult<DoctorInfo>, DoctorQuery>(
(ref, query) => ref.watch(apiProvider).doctors(query, size: 20),
);
final prescriptionProvider = FutureProvider.family<PrescriptionData, int>(
(ref, id) => ref.watch(apiProvider).prescription(id),
);
final unreadCountProvider = FutureProvider<int>(
(ref) => ref.watch(apiProvider).unreadCount(), // 驱动底部 Tab 角标
);
// 跨页面共享、会被用户操作改变 → Notifier(问诊下单草稿:科室/病情/就诊人三步收集)
class ConsultDraft extends Notifier<ConsultDraftData> {
@override
ConsultDraftData build() => const ConsultDraftData();
void setDep(int depId) => state = state.copyWith(depId: depId);
void setIllness({...}) => state = state.copyWith(...);
void reset() => state = const ConsultDraftData(); // 下单成功后清空
}
final consultDraftProvider =
NotifierProvider<ConsultDraft, ConsultDraftData>(ConsultDraft.new);
- FutureProvider:详情类页面(医生详情 / 处方 / 物流)——纯读取,进入即请求,Riverpod 自动缓存与去重;刷新用
ref.invalidate(provider)。 - Notifier:问诊草稿、登录态这类「会被写」的状态;动作完成后对受影响的 FutureProvider 调
invalidate,列表自然回到最新。 - 医生列表反而用 StatefulWidget 的
page / hasMore / loading三变量写分页更直观——同一个项目里多种写法并存是正常的,别为了统一而统一。
16.4.4 一个状态机,多种按钮组合
// 问诊订单卡片:按钮组合完全由订单状态推导,不做「能不能点」的猜测
if (order.isPendingPay) ...[
OutlinedButton(onPressed: _cancel, child: const Text('取消订单')),
FilledButton(onPressed: () => context.push('/room/${order.id}'),
child: const Text('去支付')),
],
if (order.isWaiting || order.isInProgress)
FilledButton(onPressed: () => context.push('/room/${order.id}'),
child: const Text('进入问诊室')),
if (order.isFinished) ...[
if (order.hasPrescription)
OutlinedButton(onPressed: () => context.push('/order/pay/${order.prescriptionId}'),
child: const Text('查看处方')),
OutlinedButton(onPressed: () => context.push('/consult/order/${order.id}'),
child: const Text('查看详情')),
],
后端的状态校验是最后一道防线:即使客户端被改了包,重复支付、已支付再取消、问诊结束后发消息、重复评价,都会得到 409 + 明确提示(49 项冒烟测试里逐条验证过)。
16.4.5 图片上传:image_picker + MultipartFile.fromBytes
// lib/data/api.dart —— Web 与移动端统一走 bytes,不碰平台文件路径
Future<String> uploadImage(Uint8List bytes, String filename) async {
final form = FormData.fromMap({
'file': MultipartFile.fromBytes(bytes, filename: filename),
});
final res = await _dio.post('/upload', data: form);
final url = (res.data as Map)['url'] as String;
// 后端返回相对路径 /files/xxx.png,这里补全成绝对地址
return url.startsWith('http') ? url : '$_baseUrl$url';
}
16.5 与第 15 章项目的分工
| 维度 | 第 15 章 记账本 | 第 16 章 优医问诊 |
|---|---|---|
| 业务复杂度 | 单表 CRUD + 聚合统计 | 多实体关联 + 两台状态机 + 会话消息 + 处方/物流链路 |
| 页面规模 | 约 6 个页面 | 21 条路由,18+ 个页面 |
| 状态管理 | AsyncNotifier 为主 | FutureProvider.family ×15 + Notifier 草稿/登录态 |
| 表单校验 | 客户端自校验 | Bean Validation 字段级错误回填(规则只在后端) |
| 离线能力 | sqflite 缓存兜底 | 不做离线(医疗数据以服务端为准) |
| 实时性 | 无 | 2 秒增量轮询 |
| 接口数量 | 约 10 个 | 43 个(9 组) |
| 端口 | 8080 | 8081(可与第 15 章同时运行) |
16.6 启动步骤
- Flutter 学习手册:https://aichilizi.com/
- Web 版 App 在线体验:https://aichilizi.com/app/
- 在线接口基础地址:
https://aichilizi.com/api(本地开发为http://localhost:8081/api) - 演示账号:
demo / demo1234
# 1) 启动后端(首次会自动建库并写入演示数据)
cd project/consult-backend
mvn spring-boot:run
# 看到 Started ConsultApplication 即成功
# 健康检查:http://localhost:8081/api/health
# 接口文档:http://localhost:8081/swagger-ui.html
# 2) 启动客户端
cd project/consult_app
flutter pub get
flutter run # Android 模拟器 / 真机
flutter run -d chrome # 或直接在浏览器里跑(截图即来自 Web 版)
# 3) 复现本章截图(可选)
cd project/consult_app
node tools/serve.mjs build/web 8090 # 静态托管构建产物
node tools/shots.mjs # Playwright 真实登录后逐页截图
演示账号 demo / demo1234(手机号 13211112222 同账号)、test / test1234。预置数据:8 个一级科室 + 18 个二级科室、18 位医生(三甲/二甲两级)、13 篇健康百科、3 位就诊人、2 个收货地址,以及 1 条已完成并评价的问诊(含 2 味药的处方、1 笔药品订单与 4 条物流轨迹)、若干进行中订单——打开就能看到 16.1 截图里的完整界面,不用先手工造数据。
cd project/consult_app && flutter run -d chrome 即可在 Chrome 里操作全部页面(Web 下默认连 http://localhost:8081/api,路由自动使用 hash 模式)。适合还没装 Android SDK、想先确认业务流程的情况;真机表现仍以 flutter run 为准。
http://电脑IP:8081/api/health 能否出 JSON;② 电脑防火墙是否放行 8081;③ 模拟器必须用 10.0.2.2 而不是 localhost;④ App「我的 → 服务器地址」改完立即生效。Android 9+ 明文 HTTP 需要 usesCleartextTraffic="true",项目已在 AndroidManifest 里配好。
16.7 学过的知识落在哪个文件
| 手册章节 | 本项目中的落点 |
|---|---|
| 第 3 章 Dart(空安全、模式匹配、集合 if/for、异常) | lib/data/models.dart(fromJson + DoctorQuery 值对象)、core/api_client.dart(on ApiFailure 分型捕获) |
| 第 4 章 Widget / setState | lib/features/shell/main_shell.dart(NavigationBar 切 Tab) |
| 5.1 Text / Image / 头像 | lib/shared/widgets.dart(EmojiAvatar、NameAvatar) |
| 5.2 Row / Column / Expanded | 医生卡片、药品行、消息气泡 |
| 5.3 ListView / GridView / 下拉刷新 | home_page.dart(九宫格 + 横滑)、doctor_list_page.dart(分页) |
| 5.4 Scaffold / NavigationBar / Card | main_shell.dart(未读角标 Badge)、各详情页 |
| 5.5 按钮 / TextField / Form 校验 | login_page.dart、register_page.dart、consult/illness_page.dart(字段错误回填) |
| 5.6 Dialog / BottomSheet | shared/widgets.dart(confirmDialog、showToast)、评价弹层 |
| 第 6 章 路由与导航 | lib/app.dart(StatefulShellRoute + redirect 守卫 + 21 条路由) |
| 第 7 章 / 7.5 章 状态管理与 Riverpod | lib/data/providers.dart(20 个 Provider) |
| 第 8 章 网络与 JSON | lib/core/api_client.dart、lib/data/api.dart、lib/data/models.dart |
| 第 9 章 持久化 | lib/core/prefs.dart(token、用户信息、服务器地址) |
| 第 10 章 动画 | 消息自动滚动、下拉刷新、状态标签过渡 |
| 第 11 章 主题 | ColorScheme.fromSeed(Teal 种子色)+ 亮暗双主题 |
| 第 12 章 插件 | image_picker(病情/聊天图片)、shared_preferences |
| 第 13 章 调试与测试 | Swagger UI 调接口、tools/shots.mjs(Playwright 端到端) |
| 第 14 章 打包发布 | android/app/src/main/AndroidManifest.xml(网络权限、明文 HTTP) |
16.8 验收清单
能独立完成下面 10 条,说明这个项目你已经吃透:
- 用
demo / demo1234(或手机号 13211112222)登录,杀掉 App 重进仍是登录态(token 持久化); - 首页科室九宫格选「内科 → 呼吸内科」→ 列表只剩呼吸内科医生;搜索框输入关键词能过滤;
- 极速问诊下单后进问诊室,第一条消息拉取时订单自动从「待接诊」变「问诊中」(Swagger 里能查到状态 3);
- 问诊室发一条含「发烧」的消息,约 2 秒后收到带「演示回复」标记的回复;
- 结束问诊后自动开出处方,从订单详情点「查看处方」进入药品支付页,金额、运费、优惠券全部由后端计算;
- 药品订单支付后状态从 10(待支付)变 11(待发货),物流页能看到 ExpressStatus 时间线;
- 就诊人管理:新增一位就诊人并设为默认,列表里原来那位的默认标记自动取消;
- 在 Swagger 里用同一个 token 重复支付已支付订单 → 返回 409,客户端也弹同样提示;用另一账号
test的 token 访问 demo 的订单 → 404; - 「我的 → 服务器地址」改成错误地址 → 所有请求弹「无法连接服务器」,改回来立即恢复;
- 能对着 16.8 的表,说出任意一个功能在哪个文件里实现。
16.9 继续练手的方向
- 换成 WebSocket:把轮询替换成
web_socket_channel,后端加@EnableWebSocket,体会「推」与「拉」的差别; - 加医生端:同一套接口加
role字段,医生登录后看到待接诊列表并真人回复,simulated自然变成 false; - 支付倒计时:预支付页加 15 分钟倒计时(
payTimeoutSeconds后端已返回),超时自动取消订单(@Scheduled); - 图片消息:问诊室接入
image_picker+POST /api/upload,消息类型用msgType=4展示图片气泡(后端已支持); - 收货地址管理:后端已有
GET /api/addresses,补一个地址管理页面(练习表单 + 列表 + 默认标记); - 单元测试:给
models.dart的 fromJson 与ConsultDraft.missingTip补flutter_test用例。