news 2026/10/12 5:50:43

用Go从零写一个命令行记账工具:SQLite存储与CSV导出

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用Go从零写一个命令行记账工具:SQLite存储与CSV导出

1. 为什么我最终选择用 Go 来写一个记账工具

很多人第一次听到“用 Go 写记账工具”都会觉得有点小题大做——记账嘛,Excel 拉个表格,或者手机上下个现成的 App 不就完了?我一开始也是这么想的。但用了几年各种记账软件之后,我发现自己陷入了一个很尴尬的循环:下载、记三天、嫌麻烦、卸载、过两个月再下载。问题不在于我懒,而在于那些工具要么太重(动不动就要注册账号、同步云端、推送广告),要么太死板(分类固定、导出困难、数据不在自己手里)。

后来我决定自己写一个。需求很朴素:命令行里敲一行就能记一笔,数据存本地,随时能导出成 CSV,想加什么统计维度自己改代码就行。选 Go 的原因也很直接——编译出来就是一个二进制文件,扔到任何机器上都能跑,不用装运行时;标准库自带 SQLite 驱动之外的很多东西,文件操作、时间处理、字符串格式化都很顺手;而且 Go 的语法干净,一个周末就能把核心功能写完。

这个工具解决的核心问题其实就三个:记录要快(不能比打开手机 App 还慢)、数据要可控(存在自己硬盘上,格式自己定)、扩展要容易(想加个“按周统计”或者“预算提醒”不用求人)。适合谁来参考呢?如果你有一点编程基础,想找一个练手项目顺便解决自己的实际问题,Go 写记账工具是个非常好的切入点;如果你完全不懂代码,也可以看看思路,理解一个记账系统底层是怎么运转的。

我把它命名为ledger,整个项目大概 800 行代码,包含记录收支、查询流水、月度统计、导出 CSV 四个核心功能。下面我会把设计思路、关键代码、踩过的坑和后续扩展方向都摊开讲,你照着做就能跑起来一个属于自己的记账工具。

2. 数据模型设计:一笔账到底该存哪些字段

2.1 从“记一笔”这个动作反推字段需求

在动手写代码之前,我先拿纸笔画了一下“记一笔账”这个动作到底涉及哪些信息。假设我今天中午吃饭花了 35 块,用微信付的,属于“餐饮”类别,我想在记录的时候顺便写一句“公司楼下快餐”。那么这笔账至少包含:金额、方向(收入还是支出)、类别、支付方式、备注、时间。

这里有个容易忽略的点:方向和金额的关系。很多记账工具把支出存成负数、收入存成正数,这样求和的时候直接相加就行。我一开始也这么设计,但后来发现统计的时候很别扭——比如我想算“总支出”,还得先过滤出负数再取绝对值。所以我把方向单独拆成一个字段type,取值只有income和expense,金额永远存正数。这样查询逻辑更清晰,统计时直接WHERE type = 'expense'就行。

另一个细节是时间。记账的时间到底用“记录时间”还是“发生时间”?我选择让用户显式指定,默认取当前时间。因为有时候晚上补记中午的账,如果直接用time.Now(),统计月度数据时就会归到错误的月份。Go 的time.Time类型存进 SQLite 的时候,我统一用 RFC3339 格式的字符串,这样可读性好,导出 CSV 也方便。

2.2 表结构设计与 SQLite 的选型理由

最终的表结构长这样:

CREATE TABLE IF NOT EXISTS records ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL CHECK(type IN ('income', 'expense')), amount REAL NOT NULL CHECK(amount > 0), category TEXT NOT NULL, method TEXT NOT NULL DEFAULT 'cash', note TEXT DEFAULT '', happened_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) );

为什么用 SQLite 而不是直接写 JSON 文件?我试过 JSON 方案,记录少的时候没问题,但一旦超过几百条,每次查询都要把整个文件读进内存再遍历,而且并发写入容易把文件写坏。SQLite 是单文件数据库,不需要额外服务,Go 里用database/sql配合modernc.org/sqlite这个纯 Go 驱动就能跑,连 CGO 都不用开。实测下来,一万条记录的查询响应在毫秒级,完全够个人使用。

category和method这两个字段我没有做成外键关联的独立表,因为个人记账场景下类别就那么十几种,硬编码在代码里反而更灵活。如果你想要更规范的设计,可以拆成categories和methods两张表,但会增加不少代码量,我权衡之后觉得没必要。

注意:amount用REAL类型存储浮点数,在极端情况下会有精度问题。如果你记的金额都是两位小数,可以在写入前用math.Round(amount*100)/100处理一下。更严谨的做法是用整数存“分”,但那样每次显示都要除以 100,我嫌麻烦就没这么做。

2.3 Go 结构体与数据库的映射

在 Go 代码里,我定义了一个Record结构体来承载数据:

type Record struct { ID int64 Type string Amount float64 Category string Method string Note string HappenedAt time.Time CreatedAt time.Time }

这里有个小技巧:HappenedAt和CreatedAt在数据库里是字符串,但 Go 里用time.Time。扫描的时候需要手动转换,我写了一个辅助函数:

func scanRecord(rows *sql.Rows) (Record, error) { var r Record var happenedAt, createdAt string err := rows.Scan(&r.ID, &r.Type, &r.Amount, &r.Category, &r.Method, &r.Note, &happenedAt, &createdAt) if err != nil { return r, err } r.HappenedAt, _ = time.Parse(time.RFC3339, happenedAt) r.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) return r, nil }

这个函数看起来不起眼,但如果没有它,每次查询都要重复写解析逻辑,很容易漏掉某个字段导致时间显示为零值。我在第一次写的时候就是直接在main里解析,结果加了新查询之后忘了改,统计出来的月份全是 0001 年,排查了半天才发现。

3. 命令行交互的核心逻辑:让记账快过打开手机

3.1 命令设计:add、list、stat、export 四件套

命令行工具好不好用,一半看命令设计。我最终定了四个子命令:

  • ledger add:添加一笔记录,支持-t指定类型、-a金额、-c类别、-m支付方式、-n备注、-d日期
  • ledger list:列出记录,支持按月份、类别过滤,默认显示最近 20 条
  • ledger stat:统计,支持按月、按类别汇总,输出表格
  • ledger export:导出 CSV,支持指定时间范围

为什么不用交互式菜单?因为交互式意味着要等提示、要按方向键选择,速度反而慢。直接一行命令敲完回车,整个过程不到两秒。比如记一笔午餐:

ledger add -t expense -a 35 -c 餐饮 -m 微信 -n "公司楼下快餐"

这条命令我用了几个月,已经形成肌肉记忆了。-t默认是expense,所以实际上可以省略,变成ledger add -a 35 -c 餐饮 -m 微信 -n "公司楼下快餐",更短。

3.2 参数解析:flag 标准库够不够用

Go 标准库的flag包完全能胜任这个场景。我一开始考虑过cobra这种第三方库,但引入之后发现光是初始化命令树就要写几十行,而我的需求就是四个子命令加几个参数,flag足够了。

func runAdd(args []string) { fs := flag.NewFlagSet("add", flag.ExitOnError) t := fs.String("t", "expense", "类型: income 或 expense") a := fs.Float64("a", 0, "金额") c := fs.String("c", "其他", "类别") m := fs.String("m", "现金", "支付方式") n := fs.String("n", "", "备注") d := fs.String("d", "", "日期, 格式 2006-01-02, 默认今天") fs.Parse(args) if *a <= 0 { fmt.Println("金额必须大于 0") return } // ... 后续写入逻辑 }

这里有个坑:flag包在解析遇到未知参数时会直接报错退出,如果你想让工具更友好,可以捕获错误并给出提示。另外fs.Parse(args)里的args是os.Args[2:],因为os.Args[0]是程序名,os.Args[1]是子命令名。

3.3 日期处理的那些坑

日期处理是我踩坑最多的地方。用户输入的日期格式是2025-01-15,但 Go 的time.Parse要求你提供一个“参考时间”作为格式模板,也就是2006-01-02。这个设计一开始让我很困惑,后来才明白 Go 用的是“用例子来定义格式”的思路,而不是%Y-%m-%d这种占位符。

func parseDate(s string) (time.Time, error) { if s == "" { return time.Now(), nil } t, err := time.ParseInLocation("2006-01-02", s, time.Local) if err != nil { return time.Time{}, fmt.Errorf("日期格式错误,应为 2006-01-02: %w", err) } return t, nil }

注意这里用的是ParseInLocation而不是Parse。区别在于Parse默认用 UTC 时区,如果你在东八区晚上 8 点之后记一笔当天的账,用Parse解析出来的时间会变成前一天。我一开始就用了Parse,结果发现晚上记的账全跑到前一天去了,查了半天才定位到时区问题。

提示:所有涉及用户输入日期的地方,一律用time.ParseInLocation并传入time.Local。这个坑不踩一次很难记住。

4. 统计与导出:让数据真正产生价值

4.1 月度统计的 SQL 怎么写才高效

记账工具最有价值的功能就是统计。我实现了两个维度的统计:按月汇总收支和按类别汇总支出。

按月汇总的 SQL 是这样的:

SELECT strftime('%Y-%m', happened_at) AS month, SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) AS total_income, SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END) AS total_expense FROM records GROUP BY month ORDER BY month DESC LIMIT 12;

这里用了strftime函数从 RFC3339 字符串里提取年月。SQLite 的strftime支持%Y-%m这种格式,直接就能按月份分组。CASE WHEN的写法比跑两次查询再合并要高效得多,一次扫描就能算出收入和支出两个值。

按类别汇总更简单:

SELECT category, SUM(amount) AS total FROM records WHERE type = 'expense' AND happened_at >= ? AND happened_at < ? GROUP BY category ORDER BY total DESC;

传入月份的第一天和最后一天作为范围参数。这里有个细节:happened_at存的是 RFC3339 格式,比如2025-01-15T12:30:00+08:00,字符串比较是按字典序进行的,而 RFC3339 的字典序恰好和时间顺序一致,所以直接用>=和<比较字符串就能正确过滤时间范围。这是 RFC3339 格式的一个隐藏优势。

4.2 输出格式化:表格对齐与颜色标记

命令行工具的输出如果乱七八糟,用起来会很糟心。我用了text/tabwriter包来做表格对齐:

w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "日期\t类型\t金额\t类别\t备注") for _, r := range records { fmt.Fprintf(w, "%s\t%s\t%.2f\t%s\t%s\n", r.HappenedAt.Format("01-02"), typeLabel(r.Type), r.Amount, r.Category, r.Note, ) } w.Flush()

tabwriter会自动根据内容宽度对齐各列,输出效果很整齐。typeLabel函数把income和expense转成“收入”和“支出”,顺便用 ANSI 转义码加上颜色——收入绿色、支出红色。不过颜色在重定向到文件时会变成乱码,所以我加了一个判断:如果os.Stdout不是终端,就不加颜色。

func typeLabel(t string) string { if t == "income" { return colorize("收入", "32") // 绿色 } return colorize("支出", "31") // 红色 } func colorize(s, code string) string { if !isTerminal() { return s } return fmt.Sprintf("\x1b[%sm%s\x1b[0m", code, s) }

isTerminal的实现依赖golang.org/x/term包,或者简单点用os.Stdout.Stat()判断文件模式。这个细节虽然小,但导出 CSV 的时候如果混进颜色码,Excel 打开就是一堆乱码,所以必须处理。

4.3 CSV 导出的编码问题

导出 CSV 看起来简单,但中文用户会遇到一个经典问题:Excel 默认用 GBK 编码打开 CSV,而 Go 写出来的是 UTF-8,直接打开会乱码。解决方案是在文件开头写入 UTF-8 BOM(字节序标记):

func exportCSV(records []Record, filename string) error { f, err := os.Create(filename) if err != nil { return err } defer f.Close() // 写入 UTF-8 BOM,让 Excel 正确识别编码 f.Write([]byte{0xEF, 0xBB, 0xBF}) w := csv.NewWriter(f) w.Write([]string{"日期", "类型", "金额", "类别", "支付方式", "备注"}) for _, r := range records { w.Write([]string{ r.HappenedAt.Format("2006-01-02 15:04"), typeLabelPlain(r.Type), fmt.Sprintf("%.2f", r.Amount), r.Category, r.Method, r.Note, }) } w.Flush() return w.Error() }

注意这里用的是typeLabelPlain而不是带颜色的typeLabel,因为 CSV 里不能有 ANSI 转义码。这个 BOM 技巧适用于所有需要 Excel 打开的中文 CSV 文件,我后来在其他项目里也反复用到。

5. 实际使用中暴露的问题与修复过程

5.1 并发写入导致的 database is locked

这个坑是在我写了一个批量导入脚本之后出现的。当时我想把之前记在备忘录里的几十笔账一次性导进去,写了个循环快速调用add函数,结果频繁报database is locked。原因是 SQLite 默认的日志模式是DELETE,每次写入都会锁住整个数据库文件,而我的导入脚本没有等上一次写入完成就发起下一次。

解决方案有两个:一是开启 WAL(Write-Ahead Logging)模式,允许多个读和一个写并发;二是在连接字符串里加上_busy_timeout参数,让 SQLite 在遇到锁时等待而不是立即报错。

db, err := sql.Open("sqlite", "ledger.db?_journal_mode=WAL&_busy_timeout=5000")

_busy_timeout=5000表示遇到锁时最多等 5 秒。加上这两个参数之后,批量导入再也没报过错。WAL 模式还会生成ledger.db-wal和ledger.db-shm两个辅助文件,这是正常的,不要手动删。

注意:如果你把数据库文件放在同步盘里(比如某些云盘),WAL 模式可能会导致同步冲突。个人使用建议放在本地硬盘的固定目录,比如~/ledger/。

5.2 金额输入为负数时的静默错误

有一次我手快,输入了-a -50,想表达“支出 50”,结果程序直接把 -50 存进去了。因为我的校验只检查了*a <= 0,而 -50 确实小于等于 0,所以被拦截了——但拦截之后没有任何提示,命令直接退出,我以为记录成功了,查的时候才发现没有。

修复方案是加一个明确的错误提示,并且支持用户输入负数时自动取绝对值并推断类型:

if *a == 0 { fmt.Println("错误:金额不能为 0") os.Exit(1) } if *a < 0 { *a = -*a *t = "expense" fmt.Println("提示:检测到负数金额,已自动记为支出") }

这个改动看起来很小,但体验提升很大。后来我还加了金额上限校验,超过 100 万的记录会提示确认,防止手滑多按几个零。

5.3 月份统计跨年时的排序错乱

前面按月统计的 SQL 用了ORDER BY month DESC,month是2025-01这种格式。用了几个月之后,到了 2026 年 1 月,我发现统计结果里 2026-01 排在了 2025-12 前面,这是对的。但再往前翻,2025-01 和 2024-12 的顺序也没问题。真正的问题出在LIMIT 12上——它只取最近 12 个月,但如果某个月没有任何记录,那个月就不会出现在结果里,导致“最近 12 个月”实际上跨越了更长的真实时间范围。

比如 2025 年 3 月我一笔账都没记,那么统计结果里就没有 2025-03 这一行,LIMIT 12会一直往前取到 2024 年更早的月份。这本身不算 bug,但如果你想看“最近一年每个月的趋势”,缺失的月份会让图表断档。

修复方式是在 Go 代码里补全缺失的月份:

func fillMissingMonths(rows []MonthStat, months int) []MonthStat { now := time.Now() result := make([]MonthStat, 0, months) for i := 0; i < months; i++ { m := now.AddDate(0, -i, 0).Format("2006-01") found := false for _, r := range rows { if r.Month == m { result = append(result, r) found = true break } } if !found { result = append(result, MonthStat{Month: m}) } } return result }

这段代码从当前月往前推 12 个月,逐个检查是否有数据,没有就补一个零值。这样输出的表格永远是 12 行,趋势一目了然。

6. 后续可以继续打磨的几个方向

6.1 预算功能:从“记录”到“控制”

现在的工具只能事后看账,不能事前控制。我下一步想加一个预算功能:每月初设定各类别的预算上限,每次add的时候自动检查是否超支,超了就给出警告。实现思路是新建一张budgets表,存category、month、limit_amount三个字段,然后在add之后触发一次查询:

SELECT COALESCE(SUM(amount), 0) FROM records WHERE type = 'expense' AND category = ? AND strftime('%Y-%m', happened_at) = ?;

拿这个结果和预算上限比较,超过 80% 给黄色提示,超过 100% 给红色警告。这个功能不复杂,但需要想清楚“超支之后是阻止记录还是仅提醒”——我倾向于仅提醒,因为阻止记录会导致数据不完整,反而影响后续统计。

6.2 定期账单的自动记录

房租、水电费、订阅服务这类固定支出,每个月都要手动记很烦。可以加一个recurring子命令,定义“每月 1 号自动记一笔 3000 元房租”。实现方式有两种:一是每次启动程序时检查有没有到期的定期账单,有就自动补记;二是用系统的定时任务调用ledger add。我倾向于第一种,因为不依赖外部配置,跨平台也更方便。

具体做法是在数据库里加一张recurring_rules表,存type、amount、category、day_of_month、last_triggered等字段。每次程序启动时扫描一遍,如果当前日期超过了day_of_month且last_triggered不是本月,就自动插入一条记录并更新last_triggered。

6.3 数据可视化:终端里的简易图表

纯文字统计看多了会疲劳,我想在stat命令里加一个简单的柱状图,用 Unicode 方块字符画出来:

餐饮 ████████████████ 1,280 交通 ██████ 480 购物 ██████████ 800

实现起来就是按比例把金额映射成方块数量,每 100 元一个方块,最多 30 个。这个功能代码量不大,但视觉效果提升明显。如果你想要更专业的图表,可以导出 CSV 之后用其他工具画,但在终端里快速看一眼,这种简易图表足够了。

6.4 多币种支持的取舍

有朋友问我能不能记美元、日元的账。技术上不难,加一个currency字段就行,但统计的时候就需要汇率换算,而汇率是实时变动的,存历史汇率还是用当前汇率?这个问题没有标准答案。我的建议是:如果你只有偶尔的外币支出,直接在备注里写“美元”就行,金额按当时汇率折算成人民币记录;如果你经常用多种货币,那可能需要一个更完整的方案,比如每笔记录都存原币金额、币种、以及折算后的人民币金额。后者会让表结构复杂不少,我目前还没做,因为个人使用场景下需求不强。

我在实际使用这个工具半年多之后,最大的体会是:记账工具的价值不在于功能多全,而在于你愿不愿意每天用它。命令行方案之所以适合我,就是因为它足够快、足够轻,没有任何干扰。如果你也想自己动手写一个,建议先从add和list两个命令做起,跑通了再慢慢加统计和导出。不要一上来就设计复杂的表结构和功能列表,那样很容易在写代码的过程中失去兴趣。先把最核心的“记一笔”做到极致,剩下的功能自然会随着使用需求长出来。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/12 5:50:00

throw/throws 关键字

一、throws 作用throws 写在方法声明处&#xff0c;用于声明该方法有可能抛出某种异常&#xff0c;把异常交给调用这个方法的人去处理。一句话区分&#xff1a;throws 是声明异常&#xff0c;自己不捕获&#xff0c;抛给上层调用者。 和 try-catch 的区别&#xff1a;try-catch…

作者头像 李华
网站建设 2026/10/12 5:49:56

微网共享储能利益分配机制:调度权、收益分成与协议设计实战指南

做微网的人这几年大概率听过“共享储能”这个词。园区里建一块电池&#xff0c;几家企业一起用&#xff0c;投资有人分担&#xff0c;利用率也拉得起来&#xff0c;表面上是一个多方共赢的方案。但等到微网运营商和用户聚合商真正坐下来谈合作&#xff0c;事情就没那么舒服了。…

作者头像 李华
网站建设 2026/10/12 5:48:22

波士顿房价线性回归实战:从数据直觉到模型调优的完整代码顺序

1. 为什么我坚持用波士顿房价数据集练手线性回归很多人入门机器学习时&#xff0c;第一个接触的算法就是线性回归&#xff0c;第一个跑的数据集往往就是波士顿房价。这个组合几乎成了行业里的"Hello World"。但有意思的是&#xff0c;我见过太多人跑完一遍sklearn的L…

作者头像 李华
网站建设 2026/10/12 5:47:45

开源飞控ArduPilot备降点机制与源码实现解析

项目标题很直白&#xff0c;“开源飞控APM备降点源码分析”。你要是玩过多旋翼或者固定翼&#xff0c;肯定遇到过类似场景&#xff1a;电池电压警报响了&#xff0c;飞控自动切返航&#xff0c;结果返航点Home在你出发的地方&#xff0c;但你现在离它两公里远&#xff0c;一路顶…

作者头像 李华
网站建设 2026/10/12 5:46:30

真开源还是伪开源:从爆火团队看代码开放与社区参与的核心逻辑

这几年开源圈有个特别有意思的现象&#xff1a;隔一段时间就会冒出一个团队&#xff0c;因为“真开源”三个字被推到风口浪尖&#xff0c;GitHub 上一夜涨几千星&#xff0c;Issue 区挤满各国语言&#xff0c;连我所在的技术群里都在刷屏讨论。很多不太了解开源生态的朋友会问&…

作者头像 李华