Django家庭记账系统实战:从MVT设计到部署避坑
发布时间:2026/9/25 6:56:51来源:尧图网络
简介基于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_deletemodels.SET_NULL, nullTrue, blankTrue, related_nametransactions ) # 账户关联到 User 或者独立的 Account 表 account models.ForeignKey( Account, on_deletemodels.CASCADE, related_nametransactions ) # 金额用 DecimalFieldmax_digits10 表示最多 10 位数字decimal_places2 保留两位小数 amount models.DecimalField(max_digits10, decimal_places2) # 交易类型用 choices 限制取值防止脏数据 TRANSACTION_TYPES [(income, 收入), (expense, 支出)] txn_type models.CharField(max_length10, choicesTRANSACTION_TYPES) # 交易时间单独存不依赖 auto_now_add因为可能要补录历史账 txn_date models.DateTimeField() note models.CharField(max_length255, blankTrue) owner models.ForeignKey(User, on_deletemodels.CASCADE, related_nametransactions) def __str__(self): return f{self.txn_date:%Y-%m-%d} {self.get_txn_type_display()} {self.amount}注意on_delete参数的设定这是 Django 删除对象时的核心行为控制。CASCADE表示父对象删除时子对象连带删除SET_NULL表示父对象删除后子对象的外键置空前提是字段设置了nullTrue。流水表里的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 migratemakemigrations只是在book/migrations/目录下多出一个000x_xxx.py文件它不碰数据库。migrate才是把表和字段在数据库里建出来。如果你改了模型的字段重新跑makemigrations后会生成新的迁移文件Django 会按顺序执行这些迁移而不是重新建一整张表。这就是 Django 比手写 SQL 改表结构省心的地方它替你追踪「上次的表结构是什么样这次要加哪些字段」不需要你导出旧表再导入新表。删除对象的操作也顺带说一下。Django ORM 里删数据不是delete()一串就完事它会按照模型里的on_delete规则链式操作。比如ledger.delete()被调用时Django 会找到所有ForeignKey指向这个 ledger 的Transaction逐个执行它们各自的删除策略。如果你没设related_name删除关联对象时会多出额外查询甚至报错如果设了on_deletemodels.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(day1) # annotate按分类分组返回一个 QuerySet每一行是一个分类的汇总 expense_by_category ( Transaction.objects .filter(owneruser, txn_typeexpense, txn_date__gtefirst_day) .values(category__name) .annotate(totalSum(amount)) .order_by(-total) ) # aggregate对 QuerySet 整体做聚合返回一个字典不是 QuerySet total_expense Transaction.objects.filter( owneruser, txn_typeexpense, txn_date__gtefirst_day ).aggregate(totalSum(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 classtable 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 %} trtd colspan5本月还没有记账记录/td/tr {% endfor %} /tbody /tableget_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 有个坑开发者模式DEBUGTrue下django.contrib.staticfiles会帮你自动服务STATIC_URL下的文件看起来像是「没配也能用」但一旦设置了STATICFILES_DIRS目录结构不对反而会互相遮蔽。检查顺序应该是模板{% load static %}→STATICFILES_DIRS路径 → 文件实际位置三步链路由前到后排查。5.2 生产环境关掉 DEBUG 后静态文件全部丢失现象DEBUGFalse部署到服务器后页面变成裸 HTML所有 CSS、JS、图片全部 404。原因是 Django 在设计上就明确开发者模式和生产模式的服务方式不同DEBUGTrue时 Django 自己处理静态文件DEBUGFalse时它把这个责任交给 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()没有追加ownerrequest.user条件。上一章的仪表盘代码里我特意写了filter(owneruser)就是为了锁死数据范围。解决给自己定一条铁律——任何从数据库取数的QuerySet第一行先写request.user的过滤条件然后再拼其他条件。如果写的是Transaction.objects.filter(ownerrequest.user).filter(txn_date__gtefirst_day),顺序上先过滤用户再用日期缩小范围虽然没有性能差别但对阅读者来说逻辑更清晰。还有一个辅助手段在模型层自定义一个Manager默认过滤ownerrequest.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_deletemodels.CASCADE)导致父分类删除时子流水被连带删除。如果你本意只是清理一个不再使用的分类这个结果就是事故。解决把on_delete改成SET_NULL是一种方案但前提是你的ForeignKey字段允许为空。还有一个更安全的习惯在删除操作前显式打印即将删除的关联数量。在 Django shell 里可以这样排查python manage.py shell进入 shell 后执行from book.models import Category cat Category.objects.get(id1) # 查看这个分类下有多少交易流水确认不会误删 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(accountacc, txn_typeincome).aggregate(sSum(amount))[s] or 0 expense Transaction.objects.filter(accountacc, txn_typeexpense).aggregate(sSum(amount))[s] or 0 print(f{acc.name}: 结余 {income - expense}) 另外两个优化建议。第一给Transaction表添加索引否则流水量超过几千条后按月筛选的查询明显变慢。用Meta类里的indexes定义组合索引比如(owner, txn_date, txn_type)比单独索引更贴近实际查询路径。第二做交易导入时用transaction.atomic()包裹批量写入保证几十行数据要么全成功要么全回滚不然导入半截断掉会导致账目不平。这是我踩过的坑现在每个月导入信用卡账单时都会在脚本里加上with transaction.atomic():这行。这个系统最让我踏实的一点就是数据全在自己手里复盘每一笔钱往哪去了不用看第三方 App 的脸色。希望这些设计思路和坑能帮到你少走我走过的弯路。本文还有配套的精品资源点击获取
网站建设高端定制企业官网