第 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)。

① 账号与首页

登录页
01 · 登录页:密码 / 短信验证码双模式,内置演示账号提示
首页
02 · 首页:问医生 / 极速问诊 / 开药门诊三入口 + 四宫格 + 关注医生横滑 + 健康百科
我的
08 · 我的:积分、优惠券、健康档案、药品订单、服务器地址设置

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

健康百科列表
05 · 健康百科:推荐 / 减脂 / 饮食三个 Tab,阅读量排序
百科详情
06 · 百科详情:收藏(collect)接口 + 每次进入阅读量 +1
消息通知
07 · 消息通知:未读角标(unread),点击整批已读
就诊人管理
09 · 就诊人管理:增删改 + 默认就诊人,下单时可复用(selectMode)

③ 找医生与下单

医生列表
03 · 医生列表:按二级科室筛选 + 关键词搜索 + 分页,三甲标识 / 评分 / 咨询量
医生详情
04 · 医生详情:关注(follow)、擅长与简介、图文问诊入口
极速问诊
10 · 极速问诊:20 秒极速回复说明页,选择后自动分配高分医生
选择科室
11 · 选择科室:8 个一级科室 + 18 个二级科室两级联动
病情描述
12 · 病情描述:图文 / 拍照,选择就诊人、患病时长、就诊情况

④ 问诊室与问诊订单

问诊室
13 · 问诊室:消息气泡 + 「演示回复」角标 + 2 秒增量轮询
问诊订单
14 · 问诊订单:全部 / 待支付 / 问诊中 / 已完成 状态筛选
问诊详情
15 · 问诊详情:费用明细(优惠券 / 积分抵扣)、评价、再次问诊

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

药品支付
16 · 药品支付:处方药品清单 + 地址 + 优惠券,金额全部由后端计算
药品订单
17 · 药品订单:待支付 / 待发货 / 待收货 / 已完成 状态机
药品订单详情
18 · 药品订单详情:金额明细 + 物流入口
物流详情
19 · 物流详情:ExpressStatus 时间线(支付成功时后端一次性生成)
⚠️ 三处如实说明 ① 本项目没有医生端,医生回复由后端按关键词生成、字段 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。

🔗 在线接口基础地址
分组接口说明
认证(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();
}
关键点

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 秒后再来,不打扰用户
  }
}
关键点

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);
选型经验

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 组)
端口80808081(可与第 15 章同时运行)

16.6 启动步骤

🌐 在线演示
# 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 截图里的完整界面,不用先手工造数据。

🌐 想先在浏览器里看效果? 客户端已包含 Web 平台配置: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 / setStatelib/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 / Cardmain_shell.dart(未读角标 Badge)、各详情页
5.5 按钮 / TextField / Form 校验login_page.dart、register_page.dart、consult/illness_page.dart(字段错误回填)
5.6 Dialog / BottomSheetshared/widgets.dart(confirmDialog、showToast)、评价弹层
第 6 章 路由与导航lib/app.dart(StatefulShellRoute + redirect 守卫 + 21 条路由)
第 7 章 / 7.5 章 状态管理与 Riverpodlib/data/providers.dart(20 个 Provider)
第 8 章 网络与 JSONlib/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 条,说明这个项目你已经吃透:

  1. 用 demo / demo1234(或手机号 13211112222)登录,杀掉 App 重进仍是登录态(token 持久化);
  2. 首页科室九宫格选「内科 → 呼吸内科」→ 列表只剩呼吸内科医生;搜索框输入关键词能过滤;
  3. 极速问诊下单后进问诊室,第一条消息拉取时订单自动从「待接诊」变「问诊中」(Swagger 里能查到状态 3);
  4. 问诊室发一条含「发烧」的消息,约 2 秒后收到带「演示回复」标记的回复;
  5. 结束问诊后自动开出处方,从订单详情点「查看处方」进入药品支付页,金额、运费、优惠券全部由后端计算;
  6. 药品订单支付后状态从 10(待支付)变 11(待发货),物流页能看到 ExpressStatus 时间线;
  7. 就诊人管理:新增一位就诊人并设为默认,列表里原来那位的默认标记自动取消;
  8. 在 Swagger 里用同一个 token 重复支付已支付订单 → 返回 409,客户端也弹同样提示;用另一账号 test 的 token 访问 demo 的订单 → 404;
  9. 「我的 → 服务器地址」改成错误地址 → 所有请求弹「无法连接服务器」,改回来立即恢复;
  10. 能对着 16.8 的表,说出任意一个功能在哪个文件里实现。

16.9 继续练手的方向

← 上一章第 15 章 综合实战:记账本 App(Flutter + Spring Boot 全栈)下一章 →第 17 章 学习路径与官方资源
✉ 联系作者:linghurongjun@sina.com