第 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 / setState | lib/features/home/home_page.dart(Tab 切换) |
| 5.1 Text / Image | lib/shared/widgets.dart(AmountText、CategoryAvatar) |
| 5.2 Row / Column / Stack | lib/features/entries/ledger_tab.dart(汇总卡与列表项) |
| 5.3 ListView / GridView / 下拉刷新 | ledger_tab.dart(分页列表 + RefreshIndicator + 无限加载) |
| 5.4 Scaffold / NavigationBar / Card | home_page.dart、stats_tab.dart |
| 5.5 按钮 / TextField / Form | login_page.dart、register_page.dart、entry_form_sheet.dart |
| 5.6 Dialog / BottomSheet | entry_form_sheet.dart、删除确认、设置里的弹窗 |
| 第 6 章 路由与导航 | lib/app.dart(go_router + 登录重定向) |
| 第 7 章 状态管理 / Riverpod | lib/features/entries/providers.dart(AsyncNotifier + 派生 Provider) |
| 第 8 章 网络与 JSON | lib/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
🔗 接口基础地址
- 基础地址:
http://localhost:8080/api(照第 15 章代码在本地跑起来后的地址) - 拼法:本章接口表里的路径都省略了基础地址,例如
POST /api/auth/login→http://localhost:8080/api/auth/login - 记账本项目未部署到公网:线上演示的是第 16 章问诊项目,基础地址
https://aichilizi.com/api
演示账号 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
}
}
关键点
- 密码只存 BCrypt 哈希;登录失败时「用户不存在」与「密码错误」返回同一句提示,避免泄露账号是否注册。
- 接口里查数据一律带
userId条件(findByIdAndUserId),从根上防止越权访问别人的账目。 - token 有效期 72 小时,配置在
application.yml;生产环境密钥要用环境变量注入。
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;
}
关键点
ref.watch(currentMonthProvider)让「换月份」自动触发重查,不需要手动调刷新。- 离线兜底只做在第一页:够看最近记录,也避免缓存与分页逻辑打架。
- 写操作成功后
invalidate汇总与趋势,保证列表、数字、图表三者一致。 - 切 Tab 用
IndexedStack,滚动位置和已加载的分页都保留,体验接近原生。
15.6 接口清单(详见 docs/API.md)
| 方法 | 路径 | 说明 | 成功 |
|---|---|---|---|
| POST | /api/auth/register | 注册并返回 token | 200 |
| 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 验收:做到这些才算「会了」
- 后端能启动,Swagger 页面能登录并调通 9 个接口;
- App 用
demo / demo1234登录后能看到最近 3 个月的账目; - 新增一笔支出后,列表、汇总卡、统计页数字同时变化;
- 左滑删除一条,汇总金额随之减少;
- 把后端停掉,App 仍能打开并显示「离线缓存」提示;
- 设置里切换深色模式,杀掉 App 重进仍是深色;
- 能对照 15.2 的表格,说出每个功能在哪个文件里实现。
15.8 可以继续加的功能(练手方向)
- 预算提醒:按月设预算,超支时首页卡片变红(服务端加一张 budget 表);
- 账目搜索与按分类筛选(接口加
category/keyword参数); - 导出 CSV 并分享(
path_provider+share_plus); - 离线写入队列:断网时先存 sqflite,联网后按顺序补传(体会「冲突处理」的真实难点);
- 多币种与汇率(练习 BigDecimal 与精度处理)。