起因
去年过年的时候,我忘了给一个长辈发生日祝福。不是不想发,是真的忘了。我妈提醒我的时候已经过了两天,场面一度很尴尬。
后来我想,能不能做个工具自动提醒我?上网搜了一圈,发现要么是手机日历(不支持农历),要么是付费 App(一年几十块),要么是那种企业级的日程管理系统(杀鸡用牛刀)。我家里有台 NAS,跑着飞牛OS,上面已经有好几个 Docker 容器了。如果能做一个 Docker 应用,一键部署到 NAS 上,岂不是完美?
就这样,TimeMark 诞生了。这是我做完博客之后的第二个项目,也是我第一次接触 Docker 开发。
TimeMark 是什么
简单说,TimeMark 是一个智能事件提醒系统。你把家人朋友的生日、纪念日录进去,它会在指定时间通过微信、QQ、钉钉、飞书、邮件、Bark 等渠道自动发通知给你。
听起来不复杂对吧?但有几个点让它变得有意思:
- 农历支持。我奶奶的生日是农历的,每年对应的公历日期都不一样,还要处理闰月的情况。
- NAS 友好。专门为家用 NAS 设计,J4125、N5105 这种低功耗处理器也能跑。
- 一键部署。三行命令搞定,不需要懂代码。
目前支持飞牛OS、群晖、威联通、铁威马这几个主流 NAS 系统。
技术选型:为什么选这些
做博客的时候我用了 Next.js + Tailwind CSS,算是入了前端的门。TimeMark 是全栈项目,前后端都要写,所以技术选型花了不少心思。
后端框架选了 Hono。 那段时间刷 GitHub Trending,发现 Hono 经常上榜。点进去一看:轻量、快、TypeScript 原生支持、API 设计很优雅。试了一下,确实比 Express 写起来舒服。而且 Hono 的中间件机制很灵活,做鉴权、日志这些很方便。
数据库选了 sql.js(SQLite)。 这个选择其实是踩了坑之后才做的,后面会讲。最终选 sql.js 的原因很简单:零依赖,数据就是一个文件,NAS 用户备份的时候直接拷贝就行。做博客的时候我就学到了一个道理:能简单就别复杂。
前端还是 React + TailwindCSS。 熟悉嘛,博客项目打下的基础直接用上了。
项目结构用了 pnpm workspace monorepo。 前端和后端放在一个仓库里,共享 TypeScript 类型定义。比如一个 Event 类型,前端后端用的是同一份定义,改一处两边都生效。类型安全这东西,用过就回不去了。
架构设计参考了不少开源项目,Docker 最佳实践也翻了官方文档好几遍。
架构演进:从过度设计到大道至简
v1.x 时代:三个容器的「豪华配置」
第一版的 TimeMark,我用了 PostgreSQL 做数据库,Redis + Bull 做任务队列,加上应用本身,一共三个 Docker 容器。
为什么这么设计?因为我看的教程和开源项目都是这么搞的。PostgreSQL 是「正经」数据库,Redis 做缓存和消息队列是「业界标准」,Bull 是 Node.js 生态里最流行的任务调度库。一切看起来都很「专业」。
然后 NAS 用户开始反馈了。
"这玩意吃了我 800MB 内存,我 NAS 一共才 4GB。" "J4125 跑你这个 CPU 占用 30%,我还跑着其他服务呢。" "就一个提醒工具,为什么要装 PostgreSQL?"
说实话,他们说得对。一个生日提醒系统,数据量撑死几百条,用 PostgreSQL 确实是大炮打蚊子。Redis 就更离谱了,我用它只是为了跑定时任务,完全可以用更轻量的方案替代。
v2.0 重写:砍掉一切不必要的
想明白之后,我花了两周重写了整个后端:
- PostgreSQL → sql.js。SQLite 跑在 WASM 里,不需要单独的数据库容器。数据就是一个
.db文件。 - Redis + Bull → Croner。Croner 是一个纯 JavaScript 的 cron 调度器,几十 KB 大小,功能完全够用。
- 三容器 → 单容器。只需要一个 Docker 容器,内存占用从 800MB 降到 256MB,减少了 70%。
这次重写让我真正理解了一句话:不要为了用技术而用技术。 选型的时候应该先想清楚需求是什么,而不是什么流行就用什么。一个面向家庭用户的小工具,简单可靠比「架构优雅」重要一万倍。
踩过的坑
做这个项目踩的坑比做博客多得多。挑几个印象最深的说说。
Docker Hub 用户名:连改了 6 次
第一次往 Docker Hub 发布镜像,我以为随便填个用户名就行。结果 push 的时候一直报错,说找不到仓库。
折腾了半天才搞明白:Docker Hub 的镜像名格式是 用户名/镜像名,这个用户名必须和你的 Docker Hub 账号完全一致。我一开始注册的时候手滑打错了,后来又改了好几次。翻 git log 能看到连续 6 个 commit 都在改这个用户名:wfffff666、xxxxxf666、xfffff666……
现在回头看那段 git 历史,挺好笑的。但当时真的很崩溃,因为 CI/CD 流水线一直跑不通,每次改完都要等 GitHub Actions 重新构建。
最终确定的镜像地址是 xfffff666/timemark:latest。
SQL 方言不兼容:PostgreSQL 到 SQLite 的迁移噩梦
v2.0 把数据库从 PostgreSQL 换成 sql.js 的时候,我天真地以为改个连接字符串就行了。
大错特错。
PostgreSQL 和 SQLite 的 SQL 方言差异比我想象的大得多。TO_CHAR() 函数在 SQLite 里根本不存在,日期格式化要用 strftime()。PostgreSQL 的布尔值是 true/false,SQLite 里是 1/0。Date 对象的处理方式也完全不同。
我不得不把所有涉及日期的查询全部重写。光这一项就改了好几天,提交了十几个 commit。有些 bug 还很隐蔽,比如农历转换后的日期比较逻辑,在 PostgreSQL 里跑得好好的,换到 SQLite 就出错了,因为两边的日期排序规则不一样。
教训:换数据库不是换个驱动那么简单。SQL 看起来是「标准」的,但每个数据库都有自己的方言和怪癖。
crypto.randomUUID 在 NAS 上不工作
这个 bug 让我排查了很久。本地开发一切正常,部署到 NAS 上之后,用户注册和登录功能直接崩了。
报错信息是 crypto.randomUUID is not a function。
查了一圈才知道:crypto.randomUUID() 需要「安全上下文」(Secure Context),也就是 HTTPS 环境。但 NAS 用户大多是通过局域网 HTTP 访问的,没有 HTTPS。
解决方案是加了一个 fallback:
function generateUUID(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID()
}
// fallback: 手动拼接 UUID v4
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}这种「本地没问题,线上就炸」的 bug 是最烦人的。以后写代码得多想想目标用户的实际使用环境。
时区问题:经典的差 8 小时
做 Web 开发的人大概都会遇到时区问题,我也没逃过。
用户反馈说登录时间显示不对,明明是下午三点登录的,系统显示的是早上七点。差了刚好 8 小时。
原因很简单:服务端存的是 UTC 时间,前端展示的时候没有做时区转换。中国是 UTC+8,所以差了 8 小时。
听起来很好修对吧?但实际上我前前后后改了好几次才彻底修好。因为时区问题不只出现在登录时间,事件提醒的触发时间、农历日期的计算、cron 表达式的解析……到处都有时区的影子。每修一个地方就发现另一个地方也有问题。
最后我统一了策略:服务端全部用 UTC 存储和计算,只在返回给前端的时候转换成 Asia/Shanghai。
Docker 容器里的 DNS 解析失败
有用户反馈说通知发不出去,看日志发现是 DNS 解析失败。容器里 ping 任何域名都不通。
这是国内网络环境的老问题了。Docker 默认用的 DNS 是 8.8.8.8(Google DNS),在国内很多网络环境下不稳定。
解决方案是在 docker-compose.yml 里加上国内 DNS:
dns:
- 223.5.5.5 # 阿里 DNS
- 8.8.8.8 # Google DNS(备用)223.5.5.5 是阿里云的公共 DNS,国内解析速度快、稳定性好。加上之后问题就解决了。
几个有意思的功能
TimeMark 的功能不少,挑几个我觉得有意思的说说。
农历支持是最费心思的。我用了 lunar-javascript 这个库来做农历和公历的互转。农历有闰月,比如闰四月,这种情况下同一个农历生日在公历上的日期会跳来跳去。我写了一套逻辑来处理这些边界情况,测试用例写了一大堆。
通知渠道目前支持 35 个以上,覆盖了微信(Server 酱、PushPlus)、QQ、Telegram、钉钉、飞书、邮件、Bark、Gotify 等等。这些渠道的 API 各不相同,我抽象了一个统一的通知接口,新增渠道只需要实现一个 adapter 就行。
安全方面也下了功夫。JWT 做身份认证,bcrypt 做密码哈希,AES-256 加密存储第三方通知渠道的密钥(比如邮箱密码、API Token),还有登录失败锁定机制防暴力破解。虽然是个小工具,但跑在 NAS 上,安全不能马虎。
还有个小功能我挺喜欢的:智能关系映射。你输入「我爸」,系统会自动识别成「父亲」这个关系类型,方便后续分类和展示。
部署方式
说了这么多,怎么用呢?
最快的方式:三行命令
mkdir timemark && cd timemark
curl -sSL https://raw.githubusercontent.com/WXFffff666/timemark-docker/master/docker-compose.dockerhub.yml -o docker-compose.yml
docker compose up -d完整的 docker-compose.yml
如果你想手动创建配置文件,内容就这么多:
# TimeMark 一键部署 (Docker Hub)
# 使用: docker compose up -d
services:
app:
image: xfffff666/timemark:latest
container_name: timemark-app
restart: unless-stopped
ports:
- '3000:3000'
environment:
TZ: Asia/Shanghai
dns:
- 223.5.5.5
- 8.8.8.8
volumes:
- ./data:/app/data就这 18 行,没了。不需要配置数据库,不需要配置 Redis,不需要设置环境变量。docker compose up -d 之后访问 http://你的NAS的IP:3000 就能用了。
几个部署细节
端口冲突? 如果 3000 端口被占了,改成别的就行:
ports:
- '8080:3000' # 改成 8080 或者任何空闲端口数据在哪? 所有数据存在 ./data/timemark.db 这个 SQLite 文件里。备份的时候直接拷贝这个文件就行:
cp ./data/timemark.db ./data/timemark.db.bakDNS 为什么要配? 国内网络环境下,Docker 默认的 DNS(8.8.8.8)经常解析失败。加上 223.5.5.5(阿里 DNS)就稳了。这也是我踩坑踩出来的经验。
公网部署? 如果要放到公网服务器上,建议自定义 JWT_SECRET 和 MASTER_KEY:
environment:
TZ: Asia/Shanghai
JWT_SECRET: 你自己生成一个随机字符串
MASTER_KEY: 再生成一个随机字符串镜像地址
| 镜像源 | 地址 | 说明 |
|---|---|---|
| Docker Hub(推荐) | xfffff666/timemark:latest | 无需登录,直接拉取 |
| GHCR | ghcr.io/wfffff666/timemark:latest | 需要 GitHub 登录 |
飞牛OS、群晖、威联通、铁威马都能用。只要你的 NAS 支持 Docker,就没问题。
v2.1.0:继续打磨
v2.0 发布之后,我以为可以告一段落了。结果用了几天就发现还有很多可以改进的地方。
8 个新通知渠道
v2.0 的时候支持 27 个通知渠道,已经不少了。但我发现国内用户最常用的几个推送服务还没支持:Server 酱、PushPlus、Bark 这些。
于是我又加了 8 个新渠道:
| 渠道 | 说明 |
|---|---|
| 🦞 微信龙虾 (ClawBot) | 直接推送到个人微信 |
| 📡 Server 酱 | 微信推送服务 |
| 📮 PushPlus | 多渠道推送 |
| 🔔 Bark | iOS 推送通知 |
| 📢 Gotify | 自托管推送 |
| 🐱 喵推送 (Meow) | 鸿蒙系统推送 |
| 📲 PushMe | 多平台推送 |
| 💼 企业微信应用 | 企微应用消息 |
加完之后渠道总数到了 38+。因为之前设计了统一的通知接口,新增渠道只需要实现一个 adapter,所以加起来还挺快的。每个渠道大概半天就能搞定,主要时间花在看各家的 API 文档上。
顺便还把邮件渠道做了分类重组,支持多账号选择了。比如你可以配置多个邮箱,创建事件的时候选择用哪个邮箱发通知。
零配置即开即用
v2.0 的时候,虽然已经是单容器了,但还是需要配置 JWT_SECRET 和 MASTER_KEY 这两个环境变量。有用户反馈说不知道怎么生成随机字符串,觉得麻烦。
想了想,确实没必要强制配置。对于家庭内网使用的场景,内置一个默认密钥就够了。于是我给所有环境变量都加了内置默认值:
JWT_SECRET和MASTER_KEY有内置默认值- 默认管理员账号
admin/TimeMark@2026 docker compose up -d之后直接就能用,不需要改任何配置
当然,如果是公网部署,还是建议自定义密钥。但对于大多数 NAS 用户来说,零配置才是最友好的。
登录锁定升级
v2.0 的登录锁定是固定的:连续 5 次失败,锁定 15 分钟。但这样有个问题——攻击者等 15 分钟就能继续尝试。
v2.1.0 改成了线性叠加:第一次锁定 5 分钟,第二次 10 分钟,第三次 15 分钟……每次锁定时间递增。这样暴力破解的成本会越来越高。
CI 构建修复
加了 ClawBot(微信龙虾)渠道之后,CI 构建突然挂了。排查了一下,发现是两个问题:
- ClawBot 依赖
nodemailer,但没有加到package.json里。本地开发的时候因为node_modules里有缓存所以没问题,CI 环境是干净安装就报错了。 - ClawBot 的类型定义有问题,TypeScript 编译不过。
修了这两个问题之后 CI 就通了。这种「本地没问题,CI 就炸」的情况我在做博客的时候也遇到过,算是老朋友了。
这个项目教会我的
回头看,TimeMark 教会我的东西比博客项目多得多。
全栈开发的完整链路。 从前端 UI 到后端 API,从数据库设计到 Docker 打包,从 CI/CD 到镜像发布。每一环都自己来,虽然累,但对整个开发流程的理解深了很多。
Docker 多阶段构建。 为了减小镜像体积,我学了 multi-stage build。第一阶段用 Node.js 编译 TypeScript,第二阶段只拷贝编译产物和运行时依赖。最终镜像从 1GB+ 压缩到了几百 MB。
安全意识。 以前写代码不太在意安全,觉得「又没人攻击我」。但 TimeMark 要存用户的通知渠道密钥,这些东西泄露了是真的会出问题的。所以我认真学了加密、鉴权、防暴力破解这些知识。
架构重构的勇气。 v1.x 到 v2.0 的重写,等于把后端推翻重来。当时很纠结,毕竟 v1.x 已经能用了。但用户反馈摆在那里,不改不行。这次经历让我明白:代码不是写完就结束了,根据反馈持续改进才是常态。
CI/CD 自动化。 用 GitHub Actions 实现了自动构建和推送 Docker 镜像。每次 push 代码,Actions 会自动编译、打包、推送到 Docker Hub。再也不用手动 docker build 然后 docker push 了。
用户反馈的价值。 如果不是 NAS 用户告诉我「太重了」,我可能永远不会去做 v2.0 的重写。闭门造车做出来的东西,和真实用户需要的东西,往往差距很大。
写在最后
从博客到 TimeMark,每个项目都让我成长了一截。博客教会我前端开发和部署,TimeMark 教会我全栈开发和 Docker。
从 v1.x 到 v2.0 再到 v2.1.0,每次迭代都是在用户反馈的基础上打磨。这个项目不会「完成」,它会一直跟着需求进化。
下一个项目会是什么?还不确定。但我知道,只要一直做下去,一直学下去,总会越来越好的。
如果你也是学生,也想做点什么,别犹豫,直接开始。第一版肯定很烂,没关系。我的第一版也很烂,三个容器吃 800MB 内存那种烂。但做着做着,就会越来越好。
继续写代码,继续踩坑,继续成长。


