
1. 为什么会动手写一个个人财务管理系统先交代一下背景。记账这件事我断断续续坚持了六年试过随手记、鲨鱼记账、Excel 表格最后都败给了同一个问题——数据不在自己手里。你辛辛苦苦记了两年的流水某天想按餐饮 工作日午餐 外卖这个维度拉一张趋势图发现 App 根本不支持这么细的维度想把三年的账导出来做一次深度复盘导出的 Excel 字段对不上、分类还带一堆口粮。这种受制于人的感觉对一个习惯自己动手的人来说非常别扭。后来我干脆用 Python Django 自己写了一套个人财务管理系统。从最朴素的动账记录开始逐步加上了账户管理、消费分类、月度预算、资金趋势、账单提醒这些模块。整套系统的源码、数据库脚本和说明文档完整保留了下来也就是我这篇博文想拆解的对象——基于 Django 的个人财务管理系统。为什么选 Django因为这种管理类系统本质上就是数据录入 权限隔离 统计查询 定期报表Django 的 ORM、Admin 后台、认证体系、模板引擎刚好全覆盖不需要自己拼轮子。你拿 Flask 写也行但到了用户认证、数据库迁移、后台管理这一步工作量会明显上来。而 Django 从startproject到第一个能记一笔账的页面一个晚上就能跑通。这篇博文适合两类人想用 Django 做一个完整实战项目的初学者以及像我一样对现成记账工具不满意、想拥有一套可定制数据模型的个人用户。我会把系统拆成模块来讲——数据库怎么建模、记账和转账的核心逻辑怎么写、统计报表怎么做、用户数据怎么隔离最后再聊聊部署和那些容易踩的坑。我会尽量把为什么这么设计的思考过程也写出来而不是只贴代码。2. 功能边界与模块设计先想清楚个人财务到底需要什么很多人一上来就急着建表写代码我倒建议先花半小时把需求边界划清楚。个人财务管理系统和企业财务软件是两码事。企业级需要凭证、应收应付、多级审批、固定资产折旧这些对个人记账来说全是负担。个人系统的核心就四件事记录每一笔钱的去向、知道现在还有多少钱、判断钱花得是否合理、提前知道哪些固定支出要来了。2.1 从记账场景反推出来的五个核心模块我把整个系统拆成了五个领域模块每个模块对应一组数据库表和一组视图账户管理现金、银行卡、信用卡、支付宝/微信钱包、储值卡。每笔流水必须挂在某个账户下账户余额由初始余额加上该账户下所有流水的收支盈亏计算得出。账单流水系统的心脏。每一笔记录包含金额、方向收入/支出/转账、分类、账户、交易时间、备注、关联交易 ID。分类管理两级结构大类如餐饮交通居住子类如工作日午餐地铁房租。分类决定了统计的口径一定要在一开始就设计好层级不然后面重新归类会非常痛苦。预算管理按月为某个分类设置预算上限比如餐饮每月 2000 元。月底统计实际支出和上限做对比超了就给出提示。统计报表月度收支总览、分类占比、账户资金分布、月度趋势。这部分不打算在页面里堆图表库先用表格 简单的 CSS 条形图实现后期再考虑引入 ECharts。2.2 单用户系统的特殊考虑因为是个人系统我不需要做复杂的角色权限用户/管理员/财务但依然要保留登录机制。原因很现实这套系统后期很可能部署在一台云服务器上通过浏览器访问如果完全不设登录任何人都能打开页面看到你的收入那就太可怕了。数据隔离用最简单的方式实现所有业务表都带一个user外键查询时统一用当前登录用户过滤。这种做法牺牲了一点扩展性没法支持多个账本共享但对单用户场景来说简单可靠也方便新手理解 Django ORM 里的request.user到底是怎么用的。2.3 为什么保留 Django Admin我几乎在所有 Django 实战项目里都保留 Admin 后台个人财务系统也不例外。虽然我写了自定义的前端页面但 Admin 后台在处理数据修正、分类初始化和查看异常流水时效率比写 SQL 高得多。比如某天导入历史数据时把一笔大额支出的方向搞反了直接在 Admin 里筛选出这笔记录改掉比写一遍update语句更直观。Admin 还有一个隐藏价值它是 Django 框架最好的学习样本。对新学者来说往 Admin 里注册模型、自定义list_display、加搜索框和过滤器就是理解 ORM 查询集QuerySet最直观的入门路径。3. 数据库建模这套系统的地基是怎么搭的数据库设计决定了整个系统的上限。如果表结构设计得不好后面做统计查询时你会到处打补丁。我在设计时参考了复式记账的基本思想但做了简化——不搞凭证和科目只保留最核心的账户 - 流水 - 分类三角关系。3.1 核心表结构与字段选择我用的数据库是 SQLiteDjango 默认配置零成本起步。如果你要部署到公网服务器可以换成 PostgreSQL生产环境我推荐它ORM 迁移基本无缝。以下是最核心的几张表Account 账户表字段类型说明nameCharField账户名称如招商银行储蓄卡account_typeCharField账户类型现金/借记卡/信用卡/电子钱包/储值卡initial_balanceDecimalField初始余额开户时账户里已有的钱iconCharField展示用图标名称可选created_atDateTimeField创建时间这里有一个容易踩坑的点账户余额不要直接存一个字段而是初始余额 累计收支差额。否则每次记账都要去更新 Account 表的余额值一旦出现并发写入或者漏更新账就对不上了。余额通过查询流水实时算出来虽然多一次聚合查询但换来的是数据一致性。Category 分类表字段就三个name分类名、parent自关联外键空表示大类、type收入/支出。为什么要把收入支出分开因为统计消费结构时只看支出分类收入分类是另一套维度工资、理财收益、红包混在一起会让报表很难看。Transaction 流水表这张表是整个系统的核心字段比较多字段类型说明accountForeignKey关联账户categoryForeignKey关联分类转账时置空transaction_typeCharField支出/收入/转账amountDecimalField金额transaction_dateDateField交易日期noteTextField备注related_transactionForeignKey转账关联的另一条流水 IDcreated_atDateTimeField创建时间related_transaction这个字段是转账功能的关键。一笔转账涉及两个账户——钱从 A 账户转出进入 B 账户。我在表里存两条记录一条支出方向挂在 A 账户一条收入方向挂在 B 账户两条记录通过related_transaction互相指向。这样账户余额计算逻辑不用单独特判转账场景统计时又能通过关联字段排除掉重复计算。Budget 预算表按月为分类设置预算上限。字段category、month格式 YYYY-MM、amount。查询时按月分组汇总某分类下的支出再和预算数比较。3.2 Django 模型的完整代码用 Django 的models.py写出来大概是这个样子from django.db import models from django.contrib.auth.models import User class Account(models.Model): ACCOUNT_TYPES [ (cash, 现金), (debit, 储蓄卡), (credit, 信用卡), (e_wallet, 电子钱包), (prepaid, 储值卡), ] user models.ForeignKey(User, on_deletemodels.CASCADE, verbose_name所属用户) name models.CharField(账户名称, max_length50) account_type models.CharField(账户类型, max_length20, choicesACCOUNT_TYPES) initial_balance models.DecimalField(初始余额, max_digits12, decimal_places2, default0) created_at models.DateTimeField(创建时间, auto_now_addTrue) class Meta: verbose_name 账户 verbose_name_plural 账户 def current_balance(self): incomes self.transaction_set.filter( transaction_typeincome ).aggregate(totalmodels.Sum(amount))[total] or 0 expenses self.transaction_set.filter( transaction_typeexpense ).aggregate(totalmodels.Sum(amount))[total] or 0 return self.initial_balance incomes - expenses class Category(models.Model): TYPE_CHOICES [(income, 收入), (expense, 支出)] user models.ForeignKey(User, on_deletemodels.CASCADE, verbose_name所属用户) name models.CharField(分类名称, max_length50) parent models.ForeignKey(self, nullTrue, blankTrue, on_deletemodels.CASCADE, verbose_name父分类) category_type models.CharField(类型, max_length10, choicesTYPE_CHOICES) class Meta: verbose_name 分类 verbose_name_plural 分类 def __str__(self): return self.name class Transaction(models.Model): TYPES [(expense, 支出), (income, 收入), (transfer, 转账)] user models.ForeignKey(User, on_deletemodels.CASCADE, verbose_name所属用户) account models.ForeignKey(Account, on_deletemodels.CASCADE, verbose_name账户) category models.ForeignKey(Category, nullTrue, blankTrue, on_deletemodels.SET_NULL, verbose_name分类) transaction_type models.CharField(类型, max_length10, choicesTYPES) amount models.DecimalField(金额, max_digits12, decimal_places2) transaction_date models.DateField(交易日期) note models.TextField(备注, blankTrue) related_transaction models.ForeignKey(self, nullTrue, blankTrue, on_deletemodels.SET_NULL) created_at models.DateTimeField(创建时间, auto_now_addTrue)账表之间通过外键关联所有业务表都带user外键实现在线数据隔离。这里我特别说明一下category字段的on_deletemodels.SET_NULL删除分类时不应该级联删除历史流水否则你删一个用错的分组几年积累的账单记录全没了。保留流水并让分类置空事后还可以统一重新归类。3.3 索引与查询性能个人系统的数据量不大一年大约几千条流水SQLite 完全扛得住。但如果你打算长期用下去比如积累五六年数据索引就是必须考虑的事。我在Transaction表上加了两个复合索引class Meta: indexes [ models.Index(fields[user, transaction_date]), models.Index(fields[user, account, transaction_date]), ]第一个索引服务按时间范围统计的查询第二个索引服务某账户在某段时间内的流水列表。加了索引之后月度统计查询从几百毫秒降到了个位数毫秒。对于 SQLite 这种轻量级数据库这种提升非常明显。4. 核心业务逻辑记账、转账与月度统计的完整实现模型设计好之后真正的工作在业务逻辑层。个人财务系统不像电商那样有复杂的业务流程核心逻辑集中在这三块记账、转账、统计报表。4.1 记账的原子性与一致性用户提交一笔支出表单拿到的是金额、账户、分类、日期、备注。视图层要做的事情很简单把数据塞进Transaction表。但这里有一个必须处理的细节——如果用户在记账的同时又把账户删了或者提交了负数金额系统该怎么应对我的处理方式是三步表单层用 ModelForm 自带校验金额字段用MinValueValidator(0.01)限制必须大于 0。视图层手动加一层判断账户必须属于当前登录用户否则直接返回 404。数据库层用transaction.atomic()包裹写入操作保证任何一步失败都能回滚。记账的视图代码大致这样from django.db import transaction from django.shortcuts import get_object_or_404, redirect, render from django.contrib.auth.decorators import login_required from .forms import TransactionForm login_required def transaction_create(request): if request.method POST: form TransactionForm(request.POST) if form.is_valid(): with transaction.atomic(): tx form.save(commitFalse) tx.user request.user tx.save() return redirect(finance:transaction_list) else: form TransactionForm() return render(request, finance/transaction_form.html, {form: form})transaction.atomic()在单条记录写入的场景看似多余但它是个好习惯。一旦后面增加记账同时更新预算用量记账同时发送通知这类操作没有事务保护就会出现部分成功部分失败的脏数据。既然用了 Django别浪费它提供的数据库事务能力。4.2 转账处理一条记录还是两条记录最早我天真地以为转账就是一笔支出加一笔收入直接往表里插两条流水就行了。后来发现统计报表会出问题——你转账 5000 块到另一张卡消费统计里会多出 5000 支出又多了 5000 收入收支总额被虚增分类占比图也完全失真了。正确的做法是转账单独作为一种类型处理。系统在转账提交时同时创建两条 Transaction 记录类型都是transfer一条在转出账户、一条在转入账户然后通过related_transaction字段互相指向。统计消费时排除transfer类型统计账户余额时转账会被正常纳入因为转入转出金额会自然抵消。具体来说一笔从储蓄卡转到信用卡的 3000 元会在数据库里产生两条记录账户类型金额方向含义储蓄卡transfer-3000转出信用卡transfer3000转入计算储蓄卡余额时这两笔会让储蓄卡减少 3000计算信用卡欠款时如果你把信用卡设成了负债账户处理逻辑会更复杂一点。个人简化方案是信用卡也用正数余额表示当前欠款这样统计总资产时把负债减掉就行。这块逻辑是个人系统和企业级财务软件差异最大的地方不用追求标准复式记账的严谨性怎么符合自己的认知习惯怎么来。4.3 月度统计ORM 聚合查询的正确姿势统计报表是财务系统最见功力的地方。Django 的 ORM 提供了annotate和TruncMonth可以非常优雅地把数据库层面的聚合查询转换成统计图表数据。月度支出统计的核心查询from django.db.models.functions import TruncMonth from django.db.models import Sum def monthly_expense_summary(request, year, month): base_qs Transaction.objects.filter( userrequest.user, transaction_typeexpense, transaction_date__yearyear, transaction_date__monthmonth, ) # 按分类聚合当月支出 by_category base_qs.values(category__name).annotate( totalSum(amount) ).order_by(-total) # 按天聚合生成趋势折线 by_day base_qs.annotate( dayTruncMonth(transaction_date__day) ).values(day).annotate(totalSum(amount)).order_by(day) return by_category, by_dayannotate把 SQL 的GROUP BY和聚合函数封装到了 Python 层让视图代码保持可读性。这里有一个新手常见的误区试图在 Python 层面手动循环流水再累计金额。流水少时没问题一旦超过几千条内存和时间开销都会明显增大而且代码变成了一坨难以维护的逻辑。把聚合下推到数据库层是最正确也最省力的方案。4.4 预算超支判断的边界情况预算模块逻辑很简单但边界情况很多。比如用户给餐饮设了 3 月 2000 元预算3 月花了 2500 元。判断超支的查询是当月餐饮分类下所有支出流水之和与预算做比较。这里要特别注意的是分类层级。用户记账时选的可能是子类工作日午餐和外卖它们都挂在餐饮大类下。如果预算设在餐饮大类统计时就要把两个子类的支出合起来算。这个用 ORM 不好直接表达我选择在预算统计时先拿到该分类的所有子孙分类 ID再过滤流水。def get_descendant_category_ids(category): ids [category.id] for child in category.category_set.all(): ids get_descendant_category_ids(child) return ids递归函数虽然简单但如果层级很深性能会有问题。分类一般只设两级完全可以接受。这段代码也说明了一个道理不要一上来就追求极端通用先满足自己的使用场景设计永远服务需求。5. 用户认证与数据隔离单用户系统的安全感从哪来个人系统虽然只有一个用户但认证体系和数据隔离不能马虎。我把用户模块做成标准的 Djangoauth体系注册、登录、登出都用django.contrib.auth自带的视图和表单没有自己造轮子。5.1 登录保护与全局查询过滤每个需要授权的视图我都加了login_required装饰器。这个装饰器会在用户未登录时跳转到登录页登录后自动跳回原页面体验很顺畅。对于类视图我用LoginRequiredMixin效果相同。数据隔离的关键在于覆盖get_queryset方法from django.views.generic import ListView class TransactionListView(LoginRequiredMixin, ListView): model Transaction template_name finance/transaction_list.html paginate_by 20 def get_queryset(self): return Transaction.objects.filter( userself.request.user ).select_related(account, category).order_by(-transaction_date, -id)如果不覆盖get_querysetDjango 会默认返回Transaction.objects.all()一旦系统有多个用户你看到的就是所有人的流水。这是新手最容易犯的安全错误之一。同样的过滤逻辑在账户列表、分类管理、预算列表每一个视图里都要重复写一遍虽然看起来繁琐却是必须的。5.2 用 mixin 统一记账关联视图里大量出现了tx.user request.user这种赋值。我把它抽成了一个 mixin放在views.py里复用class OwnerMixin: def form_valid(self, form): form.instance.user self.request.user return super().form_valid(form)表单提交时自动把自己的用户 ID 注入到记录里既避免漏写也让视图代码简洁很多。这个 mixin 虽然只有四行但为我省下了大量重复代码。5.3 密码安全与后台保护密码这块直接用 Django 默认的 PBKDF2 哈希算法不要自己设计任何加密方案。国内很多新手喜欢在项目里写 MD5 加密密码这是一个非常危险的习惯。MD5 早已被彩虹表完全破解Django 默认的密码哈希器本身就是在行业实践中千锤百炼过的方案。记住一句话密码存储永远不要自己发明轮子用框架自带的。另外我建议把 Django Admin 也加到登录保护后面。Admin 默认的 URL 是/admin/任何知道路径的人都可以访问登录页虽然暴力破解成功的概率不高但完全可以用AdminSite子类改掉路径这种防御成本极低收益却是实打实的。6. 部署上线与数据备份系统从本地跑到服务器开发环境跑通只是第一步。这个系统要真正取代手机记账 App必须能从一个随手打开的浏览器访问。我的部署方案是一台低配云服务器 Gunicorn Nginx SQLite/PostgreSQL。6.1 从零跑通项目的完整命令序列如果你拿到这套系统的源码在本地跑起来的步骤大约是# 1. 创建虚拟环境一定要用别全局安装 python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate # 2. 安装依赖 pip install -r requirements.txt # 3. 数据库迁移 python manage.py migrate # 4. 创建管理员账号 python manage.py createsuperuser # 5. 加载初始分类数据 python manage.py loaddata initial_categories.json # 6. 启动开发服务器 python manage.py runserver这套流程本身也是 Django 项目的标准启动流程。需要注意requirements.txt里要锁定好 Django 版本我用的是 Django 4.2 LTS这个版本支持到 2026 年 4 月安全更新有保障。不建议直接上 Django 5.x 尝鲜除非你的项目没有任何第三方依赖。6.2 生产环境配置关键点上线部署时settings.py里必须改这几个配置DEBUG False ALLOWED_HOSTS [your-domain.com] # 静态文件收集 STATIC_ROOT os.path.join(BASE_DIR, staticfiles)DEBUG False这一步非常关键。DEBUG 模式下 Django 会把完整错误堆栈打印在页面上包括你的目录结构、数据库配置、代码路径——在公网环境这等于把源代码的一部分直接暴露给攻击者。另外 DEBUG 模式下没有处理静态文件的优化页面会非常慢。我习惯先在本机用python manage.py runserver跑通全部功能再上服务器部署。服务器上 Gunicorn 的启动命令大致这样gunicorn personal_finance.wsgi:application --bind 127.0.0.1:8000 --workers 2Nginx 反向代理配置里注意把客户端最大请求体调大一点不然上传 CSV 导入历史数据时可能报 413 错误location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; client_max_body_size 20m; }6.3 数据备份策略这个系统里最值钱的不是代码是数据库里的几年流水。我采用三层备份策略Django 自带的后台dumpdata定期导出 JSON 格式的完整数据快照。直接复制 SQLite 文件如果用的是 SQLite每天定时备份到另一台机器。每个月导出一次 CSV 摘要存到网盘归档。dumpdata命令输出的是 Django 序列化格式可以完美地导入回系统适合整体迁移。CSV 导出则是为了防止系统彻底不可用时能拿 Excel 打开继续看数据。三层备份策略虽然简单但保证了任何单点故障都不会让历史数据彻底丢失。7. 实战踩坑记录与这个项目能带给你的东西最后聊一聊我在整个开发过程中踩过的几个实实在在的坑以及这套系统做完之后我拿到的一些经验。7.1 时间处理交易日期与创建时间Django 默认开启USE_TZ True所有DateTimeField存的是 UTC 时间。这本身没问题但交易日期账单实际发生的时间应该是用户本地时间而不是服务器 UTC 时间。我在最早版本里直接用datetime.now()给交易日期赋值结果服务器时区是 UTC所有记录都比北京时间晚了 8 小时。修正方案是区分两个概念transaction_date用普通的DateField用户在表单里手动选择或者默认取本地日期created_at用auto_now_addTrue的DateTimeField记录真实的创建时间戳用于系统内部追踪。用户看到的永远是本地日期系统内部记录的统一时间用于排序和 debug。这个区分看起来基础但问题是真实出现的而且排查时非常隐蔽。7.2 金额计算永远用 Decimal不要用 Float这是财务系统开发中的铁律。浮点数的二进制存储特性导致0.1 0.2 ! 0.3在金额累加场景下误差会逐笔放大。Django 的DecimalField在 ORM 层面会映射到数据库的 Decimal 类型Python 层面对应decimal.Decimal运算精确。早期版本我用过 Floats记账一个月后报表上的总金额和实际银行流水差了 0.03 元不得不通过导出所有数据重算一遍来修正。自那以后所有金额字段全部统一为 Decimal再也没出过类似问题。7.3 分类修改导致统计口径变化系统用了一年后我改过一次分类结构把交通大类下的共享单车从子分类提升为独立的大类。结果当月报表里交通支出骤降共享单车支出异军突起。这个改动本身合理但历史月份的数据也跟着变了口径导致月度对比失去意义。合理的做法要么是永远保留旧分类的映射关系报表按记录时的分类展示要么在做分类调整时意识到这是一个不可逆的重写历史需要一次性导出受影响月份的数据留档。现在我的原则是大类基本不动子类可以微调调整只在月初进行避免月内报表混乱。7.4 项目源码结构与成长价值这套系统的源码结构是按 Django 标准组织起来的personal_finance/是主配置目录finance/是业务应用内部包含models.py、views.py、forms.py、urls.py、admin.py、templates/和static/干净利落。随着项目功能扩充代码量到几千行的时候坚持模块化组织的好处会越来越明显。对我个人来说这个项目最大的价值不是记账而是它让我完整地走通了一个 web 应用的从构思到部署全链路分析需求、建立数据模型、实现交互业务、处理安全、做统计查询、上线部署。它比那些纯教学 Demo 复杂得多比真实企业项目简单得多恰好处于学到东西和不至于劝退的甜蜜区间。如果你是个 Django 初学者或者你也受够了记账 App 的数据封闭我非常建议你从这样一个项目开始改造出自己的版本——它不会太占用你的时间却能给你一整套真正属于你自己、随你心意扩展的财务工具。我现在的使用状态是月初花五分钟核对预算月底花十分钟看报表日常记账基本两三秒一笔。系统里那五年的流水是一段完全由自己掌握、随时可以导出的真实生活档案。