在个人记账场景中,很多人并不需要复杂的ERP或云服务,只用到一个能本地运行、数据自主掌控的小工具。Golang凭借静态编译、单文件部署和标准库自带SQLite驱动支持的特性,非常适合用来快速搭建一个简单财务记录系统。下面我们从数据模型设计、存储层实现以及交互入口三个层面,逐步拆解这套系统的开发方式。

一、定义核心数据结构与业务模型
财务记录系统的本质是对交易流水进行建模。在Golang中,我们首先用结构体描述一条账目记录,它至少应包含唯一编号、交易类型、金额、分类、备注以及发生时间。将金额定义为int64而非float64,是以分为单位存储来规避浮点误差,这是金融类系统的基础常识。交易类型可以用自定义字符串常量区分收入与支出,方便后续统计时做正负号处理。
除了单条记录,我们还需要一个汇总视图模型。比如月度报表结构体,包含月份字符串、总收入、总支出和结余。通过在代码层面对切片做遍历累加,或者直接在SQL中使用SUM配合GROUP BY,都能得到结果。清晰的结构体定义让上层逻辑与数据库表结构一一对应,降低维护成本。
下面给出基础模型代码示例,其中包含必要的校验方法,确保写入数据库前金额不为负、类型合法。这种在领域层做前置检查的做法,比依赖数据库约束更直观,也更容易返回友好的错误提示给调用方。
package model
import (
"errors"
"time"
)
type TxType string
const (
Income TxType = "income"
Expense TxType = "expense"
)
type Transaction struct {
ID int64 `json:"id"`
Type TxType `json:"type"`
Amount int64 `json:"amount"` // 单位:分
Category string `json:"category"`
Note string `json:"note"`
CreatedAt time.Time `json:"created_at"`
}
func (t *Transaction) Validate() error {
if t.Amount <= 0 {
return errors.New("金额必须大于零")
}
if t.Type != Income && t.Type != Expense {
return errors.New("交易类型不合法")
}
if t.Category == "" {
return errors.New("分类不能为空")
}
return nil
}
二、基于SQLite的存储层实现
选择SQLite作为存储引擎,是因为它无需独立进程,数据落在单个文件,非常契合个人财务工具。Golang标准库未内置SQLite,但可使用mattn/go-sqlite3驱动,通过database/sql接口操作。初始化时执行建表语句,设置主键自增与时间索引,能提升按日期查询的效率。
存储层应提供Add、ListByMonth、Delete等函数。Add前调用模型的Validate,失败则回滚;ListByMonth利用WHERE strftime('%Y-%m', created_at) = ?过滤。为防止SQL注入,所有参数均使用占位符传递。下面的代码展示了连接与插入的核心逻辑,注意驱动名sqlite3在导入时需加下划线匿名引用。
在实际项目中,建议把*sql.DB作为结构体字段封装成Repository,而非全局变量,这样便于在测试时替换为内存库。同时,定期执行VACUUM可减小文件体积,虽然对个人记账影响不大,但能培养良好的运维习惯。
package store
import (
"database/sql"
"model"
_ "github.com/mattn/go-sqlite3"
)
type Repo struct {
db *sql.DB
}
func NewRepo(path string) (*Repo, error) {
db, err := sql.Open("sqlite3", path)
if err != nil {
return nil, err
}
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS tx (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT,
amount INTEGER,
category TEXT,
note TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`)
if err != nil {
return nil, err
}
return &Repo{db: db}, nil
}
func (r *Repo) Add(t *model.Transaction) error {
if err := t.Validate(); err != nil {
return err
}
_, err := r.db.Exec(
"INSERT INTO tx(type, amount, category, note, created_at) VALUES(?,?,?,?,?)",
t.Type, t.Amount, t.Category, t.Note, t.CreatedAt,
)
return err
}
三、命令行与HTTP两种交互入口
系统写好存储层后,需要入口供用户操作。最轻量的方式是命令行,用flag包解析子命令,如add、list、del。命令行适合习惯终端的用户,启动快且能写脚本批量导入银行流水CSV。我们在main函数中根据参数调用Repo方法,并用fmt打印表格化结果。
若希望用图形界面查看,可用net/http起一个本地服务,提供/add接口接收JSON,再用html/template生成账目页面。这样手机连同一局域网也能访问。注意在<form>提交时使用POST方法,后端用json.NewDecoder读取body。下面的片段演示了如何暴露添加接口,其中code标签常用于行内标注函数名如http.HandleFunc。
无论哪种入口,都要对用户输入做长度限制与字符过滤,防止备注字段存储恶意脚本。虽然本地工具风险低,但养成校验习惯能避免日后代码复用至公网时留坑。最后编译时用go build -ldflags="-s -w"可减小二进制体积,拷贝到任何机器均能直接运行。
package main
import (
"encoding/json"
"net/http"
"store"
"model"
"time"
)
func main() {
repo, _ := store.NewRepo("finance.db")
http.HandleFunc("/add", func(w http.ResponseWriter, r *http.Request) {
var t model.Transaction
json.NewDecoder(r.Body).Decode(&t)
t.CreatedAt = time.Now()
if err := repo.Add(&t); err != nil {
http.Error(w, err.Error(), 400)
return
}
w.Write([]byte("ok"))
})
http.ListenAndServe(":8080", nil)
}