作者:雪隐_上班了 本文欢迎分享、聚合讨论,但不接受整篇搬运。圈子不大,写文不易,急用可联系授权。
写在前面
这篇文章本来是我给自己写的"项目复盘",写着写着发现------好家伙,这字数快赶上毕业论文了。
既然都写这么多了,不如发出来给大伙看看。
如果你正打算用 Flutter + NestJS 做个全栈项目,这篇文章大概能帮你省下几个踩坑的夜晚。如果你只是闲着没事点进来看看......那也行,就当听一个失业人员吹牛。
一、项目背景:为什么我要自己造轮子?
事情的起因很简单:
我想背词组,市面上的App没有。
我问客服,客服说"以后可能会加"。
翻译一下:"以后" ≈ "下辈子"。
再加上正好失业在家,闲着也是闲着,不如自己干一个。
于是就有了佳禾英语(JiaHe English)------一个名字听起来像补习班、但确实能用的单词学习App。
整个项目的技术栈是:
- 前端:Flutter 3.x(跨平台,一套代码跑iOS和Android)
- 后端:NestJS 11 + TypeORM + MySQL 8
- 通信方式:RESTful API,HTTP协议
- 部署:树莓派(详见前几篇文章)
一句话概括:从单词怎么存,到单词怎么学,再到学了怎么记,全链路自己撸了一遍。
为什么叫"佳禾"? 因为我捏的那个虚拟形象叫"佳禾"。
对,就是上回文章里那个小女孩。
一个背单词App用虚拟美少女当吉祥物,这事儿干得出来吧?
二、后端架构:3600行代码,10个模块,9张表
后端约 3,600 行 TypeScript 代码,63 个源文件。
采用 NestJS 的模块化架构------说白了就是"分门别类放东西",按业务领域拆成了 10 个功能模块 + 1 个数据填充模块。
2.1 数据模型:9张表的关系网
先来个关系图,感受一下这9张表是怎么串起来的:
bash
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐
│ users │────►│ user_settings │ │ words │
└──────────────┘ └──────────────────┘ └──────────────┘
│ │
│ ┌──────────────────────┐ │
├────►│ user_word_progress │◄─────────────┤
│ └──────────────────────┘ │
│ ┌──────────────────────┐ │
├────►│ daily_records │ │
│ └──────────────────────┘ │
│ ┌──────────────────────┐ │
├────►│ search_history │ │
│ └──────────────────────┘ │
│ ┌──────────────────────┐ │
└────►│ wordbooks │ │
└──────────┬───────────┘ │
│ │
│ ┌──────────────────┐ │
└──│ wordbook_words │◄───┘
└──────────────────┘
┌──────────────────┐
│ admins │ ← 管理后台独立表
└──────────────────┘
看着复杂,其实核心就两条线:
单词线 :words → wordbook_words → wordbooks(单词属于哪个单词本)
学习线 :users → user_word_progress → daily_records(谁学了啥、学了没)
其他表都是辅助------user_settings存个人偏好,search_history存搜索记录,admins给管理后台用。
2.2 核心表:user_word_progress 的设计
这张表是整个学习引擎的心脏,没有它,SM-2算法就是个空壳。
typescript
// user_word_progress 核心字段
easiness_factor: decimal(3,2) // 初始 2.50,SM-2 核心参数
correct_streak: int // 连续正确次数,达到5次就"掌握"
review_count: int // 复习总次数(含答错)
next_review_at: datetime // 下次复习时间,靠它决定今天该学啥
last_interval_days: int // 上次间隔天数
status: tinyint // 0=未学 1=学习中 2=已掌握
source: varchar(20) // 'new' | 'review' | 'simple'
简单说一下这些字段是干嘛的:
easiness_factor:SM-2的核心参数,初始2.5。你答对了它就变大(最多2.5),答错了它就变小(最少1.3),直接影响下次复习间隔。correct_streak:连续答对次数。连续答对5次,你就"掌握"了这个词。next_review_at:下次该复习的时间。每天的"复习任务"就是从这里捞数据。
说白了,这张表就是每个人的"学习账本",
你今天学了什么、学得怎么样、明天该复习哪个词,全记在上面。
2.3 SM-2间隔重复算法:我照抄了SuperMemo的作业
学习引擎的核心是 SuperMemo SM-2 算法。这东西1987年就有了,比我还大几岁,但至今仍是背单词App的黄金标准。
算法的核心逻辑在 LearnService 里,简化版是这样的:
ini
// 回答正确时的处理
IF correct_streak == 0:
interval = 1 天
ELIF correct_streak == 1:
interval = 6 天
ELSE:
interval = last_interval_days * easiness_factor // 最多365天
easiness_factor += 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)
// quality 取值范围 0-5,由用户回答决定
// 回答错误时的处理
correct_streak = 0
easiness_factor = max(1.3, easiness_factor - 0.2)
interval = 1 天
翻译成人话:
- 第一次答对 → 间隔1天再复习
- 第二次答对 → 间隔6天再复习
- 第三次及以后 → 间隔 = 上次间隔 × 容易度系数(越容易间隔越长)
- 答错了 → 明天就给我回来重学!
quality(回答质量)怎么来的?
- 用户点"认识" → quality = 5(完美)
- 用户点"不认识" → quality = 1(错误)
- 拼写模式正确 → quality = 5
- 拼写模式错误 → quality = 1
连续正确达到 5次 时,状态标记为"已掌握",不再进入每日复习队列------但用户还是可以在单词本里找到它。
这算法听起来复杂,其实核心就一句话:
"你会了我就少烦你,你不会我就多烦你。"
跟老妈催你找对象是一个逻辑。
2.4 每日学习任务:怎么决定你今天学什么?
getDailyWords 方法的工作流程:
- 从
user_settings读取用户设定的每日新词数(默认10个) - 查询今天已经学了多少个(从
daily_records) - 计算今天还能学几个新词(额度 - 已学)
- 从
user_word_progress中捞"未学"的单词,补满额度 → NEW - 从
user_word_progress中捞next_review_at <= NOW的单词 → REVIEW - 合并 NEW + REVIEW,同一个单词的不同词性合并成一张卡
- 返回完整的卡片数据(含所有词性、音标、例句、音频)
关键细节:同一个单词的不同词性合并为一张卡。
什么意思?比如你学 run:
- 作为名词(n.)→ 跑步
- 作为动词(v.)→ 跑;运转
后端会把两个词性合并成一个"学习卡片",你一次学完所有词性,而不是分三次学。
这功能看着简单,
但市面上有一半背单词App都做不到,
因为它们的设计压根儿没考虑"一词多性"。
2.5 数据从哪儿来?------上一篇文章的事,这儿就不重复了
数据怎么填进 words 表的,我在上一篇文章里已经详细讲过了------本地大模型批量生成、Free Dictionary API、有道API三种策略轮着上,最终搞定了1.5万条记录。
感兴趣的同学可以翻回去看,这里就不再水字数了。
2.6 补签机制:给自己留条后路
日历模块的补签功能是个完整的"前端操作→后端处理"链路:
markdown
前端操作:
1. 点击灰色日期 → 弹出补签确认
2. 确认 → POST /calendar/makeup { userId, date }
后端校验:
- 日期是否在过去 30 天内
- 日期不能是今天或未来
- 剩余补签次数 > 0(从 user_settings.makeup_chances 读取)
后端处理:
1. 扣除一次补签机会
2. 创建或更新 daily_records(record_date)
3. 返回最新日历数据
补签卡这东西,
说白了就是"我给自己留的后路"。
毕竟我知道自己有多懒,总有几天不想学。
2.7 其他后端设计
- 认证 :管理后台用 JWT(passport-jwt),7天过期。用户端目前硬编码
userId=1------TODO列表第一位就是"实现用户认证"。 - 日志:用 Pino 输出结构化 JSON 日志,按天滚动,区分 app.log / error.log。
- 异常处理 :全局
HttpExceptionFilter捕获所有异常,统一返回格式。 - 健康检查 :
GET /health验证数据库连接和核心表是否存在。
三、前端架构:6200行Dart,19个文件,纯setState
前端约 6,200 行 Dart 代码,19 个文件。
一个重要的技术决策:我没有用 Provider / Riverpod / Bloc 等任何状态管理库。
是的,纯 StatefulWidget + setState。
有人可能觉得这是"技术债",
但我的想法是:项目初期功能边界清晰,页面间共享状态极少,
setState 完全够用,而且省去了学新框架的时间。
就像你只是下楼买个菜,没必要开辆卡车去。
后续如果加多用户、实时数据、页面间复杂通信,再迁移到 Riverpod 也不迟。
3.1 页面结构:5个主要页面 + 嵌套子页
perl
MainPage(IndexedStack 保活 3 个 Tab)
│
├── [Tab 0] HomePage
│ ├── 顶部渐变 Header + 搜索入口
│ ├── 周打卡状态(7 天网格,每格带🔥图标)
│ ├── 连续天数 / 累计天数
│ ├── "开始背单词"按钮 → push StudyPage
│ └── "最近添加"单词列表(WordCard)
│
├── [Tab 1] CalendarPage
│ ├── 月度日历网格(TableCalendar)
│ ├── 学习统计卡片(总词数 / 学习天数 / 连续天数)
│ └── 补签交互(BottomSheet → 确认 Dialog → API)
│
├── [Tab 2] ProfilePage
│ ├── 用户头像 + 欢迎语
│ ├── "我的单词本" → push WordbookPage
│ └── "设置" → push SettingsPage
│
├── SearchPage → push(AppBar 内嵌搜索框)
│ ├── 搜索历史(GET /search-history)
│ ├── 搜索结果列表
│ └── 自定义添加单词(BottomSheet 表单)
│
├── WordDetailPage → push
│ ├── 多词性卡片展示
│ ├── 音频播放(audioplayers)
│ └── 加入/移出单词本 + 标记忘记
│
├── StudyPage → push(最复杂的页面,1114 行)
│ ├── 三种学习模式切换
│ ├── 本地进度缓存(SharedPreferences)
│ └── 学习结果提交(POST /learn/result)
│
├── WordbookPage → push
│ └── WordbookDetailPage → push(4 Tab 筛选)
│ ├── 今日任务(今日添加 + 复习词)
│ ├── 在学单词
│ ├── 未学单词
│ └── 简单词
│
└── SettingsPage → push
├── 每日目标(Dialog 数字输入)
├── 通知开关(Switch)
└── 通知时间(TimePicker)
导航用的是原生的 Navigator.push(MaterialPageRoute(...)),没引入 GoRouter。底部导航用 IndexedStack 保活三个 Tab 的状态。
为什么页面设计这么多?
因为市面上背单词App有的功能,我全都要有。
他们没有的(词组、补签、多词性合并),我也要有。
3.2 学习页:1114行的庞然大物
StudyPage 是整个应用最复杂的页面------1114行Dart代码。
它实现了一套前端自维护的简易间隔重复引擎,跟后端的SM-2是"双轨并行"。
三种学习模式:
css
StudyMode.showMeaning(显示单词,用户选择"认识/不认识")
┌──────────────────────┐
│ abandon │
│ /əˈbændən/ │ ← 显示单词 + 音标 + 发音按钮
│ │
│ [🔊 发音] │
│ │
│ [认识] [不认识] │ ← 两个按钮
└──────────────────────┘
StudyMode.quiz(点击"认识"后进入拼写模式)
┌──────────────────────┐
│ 请输入单词拼写: │
│ ┌──────────────┐ │
│ │ a b a n d _ _│ │ ← 用户输入
│ └──────────────┘ │
│ [提交] │
└──────────────────────┘
StudyMode.showResult(显示结果)
┌──────────────────────┐
│ abandon │
│ ✓ 正确! │ ← 或 ✗ 错误
│ 例句展示... │
│ [继续] │
└──────────────────────┘
候选池算法:
- 初始化:从当前单词本加载所有单词
- 构建候选池(
_candidatePool):固定大小约10个,优先选"未掌握且失败次数少"的 - 学习逻辑:
- 回答正确 → streak++,移入下一轮
- 回答错误 → streak=0,移到池尾(轮转)
- 掌握条件:streak >= (从未失败 ? 1 : 4)
- 已掌握单词移出池,加入
_masteredWordIds
- 补充机制:当未掌握单词 ≤ 5 个时,从全量列表自动补充新词
进度持久化(SharedPreferences):
dart
// 存储的数据结构
{
'study_progress_2026-07-14': {
masteredWordIds: [1, 3, 5, ...],
wordStreaks: { '2': 3, '4': 1, ... },
everFailedWordIds: [4, 7, ...],
candidatePool: [8, 9, 2, ...],
currentIndex: 2
}
}
关键是 按日期键名存储。如果日期变更(跨天),自动重置进度,保证每天的学习从新开始。
自动保存机制:
dart
// WidgetsBindingObserver 监听应用生命周期
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive) {
_saveProgress(); // 锁屏/切换后台时自动保存
}
}
为什么前端要自己维护一套进度?
- 即时反馈:用户点"认识"不需要等网络请求就能看到进度更新
- 离线降级:没网也能学,学完有网了再同步到后端
- 双重保险:后端数据万一出问题,前端缓存还能兜底
3.3 单词详情页:一词多性的可视化
一个单词可能有多个词性,后端 /words/detail/:word 返回按词性分组的数组:
dart
// WordDetailPage 展示逻辑
ListView.builder(
itemCount: entries.length, // 每个词性一个卡片
itemBuilder: (context, index) {
final entry = entries[index];
return Card(
child: Column(
children: [
Text(entry.word), // record
Text(entry.phonetic), // /'rekɔːd/
Text(entry.pos), // n.
Text(entry.definition), // 记录,档案
Text(entry.example), // "Keep a record..."
if (entry.audioUrl != null)
IconButton(
icon: Icon(Icons.volume_up),
onPressed: () => _playAudio(entry.audioUrl),
),
],
),
);
},
)
展示效果就是:一个词,多个卡片,每个卡片对应一个词性,每个词性都有单独的发音、释义、例句。
3.4 搜索与自定义添加:打破"只能背内置词库"的限制
搜索页支持两种交互:
搜索已有单词 :输入关键词 → GET /words?word=keyword → 返回匹配结果列表,点击进入详情页。
自定义添加新单词:通过 BottomSheet 展开表单:
dart
// 自定义添加表单
showModalBottomSheet(
context: context,
builder: (_) => Form(
child: Column(
children: [
// 类型选择:单词 / 短语
Wrap(
children: ['word', 'phrase']
.map((type) => ChoiceChip(
label: Text(type == 'word' ? '单词' : '短语'),
selected: _selectedType == type,
))
),
// 必填:单词/短语 + 释义
TextFormField(decoration: InputDecoration(labelText: '单词/短语 *')),
TextFormField(decoration: InputDecoration(labelText: '释义 *')),
// 选填:音标、词性、例句、例句翻译
TextFormField(decoration: InputDecoration(labelText: '音标')),
TextFormField(decoration: InputDecoration(labelText: '词性')),
TextFormField(decoration: InputDecoration(labelText: '英文例句')),
TextFormField(decoration: InputDecoration(labelText: '中文翻译')),
ElevatedButton(onPressed: _submitCustomWord, child: Text('添加')),
],
),
),
);
这个功能就是当初我吐槽"市面上的App不能自定义词组"的解决方案。
你们没有?那我自己加。就是这么简单粗暴。
3.5 ApiService与统一错误处理
所有 API 调用通过单例 ApiService 管理,每个页面都遵循统一的加载/错误/成功模式:
dart
class _HomePageState extends State<HomePage> {
HomeData? _homeData;
bool _isLoading = true;
String? _errorMessage;
Future<void> _loadData() async {
setState(() => _isLoading = true);
try {
final data = await ApiService().getHomeData();
setState(() {
_homeData = data;
_errorMessage = null;
});
} on ApiException catch (e) {
setState(() => _errorMessage = e.message);
} catch (e) {
setState(() => _errorMessage = '网络错误,请稍后重试');
} finally {
setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) return Center(child: CircularProgressIndicator());
if (_errorMessage != null) return ErrorWidget(message: _errorMessage!, onRetry: _loadData);
return ContentWidget(data: _homeData!);
}
}
同样的模式出现在 HomePage、CalendarPage、WordbookPage、StudyPage 等所有页面中------保证了全 App 一致的交互体验。
四、一次完整的学习流程:从点击到数据落地的全链路
以"完成一次学习"为例,看看数据是怎么流动的:
yaml
用户操作:进入 StudyPage → 学习 10 个单词 → 点击"完成"
│
▼
前端内存:更新 _masteredWordIds / _wordStreaks / _everFailedWordIds
│
自动保存到 SharedPreferences(备灾)
│
▼
POST /learn/result ← 批量的学习结果
Body: {
userId: 1,
results: [
{ wordId: 101, action: 'remember' },
{ wordId: 102, action: 'forgot' },
{ wordId: 103, action: 'remember' },
...
]
}
│
▼
后端 LearnService.handleLearnResult()
│
├─► 逐条处理:
│ ├─► 更新 user_word_progress(easiness_factor, correct_streak,
│ │ next_review_at, review_count, status...)
│ ├─► remember → SM-2 间隔递增
│ └─► forgot → SM-2 重置
│
├─► 更新 daily_records(record_date +1)
│
└─► 返回 { learnedCount: 10, masteredCount: 3, reviewCount: 7 }
│
▼
前端收到响应 → 展示学习结果页
│
▼
用户回到首页 → GET /home/data → 刷新周状态和连续天数
整个链路的核心思想:
- 前端先把用户体验搞定(即时反馈、离线缓存)
- 后端再把数据持久化搞定(SM-2算法、学习记录)
- 最后两者通过API对齐(批量提交学习结果)
说白了就是:前端"先记着",后端"最终确认",
两者都对上了,数据才是真的。
有点像"你老婆说买了,你看了眼支付宝,确认真的买了"------双重验证。
五、一个诚实的技术坦白:这项目到底有多"丐"?
前面讲得热火朝天,但其实有几个事我一直没好意思说。
既然文章都写到这儿了,不如一起坦白了吧。
5.1 代码大部分是AI写的
先别急着骂我。
是的,这项目的大部分代码是AI生成的。
不是我一行一行敲的,而是我让AI写了初稿,然后我一行一行改的。
过程大概是这样的:
- 我跟AI说:"帮我写一个NestJS的LearnService,实现SM-2算法"
- AI吐出一堆代码
- 我看了之后说:"这里不对,interval应该这么算"或者"你这个字段类型用错了"
- AI修正
- 我再改改细节
- 如此反复N个来回
最后出来的代码,可能是 60% AI写的 + 40% 我改的。
有人可能会说:"那这不就是AI写的项目吗?"
我的回答是:你让AI自己试一个看看?
没有人类在中间把关、纠错、调整、整合,AI生成的东西就是一堆会跑的"赛博废品"。
AI是副驾驶,方向盘还得我握着。
5.2 免费Apple开发者账号:一周续命一次
更惨的是发布。
我用的是免费的Apple开发者账号 ,用 flutter run --release 安装到手机上。
只能管7天。
7天之后,App就"过期"了,打不开。你需要重新连上电脑再跑一次 flutter run --release。
这感觉就像你养了只电子宠物,
每周得给它"喂一次代码",不然它就饿死了。
所以目前的"发布流程"是:
- 每周日晚上
- 打开电脑,连上iPhone
- 跑一遍
flutter run --release - 然后祈祷接下来7天不要出bug
每年99美元的Apple开发者账号?
等我找到工作再说吧。
现在?
99美元够我吃一个月挂壁面(加蛋版)。
5.3 后台地址:为什么是 192.168..?
还有一个更"丐"的:
我的App请求的后台地址是 http://192.168.1.12:3000/api/...
对,就是内网IP。
为什么不用域名?
因为用域名就得买证书、搞备案、弄公网IP......每一样都要钱。而且我只是做个App给自己用,内网够用了。
目前的状态是:
- 树莓派跑后端,连在家里路由器上
- 出门在外时,通过某种方式连回家里网络
- App的BaseURL写死成
http://192.168.1.12:3000/api
好处是:不要钱,不用证书,随时能跑。
坏处是:出门在外得先连回家里网络,不然App就"网络错误"。
所以这App目前的使用场景是:
在家,连Wi-Fi,背单词。
在外头?你只能看着"网络错误"发呆。
六、技术决策与取舍:为什么这么干?
6.1 为什么前端自己也实现了一套学习算法?
后端有完整的 SM-2,前端也有简易版进度追踪。
这是有意为之的双轨设计:
- 前端本地进度用于即时反馈(用户不需要等网络请求就知道自己掌握了哪些单词)
- 后端进度是权威数据源,用于跨设备同步和数据分析
- 前端 SharedPreferences 缓存也是离线降级方案------没网也能学
打个比方:
前端是"草稿本",涂涂画画随时看;
后端是"正式账本",一笔一画不能错。
草稿本可以丢,正式账本不能丢。
6.2 为什么不用状态管理库?
初期评估认为:
- 页面间共享状态极少(主要是
userId = 1硬编码) - 每个页面独立管理自己的数据
- 团队(一个人)熟悉 setState
- 减少依赖引入,降低项目复杂度
后续如果加入多用户、实时数据、页面间复杂通信,迁移到 Riverpod 或 Bloc 是合理的演进方向。
简单说就是:
"够用就行"------这是单人项目的生存哲学。
6.3 为什么用户API没有认证?
这是一个 BFF(Backend For Frontend)风格 的简化设计。后端假设调用方(Flutter App)已经通过某种方式获得了用户身份,目前是写死 userId=1。
管理后台使用独立的 JWT 认证,互不干扰。
在项目的 TODO 列表里,用户认证排在靠前的位置:
typescript
// LearnService 中的 TODO
// TODO: 从 JWT token 获取真实 userId
const userId = body.userId || 1;
我知道这事儿早晚得做,
但先把核心功能跑通,再考虑安全------
毕竟现阶段,我的App连用户都没有,
黑客也不会闲到黑一个日活为1(我自己)的系统。
七、项目统计与演进路线
项目规模
| 指标 | 前端(Flutter) | 后端(NestJS) |
|---|---|---|
| 源文件数 | 19 | 63 |
| 代码行数 | ~6,200 | ~3,600 |
| API 端点数 | --- | 37 |
| 数据库表 | --- | 9 |
| 依赖包 | 6 | ~20+ |
| 单词数据 | --- | 5,614 个单词,1.5万+ 条记录 |
| 代码中AI生成比例 | ~60% | ~60% |
| 开发者账号费用 | $0(免费账号) | --- |
| 服务器费用 | ¥0.6/月(电费) | --- |
| 域名/证书费用 | ¥0 | --- |
从数据量来看,这已经不是一个"玩具项目"了,
但从成本来看,它还是个"丐版项目"。
全栈开发,月成本6毛钱,
这事儿说出去估计能把云服务厂商气死。
不足之处与下一步
- 用户认证 --- 从硬编码
userId迁移到 JWT + 手机号/微信登录 - 状态管理 --- setState → Riverpod 或 Bloc,解决页面间状态共享
- 路由 --- MaterialPageRoute → GoRouter,支持深层链接和页面过渡动画
- 离线能力 --- SharedPreferences → SQLite(drift/sqflite),支持完整离线学习
- 通知推送 --- 本地通知 → firebase_messaging 远程推送
- 测试覆盖 --- 当前只有1个测试文件,远不够
- CI/CD --- 自动化测试 + 构建 + 部署流水线
- Apple开发者账号 --- 免费 → 付费(等我找到工作)
- HTTPS/域名 --- 内网HTTP → 公网HTTPS(等我找到工作)
- 公网部署 --- 内网 → 真正的公网服务(等我找到工作)
注意上面最后三条:
**"等我找到工作"**出现了三次。
这大概就是独立开发者最真实的写照:
不是技术做不到,是钱包不允许。
可能的扩展方向
- 学习模式扩展:选择题、听力理解、拼写纠错、句子填空
- 数据分析:艾宾浩斯遗忘曲线展示、学习效率分析、薄弱环节识别
- 社交功能:学习小组、排行榜、打卡分享
- 内容生态:内置词库扩展(四六级/考研/雅思/托福词库)、用户上传词库
上面这些"下一步"和"扩展方向",
翻译一下就是:
"我脑子里还有很多想法,但手只有一双,而且还得找工作。"
八、总结
佳禾英语是一个从零搭建的全栈项目,涵盖了 数据库设计 → 后端API开发 → Flutter跨平台客户端 的完整链路。
项目最核心的价值在于:
- 实现了一套可用的 SM-2间隔重复算法
- 围绕它构建了单词管理、学习引擎、日历打卡、补签机制、搜索词库等完整功能
- 前后端双轨进度管理,实现了即时反馈 + 数据持久化
技术选型上偏向务实(或者说"穷"):
- NestJS 提供清晰的模块化架构
- TypeORM 简化数据库操作
- Flutter 实现跨平台UI
- setState + SharedPreferences 够用就先用着
- 树莓派当服务器,月成本6毛
- 免费Apple账号,每周续命一次
- HTTP + 内网IP,证书?不存在的
虽然还有很多简化之处和待改进项,但作为一个个人全栈项目,它已经跑通了 从学到练到数据追踪 的完整闭环。
而且,它真的只花了6毛钱一个月。
有人说:"你这么做太凑合了吧?"
我想说:在找到工作之前,凑合就是我的生产力。
等我有钱了,证书、域名、付费账号、云服务器,全套安排上。
但现在?
6毛钱的服务器 + 免费账号 + AI写的代码 + 每周手动续命一次,
能跑就是胜利。
最后的最后
这篇文章写了两天,全文超过1.6万字。
如果你看到了这里------
你是真爱,我谢谢你。
如果这篇文章帮到了你,或者让你觉得"这事儿我也能干",那我的目的就达到了。
欢迎评论区交流,
也欢迎点个赞让我知道你来过。