☰
Django家庭记账系统实战:从MVT设计到部署避坑
2026/9/25 6:56:46 网站建设 项目流程

简介:基于Django框架开发的家庭财务管理系统完整源码包,属于毕业设计/课程设计类项目,面向Python Web学习者、高校在校生及需要快速搭建财务管理系统的开发者。项目采用Python语言与MySQL数据库,后端按账户管理、收支记录、预算控制、报表统计等核心模块组织,前端以HTML/CSS/JS页面为主,配套Bootstrap等样式组件,可直观查看界面布局与交互效果。压缩包共2000个文件,其中JavaScript文件1626个、HTML文件257个、CSS文件52个,另有少量Python源码、JSON配置及Markdown说明,包体仅4.52MB,便于下载部署与二次修改。已有41人学习参考。通过源码可深入理解Django项目结构、MTV模式、ORM数据操作、用户认证与权限管理,同时参考多模块下的数据可视化报表实现思路;系统设计兼顾安全性与可扩展性,非常适合课程设计、毕业设计或项目练手时直接借鉴与扩展。

1. 为什么家庭记账要从 Excel 迁到 Django:一个能自己维护的财务系统

记账三个月后打开 Excel 发现公式被误删、月份汇总对不上账、手机和电脑永远不同步——这是我把家庭财务从表格迁移到 Django 的直接原因。这个标题指向的,是一个用 Python Django 搭建的完整 Web 记账系统,不是一个爬虫脚本,也不是一个数据分析 Notebook。它解决的是「家庭多人协作记账 + 月度统计 + 数据持久化」这类真实需求,适合想通过一个完整项目把 Django 的 MVT、ORM、模板渲染串起来练手的新手,也适合需要一个内网可部署的轻量财务工具、不想用第三方记账 App 的开发者。理论上你能从零跑通它,动手改造成自己家的账本。

2. 拆项目骨架:从 MVT 到 Django 项目初始化的最小闭环

2.1 先弄清楚 MVT 里谁负责记账、谁负责算账

Django 的 MVT 和传统 MVC 的差别,不是字母顺序不同,而是职责切分更明确。Model 管数据结构和数据库交互,View 管业务逻辑和取数,Template 管渲染呈现。家庭财务系统里,你记一笔「买了排骨 35 元」,Model 负责定义这笔记录的字段(金额、时间、分类、账户),View 负责把「这个月的支出按分类求和」算出来,Template 负责把算好的结果画成表格或图表。

新手最容易犯的错,是把算账逻辑写在 Template 里,比如在模板里做{{ total|add:price }}这类操作。模板引擎不是给你做业务计算的,它只做展示。要求和的逻辑必须进 View。这个拆分想清楚了,后面写代码的边界就清楚了:Model 只管存什么,View 管怎么算,Template 管怎么给人看。

2.2 用 django-admin 创建项目与账本 app:五分钟跑出第一个页面

我一般先建项目骨架,再单独建一个book的 app 来放财务相关代码,不把家庭财务逻辑塞进项目根目录。命令如下:

# 创建 Django 项目,名字用 family_finance,避免和系统的 django 目录混淆 django-admin startproject family_finance # 进入项目根目录 cd family_finance # 创建 app,命名 book,专门放账户、分类、流水等财务模块 python manage.py startapp book # 创建数据库迁移文件并执行,Django 默认使用项目里内置的 sqlite3 python manage.py makemigrations book python manage.py migrate

第一行startproject生成的是项目级配置,settings.py管全局,urls.py管路由分发。第三行startapp book生成的是一个功能模块,models.py写数据结构,views.py写业务逻辑。最后两行迁移命令是把模型变成数据库表,首次跑的时候连同 Django 自带的 auth、session 等表一起建好。

项目能跑起来之前,还需要把book注册到settings.py的INSTALLED_APPS里,然后改一下family_finance/urls.py,把根路由指向 book 的视图。常见做法是先在book/views.py写一个最简单的index返回一段文本,验证「浏览器请求 → URL 路由 → View 处理 → 响应」这条链路通了,再往下写模型。这一步别跳,它是在验证你的 Django 环境本身没问题,而不是一上来就堆业务代码然后分不清是代码错还是环境错。

3. 给钱建模:用 Django ORM 设计账户、分类与交易流水

3.1 Money 字段用 DecimalField 而不是 FloatField:浮点结算会让你对不上账

家庭财务系统里最核心的一张表是交易流水,字段大概包括:交易时间、金额、类型(收入/支出)、分类、账户、备注。金额字段的选型是第一个坑。如果图省事用FloatField,存 0.1 + 0.2 这类浮点数会出现二进制无法精确表示的问题,月度汇总时多出几分钱,你就得满世界找这笔误差。我一般用DecimalField,强制指定最大位数和小数位:

from django.db import models from django.contrib.auth.models import User class Transaction(models.Model): # 分类用 ForeignKey 关联,避免每条流水都存一串冗长的分类名 category = models.ForeignKey( 'Category', on_delete=models.SET_NULL, null=True, blank=True, related_name='transactions' ) # 账户关联到 User 或者独立的 Account 表 account = models.ForeignKey( 'Account', on_delete=models.CASCADE, related_name='transactions' ) # 金额用 DecimalField,max_digits=10 表示最多 10 位数字,decimal_places=2 保留两位小数 amount = models.DecimalField(max_digits=10, decimal_places=2) # 交易类型用 choices 限制取值,防止脏数据 TRANSACTION_TYPES = [('income', '收入'), ('expense', '支出')] txn_type = models.CharField(max_length=10, choices=TRANSACTION_TYPES) # 交易时间单独存,不依赖 auto_now_add,因为可能要补录历史账 txn_date = models.DateTimeField() note = models.CharField(max_length=255, blank=True) owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name='transactions') def __str__(self): return f"{self.txn_date:%Y-%m-%d} {self.get_txn_type_display()} {self.amount}"

注意on_delete参数的设定,这是 Django 删除对象时的核心行为控制。CASCADE表示父对象删除时子对象连带删除;SET_NULL表示父对象删除后子对象的外键置空,前提是字段设置了null=True。流水表里的category用SET_NULL,是因为分类删掉了,历史流水金额还在,不能连坐;而account用CASCADE,是因为账户都没了,这个账户底下的流水留着一堆孤儿数据没有意义。

3.2 账户、分类与流水的关联关系:删除账本时交易怎么办

再往上一层,如果家庭里有多个账本,比如「日常开销账本」「旅行专用账本」,那应该在Transaction之上再挂一个Ledger。这个层级关系决定了你删除账本时会发生什么:你删掉「旅行账本」,里面几十笔机票酒店记录直接消失,还是保留流水但账本字段置空?

我的建议是,账本作为最高层级,删除时CASCADE连带删掉里面所有流水,因为账本本身就是一种分组容器,容器没了内容物没有独立存在意义。但用户信息不能绑死在账本上,owner字段不要放在Ledger里,应该放在Transaction里或者通过Account间接关联。因为账本可能是共享的——夫妻两人各有一个账号,同看一个「家庭总账本」。如果把owner放在账本上,就锁死了一本账只能一个人看。

模型关系上还有一个容易忽略的点:用related_name明确反向查询名。比如account.transactions.all()能拿到这个账户下所有流水,而不是默认的transaction_set,代码阅读性会好很多。这个参数能省掉你在 View 里写一堆过滤条件。

3.3 迁移命令与 Django 删除对象的行为:makemigrations 到底做了什么

写完模型后要跑两条命令,新手容易混淆它们的职责:

# 生成迁移文件,不直接操作数据库,只是把模型变化记录成 Python 文件 python manage.py makemigrations book # 真正执行迁移,把生成的迁移文件翻译成 SQL 去修改数据库表结构 python manage.py migrate

makemigrations只是在book/migrations/目录下多出一个000x_xxx.py文件,它不碰数据库。migrate才是把表和字段在数据库里建出来。如果你改了模型的字段,重新跑makemigrations后会生成新的迁移文件,Django 会按顺序执行这些迁移,而不是重新建一整张表。这就是 Django 比手写 SQL 改表结构省心的地方:它替你追踪「上次的表结构是什么样,这次要加哪些字段」,不需要你导出旧表再导入新表。

删除对象的操作也顺带说一下。Django ORM 里删数据不是delete()一串就完事,它会按照模型里的on_delete规则链式操作。比如ledger.delete()被调用时,Django 会找到所有ForeignKey指向这个 ledger 的Transaction,逐个执行它们各自的删除策略。如果你没设related_name,删除关联对象时会多出额外查询甚至报错;如果设了on_delete=models.PROTECT,那删除账本时会抛出ProtectedError,提示你还有流水引用它。这是保护数据的一种方式,适合那些删了会闯祸的场景,比如你不想让任何人删掉一整年的账单。

4. 让页面动起来:仪表盘视图、模板渲染与统计聚合

4.1 按分类聚合月度收支:annotate 与 aggregate 的取舍

模型建好只是地基,用户打开首页看到的是统计结果:这个月花了多少、收入多少、哪类花销最大。这个取数的逻辑写在 View 里。家庭财务的仪表盘通常需要两类聚合:一类是按分类分组统计(餐饮 1200、交通 450);另一类是对全表求总和、平均值。前者用annotate,后者用aggregate,它们返回的数据类型不一样,新手别搞混。

from django.db.models import Sum from django.utils import timezone from datetime import timedelta from .models import Transaction def dashboard(request): # 只统计当前登录用户的数据,避免串号 user = request.user today = timezone.now().date() first_day = today.replace(day=1) # annotate:按分类分组,返回一个 QuerySet,每一行是一个分类的汇总 expense_by_category = ( Transaction.objects .filter(owner=user, txn_type='expense', txn_date__gte=first_day) .values('category__name') .annotate(total=Sum('amount')) .order_by('-total') ) # aggregate:对 QuerySet 整体做聚合,返回一个字典,不是 QuerySet total_expense = Transaction.objects.filter( owner=user, txn_type='expense', txn_date__gte=first_day ).aggregate(total=Sum('amount'))['total'] or 0 return render(request, 'book/dashboard.html', { 'expense_by_category': expense_by_category, 'total_expense': total_expense, })

这两段逻辑看着像,但数据结构完全不同。expense_by_category是一个带分组信息的QuerySet,模板里可以继续{% for %}遍历;total_expense是一个裸的数值或None。我见过有人把aggregate的结果直接拿来{% for %}循环,模板引擎直接报错,因为字典不能遍历成行。values('category__name')是关键,它先把category__name作为分组依据,后面的Sum才有意义。如果values里放了多个字段,那分组的粒度就变成多个字段的组合,比如按「分类 + 账户」两组分,金额会被切得更碎。

4.2 模板里渲染流水与日期筛选:哪些逻辑该留在视图

View 算好数据后,剩下的是 Template 的活。渲染流水表格时,我一般只做两件事:循环输出和简单格式化。不要在模板里做任何涉及条件累加的逻辑。下面是最小可用的流水列表片段:

<!-- book/templates/book/dashboard.html --> <table class="table table-striped"> <thead> <tr> <th>日期</th> <th>分类</th> <th>类型</th> <th>金额</th> <th>备注</th> </tr> </thead> <tbody> {% for tx in recent_transactions %} <tr> <td>{{ tx.txn_date|date:"Y-m-d" }}</td> <td>{{ tx.category.name|default:"未分类" }}</td> <td>{{ tx.get_txn_type_display }}</td> <td>{{ tx.amount }}</td> <td>{{ tx.note|default:"-" }}</td> </tr> {% empty %} <tr><td colspan="5">本月还没有记账记录</td></tr> {% endfor %} </tbody> </table>

get_txn_type_display是 Django 为choices字段自动生成的方法,会把代码里的expense翻译成中文「支出」,不要在 View 里再手动做一次映射。date:"Y-m-d"是模板过滤器的写法,把DateTimeField格式化成可读日期。日期筛选的建议是:把「查询起始日」「查询结束日」作为参数从 URL 或表单传入,View 里用txn_date__range=[start, end]过滤,不要模板循环里判断日期大小,那样能跑但数据一多就慢了。

{% empty %}是 Django 模板里很容易被忽略的标签,它处理的是 QuerySet 为空的情况,比用{% if %}判断再写一个循环清爽得多。这些模板写法本身很简单,但它是 Django 项目实战里最容易让新手卡壳的地方——倒不是语法难,而是不知道有这些现成的内置糖可以用。

5. 家庭财务系统最常见的 5 个坑:从 static 文件到 Websocket 推送

5.1 vscode 里 img 标签在 Django static 中显示不了

现象:在 VSCode 里写<img src="{% static 'img/logo.png' %}">,本地开发者模式打开页面图片裂开,控制台报 404。原因有三层:第一,模板里用{% static %}标签前,模板顶部没写{% load static %};第二,settings.py里没配STATIC_URL和STATICFILES_DIRS;第三,图片没放在正确目录下。Django 只认STATICFILES_DIRS里列出来的路径,你放错目录它照样找不到。

解决:

# settings.py 里补上这段配置 STATIC_URL = '/static/' STATICFILES_DIRS = [ BASE_DIR / 'static', ]

然后把图片放在项目根目录的static/img/logo.png,模板顶部加{% load static %}。特别注意,Django 有个坑:开发者模式(DEBUG=True)下django.contrib.staticfiles会帮你自动服务STATIC_URL下的文件,看起来像是「没配也能用」,但一旦设置了STATICFILES_DIRS,目录结构不对反而会互相遮蔽。检查顺序应该是:模板{% load static %}→STATICFILES_DIRS路径 → 文件实际位置,三步链路由前到后排查。

5.2 生产环境关掉 DEBUG 后静态文件全部丢失

现象:DEBUG=False部署到服务器后,页面变成裸 HTML,所有 CSS、JS、图片全部 404。原因是 Django 在设计上就明确开发者模式和生产模式的服务方式不同:DEBUG=True时 Django 自己处理静态文件;DEBUG=False时它把这个责任交给 Nginx 或 Web 应用服务器。很多人首次部署时不知道这一点。

解决:先在本地跑python manage.py collectstatic,把散落在各个 app 和STATICFILES_DIRS里的静态文件收集到STATIC_ROOT指定的目录,然后让 Nginx 用alias指向这个目录。注意STATIC_ROOT和STATICFILES_DIRS不能是同一个目录,collectstatic会清空重建STATIC_ROOT,如果你把源码静态文件也放在里面,它们会被删掉。

5.3 多用户数据串号:忘了在 filter 里带 request.user

现象:A 用户登录后,能看到 B 用户的账单流水,或者 B 用户修改了某条数据后 A 的页面数据变了。这是 Django 项目实战里最危险的一类漏洞——对象级权限缺失。原因不在模型设计,而在 View 里的filter()没有追加owner=request.user条件。上一章的仪表盘代码里我特意写了filter(owner=user),就是为了锁死数据范围。

解决:给自己定一条铁律——任何从数据库取数的QuerySet,第一行先写request.user的过滤条件,然后再拼其他条件。如果写的是Transaction.objects.filter(owner=request.user).filter(txn_date__gte=first_day),顺序上先过滤用户再用日期缩小范围,虽然没有性能差别,但对阅读者来说逻辑更清晰。还有一个辅助手段:在模型层自定义一个Manager,默认过滤owner=request.user,但我更推荐显式写清楚,因为隐式过滤会让后来接手的人看不懂为什么某些查询少了数据。

5.4 用 WebSocket 做实时推送时的依赖与部署陷阱

现象:想实现「后台有新账单录入,前端页面不刷新自动更新」这个效果,引入了channels,结果runserver能起但页面一直推送不了。原因出在同步和异步机制的混用上:Django 默认的 View 是同步的,而 Channels 的AsyncWebsocketConsumer是异步的,两者之间做数据传递时,你不能直接在一个同步 View 里调用异步group_send方法而不做转换。

解决:使用channels.layers.get_channel_layer()获取通道层后,需要调用async_to_sync(channel_layer.group_send)把同步调用的结果包成异步可等待对象。部署时也需要额外装channels_redis作为 channel layer 的后端,否则多进程环境下 WebSocket 消息无法跨进程广播。这个功能本身很适合家庭财务的场景,比如另一半刚记了一笔账,你的电脑页面瞬间多了这笔数据,但如果项目只要求内网几个人用,我反而建议先用简单的轮询(每隔 30 秒刷一次接口)代替,省掉 Redis 依赖和部署复杂度。

5.5 用delete()删除对象时的级联意外

现象:删除一条分类数据,结果一批交易流水神秘消失。原因在第一版的Category模型里,ForeignKey(on_delete=models.CASCADE)导致父分类删除时子流水被连带删除。如果你本意只是清理一个不再使用的分类,这个结果就是事故。

解决:把on_delete改成SET_NULL是一种方案,但前提是你的ForeignKey字段允许为空。还有一个更安全的习惯:在删除操作前显式打印即将删除的关联数量。在 Django shell 里可以这样排查:

python manage.py shell

进入 shell 后执行:

from book.models import Category cat = Category.objects.get(id=1) # 查看这个分类下有多少交易流水,确认不会误删 print(cat.transactions.count()) # 确认无误后再执行删除 cat.delete()

先看数量再动删除键,是我的习惯。尤其是家庭财务数据,删了是真的找不回来的,sqlite 文件本身也没有回滚的概念,这一步操作值得慢几秒。

6. 把系统跑成能长期记账的样子:月度对账与查询优化的几个验证技巧

系统能记账之后,下一步就是让它能长期用、月底敢拿出来核对。我的习惯是每隔一段时间跑一次对账脚本,验证数据库里的数据是不是真的和自己银行卡流水对得上。这个脚本不涉及复杂逻辑,就是按账户分组求出收支净额,然后和你手动算的总数比对:

python manage.py shell -c " from django.db.models import Sum from book.models import Transaction for acc in Account.objects.all(): income = Transaction.objects.filter(account=acc, txn_type='income').aggregate(s=Sum('amount'))['s'] or 0 expense = Transaction.objects.filter(account=acc, txn_type='expense').aggregate(s=Sum('amount'))['s'] or 0 print(f'{acc.name}: 结余 {income - expense}') "

另外两个优化建议。第一,给Transaction表添加索引,否则流水量超过几千条后,按月筛选的查询明显变慢。用Meta类里的indexes定义组合索引,比如(owner, txn_date, txn_type),比单独索引更贴近实际查询路径。第二,做交易导入时用transaction.atomic()包裹批量写入,保证几十行数据要么全成功要么全回滚,不然导入半截断掉会导致账目不平。这是我踩过的坑,现在每个月导入信用卡账单时都会在脚本里加上with transaction.atomic():这行。这个系统最让我踏实的一点,就是数据全在自己手里,复盘每一笔钱往哪去了,不用看第三方 App 的脸色。希望这些设计思路和坑能帮到你,少走我走过的弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询