第 15 章 综合实战:记账本 App(Flutter + Spring Boot 全栈)

本章配套的项目已经写完并真实跑通,就在本手册同级的 project/ 目录里,不是伪代码:

project/
├── ledger-backend/    Spring Boot 3.5 后端(JWT + JPA + H2),接口已逐个实测
├── ledger_app/        Flutter 3.47 客户端(Riverpod + dio + go_router + sqflite)
└── docs/API.md        完整接口文档(也可看 Swagger UI)

15.1 项目做了什么

📱 记账本 Ledger 一个真正能用的个人记账应用:注册登录 → 按月记账 → 看月度汇总与 6 个月趋势 → 断网仍能查看缓存数据。数据保存在你自己部署的后端,不依赖任何第三方服务。
模块功能技术点
账号注册、登录、退出、token 持久化、401 自动登出Spring Security + JWT、BCrypt、dio 拦截器、shared_preferences
账目按月分页列表、下拉刷新、上拉加载、新增/编辑/删除JPA 分页、@Valid 校验、Riverpod Notifier、Dismissible、BottomSheet 表单
统计月度收入/支出/结余、6 个月柱状图、分类占比JPQL 聚合查询、CustomPainter 手绘图表、TweenAnimationBuilder
体验深色模式、中文本地化、空态/骨架屏/错误重试、离线缓存Material 3、flutter_localizations、sqflite 兜底

15.2 文档知识与项目文件的对应关系

下面这张表说明每一章学的东西在项目里落在哪个文件——做完项目就等于把全手册过了一遍。

手册章节项目中的落点
第 3 章 Dart(空安全、集合、异步)lib/core/format.dart、lib/features/entries/models.dart
第 4 章 Widget / setStatelib/features/home/home_page.dart(Tab 切换)
5.1 Text / Imagelib/shared/widgets.dart(AmountText、CategoryAvatar)
5.2 Row / Column / Stacklib/features/entries/ledger_tab.dart(汇总卡与列表项)
5.3 ListView / GridView / 下拉刷新ledger_tab.dart(分页列表 + RefreshIndicator + 无限加载)
5.4 Scaffold / NavigationBar / Cardhome_page.dart、stats_tab.dart
5.5 按钮 / TextField / Formlogin_page.dart、register_page.dart、entry_form_sheet.dart
5.6 Dialog / BottomSheetentry_form_sheet.dart、删除确认、设置里的弹窗
第 6 章 路由与导航lib/app.dart(go_router + 登录重定向)
第 7 章 状态管理 / Riverpodlib/features/entries/providers.dart(AsyncNotifier + 派生 Provider)
第 8 章 网络与 JSONlib/core/api_client.dart、entries/repository.dart
第 9 章 持久化lib/core/local_db.dart(sqflite 缓存)、core/prefs.dart
第 10 章 动画数字滚动、进度条增长、柱状图生长(bar_chart.dart)
第 11 章 主题lib/shared/theme.dart + 设置页主题切换
第 13 章 调试测试test/core_test.dart、Swagger UI 调接口
第 14 章 打包发布android/app/src/main/AndroidManifest.xml(权限、应用名)

15.3 启动步骤(后端 1 分钟,客户端 3 分钟)

第一步:启动后端

cd project/ledger-backend
mvn spring-boot:run
# 看到 Started LedgerApplication 即成功;首次会自动写入演示数据
# 健康检查:http://localhost:8080/api/health
# 接口文档:http://localhost:8080/swagger-ui.html
🔗 接口基础地址

演示账号 demo / demo1234,预置最近 3 个月约 45 条贴近真实的账目(房租、工资、通勤、聚餐、话费……)。数据存在 ledger-backend/data/ledger.mv.db,删掉 data/ 目录即可重置。

第二步:启动客户端

cd project/ledger_app
flutter pub get
flutter devices
flutter run
⚠️ 服务器地址必须对 Android 模拟器访问电脑要用 http://10.0.2.2:8080/api(项目默认值);真机要改成电脑局域网 IP,例如 http://192.168.1.10:8080/api,并在 App「设置 → 服务器地址」里保存。手机浏览器先访问 http://电脑IP:8080/api/health 能出 JSON,才说明网络通。

15.4 后端关键实现

15.4.1 无状态 JWT 鉴权

// security/SecurityConfig.java —— 关闭 session,放行登录注册与文档,其余必须带 token
http.csrf(csrf -> csrf.disable())
    .cors(Customizer.withDefaults())
    .httpBasic(AbstractHttpConfigurer::disable)
    .formLogin(AbstractHttpConfigurer::disable)
    .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
    .authorizeHttpRequests(auth -> auth
        .requestMatchers("/api/auth/**").permitAll()
        .requestMatchers("/v3/api-docs/**", "/swagger-ui/**").permitAll()
        .requestMatchers(HttpMethod.GET, "/api/health").permitAll()
        .anyRequest().authenticated())
    .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
// security/JwtService.java —— 签发与校验(HS256,密钥需 ≥ 32 字节)
public String generate(String username) {
    Date now = new Date();
    return Jwts.builder()
            .subject(username)
            .issuedAt(now)
            .expiration(new Date(now.getTime() + ttlMillis))
            .signWith(key)
            .compact();
}

public Optional<String> parseUsername(String token) {
    try {
        Claims claims = Jwts.parser().verifyWith(key).build()
                .parseSignedClaims(token).getPayload();
        return Optional.ofNullable(claims.getSubject());
    } catch (JwtException | IllegalArgumentException e) {
        return Optional.empty(); // 过期或伪造,交给上层返回 401
    }
}
关键点

15.4.2 统计接口:一条 JPQL 搞定分类聚合

// repo/EntryRepository.java
@Query("""
        select e.category as category, sum(e.amount) as total from Entry e
        where e.user.id = :userId and e.type = :type
          and e.occurredOn between :start and :end
        group by e.category
        order by sum(e.amount) desc
        """)
List<CategoryTotal> sumByCategory(@Param("userId") Long userId,
                                  @Param("type") EntryType type,
                                  @Param("start") LocalDate start,
                                  @Param("end") LocalDate end);

// 接口投影:查询结果直接映射,不用再手写转换类
interface CategoryTotal {
    String getCategory();
    BigDecimal getTotal();
}

金额统一 BigDecimal(12,2);SUM 在没有记录时返回 null,service 层做了 nz() 兜底,避免前端拿到 null 崩掉。分类占比在 service 里算,保留 1 位小数。

15.5 客户端关键实现

15.5.1 Dio 单例 + 拦截器(token 与 401 统一处理)

// core/api_client.dart
final dioProvider = Provider<Dio>((ref) {
  final baseUrl = ref.watch(serverUrlProvider); // 设置页改地址后自动重建
  final dio = Dio(BaseOptions(
    baseUrl: baseUrl,
    connectTimeout: const Duration(seconds: 10),
    receiveTimeout: const Duration(seconds: 15),
  ));

  dio.interceptors.add(InterceptorsWrapper(
    onRequest: (options, handler) {
      final token = ref.read(sessionProvider).token; // read:不订阅,避免重建
      if (token != null && token.isNotEmpty) {
        options.headers['Authorization'] = 'Bearer $token';
      }
      handler.next(options);
    },
    onError: (e, handler) {
      // 登录接口的 401 是密码错,不能当成登录过期
      final isAuthPath = e.requestOptions.path.startsWith('/auth/');
      if (e.response?.statusCode == 401 && !isAuthPath) {
        ref.read(sessionProvider.notifier).clear(); // 自动跳回登录页
      }
      handler.next(e);
    },
  ));
  ref.onDispose(dio.close);
  return dio;
});

15.5.2 列表状态:AsyncNotifier + 分页 + 离线兜底

// features/entries/providers.dart
class EntriesNotifier extends AsyncNotifier<EntryListState> {
  int _page = 0;

  @override
  Future<EntryListState> build() async {
    final month = ref.watch(currentMonthProvider); // 切月份自动重查
    final result = await ref.watch(entryRepositoryProvider).fetchPage(month: month);
    return EntryListState(items: result.items, hasMore: result.hasMore,
        offline: result.offline, total: result.total);
  }

  Future<void> loadMore() async {
    final current = state.value;
    if (current == null || !current.hasMore) return;
    final next = await ref.read(entryRepositoryProvider)
        .fetchPage(month: ref.read(currentMonthProvider), page: _page + 1);
    _page += 1;
    state = AsyncData(current.copyWith(
      items: [...current.items, ...next.items],
      hasMore: next.hasMore,
    ));
  }
}
// features/entries/repository.dart —— 网络失败自动读本地缓存
} on DioException catch (e) {
  final api = ApiException.fromDio(e);
  if (api.isNetwork && page == 0) {
    final cached = await _db.readMonth(month);
    if (cached.isNotEmpty) {
      return PageResult(items: cached, page: 0, total: cached.length,
          hasMore: false, offline: true); // 界面显示「离线缓存」横幅
    }
  }
  throw api;
}
关键点

15.6 接口清单(详见 docs/API.md)

方法路径说明成功
POST/api/auth/register注册并返回 token200
POST/api/auth/login登录200
GET/api/health健康检查(免登录)200
GET/api/entries?month=&page=&size=按月分页查询200
POST/api/entries新增账目201
PUT/api/entries/{id}修改账目200
DELETE/api/entries/{id}删除账目204
GET/api/entries/summary?month=月度汇总与分类占比200
GET/api/entries/trend?months=6近 N 月趋势200

命令行验证(Windows 上中文 JSON 请存成 UTF-8 文件再用 --data-binary @body.json):

TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"demo","password":"demo1234"}' | sed -E 's/.*"token":"([^"]+)".*/\1/')

curl -s "http://localhost:8080/api/entries/summary?month=2026-11" \
  -H "Authorization: Bearer $TOKEN"

15.7 验收:做到这些才算「会了」

  1. 后端能启动,Swagger 页面能登录并调通 9 个接口;
  2. App 用 demo / demo1234 登录后能看到最近 3 个月的账目;
  3. 新增一笔支出后,列表、汇总卡、统计页数字同时变化;
  4. 左滑删除一条,汇总金额随之减少;
  5. 把后端停掉,App 仍能打开并显示「离线缓存」提示;
  6. 设置里切换深色模式,杀掉 App 重进仍是深色;
  7. 能对照 15.2 的表格,说出每个功能在哪个文件里实现。

15.8 可以继续加的功能(练手方向)

← 上一章第 14 章 打包发布 Android 应用下一章 →第 16 章 综合实战二:优医问诊 App(Flutter + Spring Boot 全栈)
✉ 联系作者:linghurongjun@sina.com