Django家谱系统开发:邻接表与闭包表双模建模,搞定录入、查询与删除 📅 发布时间:2026/9/16 12:52:36 👁 浏览次数: 简介基于PythonDjango构建的在线家谱查询录入系统是一份面向计算机专业毕业设计、Python Web学习者的完整项目源码。系统围绕家谱信息录入、关系维护与查询检索展开涵盖用户注册登录、权限控制、家谱数据管理、查询历史等模块适合用于课程设计、毕设参考或Django框架实战练习。压缩包共246个文件大小36.6MB主要包含23个py后端源码、23个html模板、21个js与15个css前端文件、65个jpg及23个png界面素材另含sqlite3数据库、项目说明文档等目录结构清晰便于直接部署或二次开发。项目实践覆盖Python基础、Django ORM、MVC架构、前端Bootstrap、认证授权、REST API设计与测试部署等核心知识点并附带真实交互页面与数据表设计可直接运行演示对提升Web全栈开发能力很有帮助。目前已有304人学习下载。1. 家谱系统真正的问题在“录入”树查询反而是简单的做“基于pythonDjango的在线家谱查询录入系统”最容易被低估的是“录入”这两个字。查一个家族只要把树遍历写对录一个人却要回答重名的人怎么区分、继养关系挂在哪一支、删掉一个中间节点后他的子女是变孤儿还是整体上移。这些问题在需求里往往只有一句“能添加成员”可一到 pythonDjango 的 ORM 层面就变成一连串决定外键怎么设、删除保护还是级联、要不要单独存路径。我的做法是邻接表管录入、闭包表管查询再用事务把两者缝住。下面这套模型、命令和排错方法新手能照着建熟手能直接拿走参数和边界。2. Django 血缘建模邻接表与闭包表各管一段2.1 三种血缘关系建模的取舍“一个人挂在谁下面”最直接的实现是给 Person 表加一个 parent 外键这叫邻接表。写入只花一次 INSERT但查询子树要递归Django ORM 没有递归能力你只能用 Python 循环一层层查或者上 WITH RECURSIVE 裸 SQL。第二种是物化路径在每个节点上存一列祖先 ID 拼接的字符串查子树靠 LIKE 前缀移动分支时要批量改路径恰好家谱里“过继改挂”是常见操作。第三种是闭包表单独建一张关系表存“谁是谁的祖先”任何子树和祖先链都能一次 JOIN 查出来代价是写入时要多插几条记录。一个几千人的家谱用哪个都跑得动但维护体验差别很大。我倾向于混合Person 表保留 parent 外键用于录入和表单选择Closure 闭包表专门服务查询。闭包表本质上可以当缓存看待数据量到一万以内出问题重建一次也就几秒。方案录入成本查整棵子树查祖先链移动分支邻接表 parent1 次 INSERT递归或循环 N 次循环向上 N 次改一行物化路径1 次 INSERT 拼路径1 次 LIKE 前缀拆分路径字段改一批路径闭包表1 树深度 次 INSERT1 次 JOIN1 次 JOIN关系记录批量改2.2 邻接表 Person 模型字段级参数怎么设from django.db import models class Person(models.Model): name models.CharField(max_length50, db_indexTrue, verbose_name姓名) gender models.SmallIntegerField( choices[(1, 男), (2, 女)], verbose_name性别, ) birth_year models.PositiveIntegerField( nullTrue, blankTrue, verbose_name出生年份 ) death_year models.PositiveIntegerField( nullTrue, blankTrue, verbose_name去世年份 ) parent models.ForeignKey( self, nullTrue, blankTrue, on_deletemodels.PROTECT, related_namechildren, verbose_name父节点, ) description models.TextField(blankTrue, verbose_name生平) created_at models.DateTimeField(auto_now_addTrue, verbose_name录入时间) class Meta: ordering [birth_year, id] indexes [ models.Index(fields[name, gender], nameidx_name_gender), ]字段参数的意义要看清。db_indexTrue加在 name 上是因为查询录入界面最常见的就是按姓名定位成员但注意默认icontains生成的 LIKE 是%keyword%不会走 B 树前缀数据量到万级之后要换方案这个坑后面说。birth_year 和 death_year 用 PositiveIntegerField 而不是 DateField因为家谱里大量成员只知道出生年份精确日期通常是空值DateField 反而要你多存无意义的数据。parent 外键必须用on_deletemodels.PROTECT。很多人默认写成 CASCADE点一次删除祖先整个分支在 ORM 层被静默清掉家谱数据不可恢复。PROTECT 会强制让你写自己的删除逻辑在第 5 章展开。related_namechildren让你能写person.children.all()模板渲染树直接循环这个管理器。2.3 闭包表 Closure一次事务完成三代链接闭包表的核心是每一行表示“某人是某人的后代”depth 表示隔了几代。0 表示自己1 表示父子2 表示爷孙。from django.db import models, transaction class Closure(models.Model): ancestor models.ForeignKey( Person, on_deletemodels.CASCADE, related_nameancestor_links ) descendant models.ForeignKey( Person, on_deletemodels.CASCADE, related_namedescendant_links ) depth models.PositiveIntegerField(db_indexTrue, verbose_name代数差) class Meta: unique_together (ancestor, descendant) classmethod def add_person(cls, instance, parent_idNone): with transaction.atomic(): instance.save() cls.objects.create( ancestorinstance, descendantinstance, depth0 ) if parent_id: links cls.objects.filter(descendant_idparent_id) for link in links: cls.objects.create( ancestorlink.ancestor, descendantinstance, depthlink.depth 1, )这里先说事务。instance.save() 和闭包插入必须包在一个transaction.atomic()里否则中途报错会出现“人有记录但关系没建全”的脏状态。再说 depth 的传参逻辑查询父节点在闭包表里的全部祖先链接每条链接的 depth 加 1就是新节点到对应祖先的代数差。例如父节点的自链接 depth0新节点的链接就变成 depth1表示父子关系。unique_together防止同一对关系被重复插入批量导入时如果没做去重这一行约束会直接报 IntegrityError 提醒你。2.4 什么时候不要用闭包表闭包表不是银弹。如果应用只是记录“谁是谁儿子”页面也只有个人详情邻接表完全够用闭包表是过度设计。它适合的是“以家族为单位”的展示查看某一支的全部成员、统计某代人数、从任意节点上溯祖先链。维护成本也真实存在新增节点要跟着插记录移动分支要重建受影响子树。所以我会把闭包表当缓存管正常录入走 2.3 的增量写入但在做 GEDCOM 批量导入或手工调整 parent 后直接调用第 5 章的全量重建函数兜底不在增量维护上追求完美。3. 环境与录入从 python 安装到 Django 创建 app 的一条龙命令3.1 用 venv 锁定解释器宝塔部署也算一条路先把环境跑起来。Linux 服务器上常见问题是用系统自带的 python3 直接 pipDebian 系的 PEP 668 会报 externally-managed-environment要求你先建虚拟环境。# 确认解释器版本3.10 以上足够 python3 --version # 创建虚拟环境并激活路径按你的项目目录改 python3 -m venv /opt/family/venv source /opt/family/venv/bin/activate # 安装 Django用范围约束避免 pip 自动升到大版本 pip install django4.2,6.0 # 生成 manage.py 和项目配置 cd /opt/family django-admin startproject family_site . # Django 创建 app 的固定命令 python manage.py startapp genealogydjango-admin startproject family_site .最后的点很重要它让 manage.py 直接生成在当前目录少一层嵌套。把 genealogy 加进 INSTALLED_APPS 之后执行python manage.py makemigrations genealogy和python manage.py migrate就能建出 Person 和 Closure 两张表。习惯用宝塔面板的人可以直接用它的“Python 项目管理器”选版本和创建虚拟环境本质上和上面命令做的是同一件事。数据库默认 SQLite 在这个规模够用如果非要切 MySQL宝塔环境里先装 libmysqlclient-dev 再pip install mysqlclient否则编译阶段大概率报 mysql_config not found。3.2 ModelForm 校验出生年与去世年的交叉验证录入表单不要手写 HTML用 ModelForm 自动生成字段再把校验补上。from django import forms from .models import Person class PersonForm(forms.ModelForm): class Meta: model Person fields [name, gender, birth_year, death_year, parent, description] def clean(self): cleaned super().clean() birth cleaned.get(birth_year) death cleaned.get(death_year) if birth and death and birth death: self.add_error(birth_year, 出生年份不能大于去世年份) self.add_error(death_year, 出生年份不能大于去世年份) return cleaned交叉校验写在clean()而不是clean_birth_year()是因为字段级校验执行顺序不稳定在 clean_birth_year 里去读 death_year 可能拿到空值。这里做的是宽松校验出生和去世同年允许家谱里夭折的情况真实存在出生大于去世才报错。parent 留空表示这是家族始祖不用额外写成必填。要在录入页面把 parent 显示成一个带“无作为始祖”的下拉框可以这样parent forms.ModelChoiceField( querysetPerson.objects.all(), requiredFalse, empty_label无作为家族始祖录入, widgetforms.Select(attrs{class: form-select}), )校验点写法触发场景生卒顺序clean()里 birth death 时 add_error录入时把年份填反parent 可空requiredFalse录入第一代祖先删除受限外键 PROTECT试图从 admin 直接删人3.3 录入视图与闭包写入commitFalse 的用法录入提交后不能直接form.save()因为还要同步闭包表。用 commitFalse 拿到未落库的实例交给 Closure.add_person 去处理。from django.shortcuts import render, redirect from django.urls import reverse from django.views import View from .forms import PersonForm from .models import Closure class PersonCreateView(View): template_name genealogy/person_form.html def get(self, request): return render(request, self.template_name, {form: PersonForm()}) def post(self, request): form PersonForm(request.POST) if form.is_valid(): person form.save(commitFalse) parent form.cleaned_data.get(parent) Closure.add_person(person, parent.pk if parent else None) return redirect(reverse(genealogy:person_detail, args[person.pk])) return render(request, self.template_name, {form: form})Closure.add_person 内部会调用 instance.save()所以这里 commitFalse 不会造成漏保存。URL 生成用reverse而不是硬编码/person/3/这是 Django 项目的基本习惯类视图里定义success_url时则需要reverse_lazy因为 URLconf 在类定义阶段还没加载完。模板最简可复现版本form methodpost {% csrf_token %} {{ form.as_p }} button typesubmit保存成员/button /form录完一个人马上要看他的树所以提交后跳详情页是对的。第 5 章会加一个?created1参数做录入成功提示。4. 查询不在“搜人名”而在把树状血缘一次查出来4.1 用 ORM 直接查后代与祖先链搜索姓名是最表层的一层查询。先把按名字搜的接口写对from django.shortcuts import render from .models import Person def search(request): q request.GET.get(q, ).strip() results Person.objects.none() if q: results ( Person.objects .filter(name__icontainsq) .select_related(parent) .order_by(birth_year, id)[:50] ) return render(request, genealogy/search.html, {results: results, q: q})select_related(parent)在这里是必须的。没有它模板里每次打印“结果列表中某人的父亲是谁”都会多执行一条 SQL50 条结果就是 50 次额外查询。name__icontains在数据量小的时候没毛病万级以上就换 PostgreSQL 的 pg_trgm 或上全文索引MySQL 可以用ngram全文解析器。家谱系统冷门姓氏多但“李强”“张伟”这类重名是真实痛点所以查询列表页要显示 parent、出生年份和 id至少帮录入人员区分同名人。4.2 用闭包表把“某人的全部祖先”压成一次查询邻接表要循环向上查好几代闭包表一个字都不用循环from .models import Closure def ancestor_chain(person_id): return ( Closure.objects .filter(descendant_idperson_id) .select_related(ancestor, ancestor__parent) .order_by(-depth) )order_by(-depth)是从最远祖先排到本人depth 最大的排最前depth0 的自链接排最后。这条 SQL 只做一次 JOIN结果集大小等于代数加一。要查某一支的全部后代同理descendant_ids ( Closure.objects .filter(ancestor_idperson_id) .filter(depth__gt0) .values_list(descendant_id, flatTrue) )查询意图filter 写法返回内容全部后代ancestor节点, depth__gt0所有子孙不含本人全部祖先descendant节点本人加所有祖先三代以内ancestor节点, depth__lte3含本人到孙辈4.3 前端渲染O(N) 内存建树避开递归查询的 N1很多人一开始在模板里写{% for child in person.children.all %}递归 include听起来没问题实际一跑就爆炸。每个节点渲染时都触发一次 children 查询1000 人的家族就是 1000 次 SQL。正确做法是一次性取出全部成员在内存里组装成树。def build_forest(): people Person.objects.all().order_by(birth_year, id) nodes { p.id: { id: p.id, name: p.name, birth_year: p.birth_year, parent_id: p.parent_id, children: [], } for p in people } forest [] for node in nodes.values(): parent nodes.get(node[parent_id]) if parent: parent[children].append(node) else: forest.append(node) return forestnodes.get(node[parent_id])比用if node[parent_id] in nodes少一次字典查找parent_id 为 None 时 get 返回 None自然进入 forest 变成根节点。整个循环 O(N)数据库只查一次。挂在闭包表上也无所谓闭包表管的是“集合”树形结构还是靠这个内存组装的父子关系。模板端用递归 include每个节点只负责渲染自己和 children 列表ul {% for node in tree.children %} li span{{ node.name }} {% if node.birth_year %}({{ node.birth_year }}){% endif %} /span {% if node.children %} {% include genealogy/tree_node.html with treenode %} {% endif %} /li {% endfor %} /ul注意 include 的变量名模板里递归的是tree.children所以 include 时必须with treenode否则子节点拿不到数据。很多教程用 Django 模板递归失败就是这个变量传递没写对。前端要做得好看可以在 span 上加点击事件展开收起子树并不需要上 Vue。这种内部录入查询工具用 Django 前后端分离是没必要的模板渲染够快维护也简单。5. 删除一个成员时别让整棵树跟着塌5.1 用事务加全量重建兜底家谱系统里“删人”是个危险动作靠 ORM 的 PROTECT 不会误删真删的时候也要自己处理子树。我的做法是“先摘孩子再删节点最后全量重建闭包表”from django.shortcuts import get_object_or_404 from django.db import transaction from .models import Person, Closure def safe_delete_person(person_id): with transaction.atomic(): node get_object_or_404(Person.objects.select_related(parent), pkperson_id) parent_id node.parent_id if node.parent else None child_ids list( Closure.objects .filter(ancestornode, depth__gt0) .values_list(descendant_id, flatTrue) ) Person.objects.filter(pk__inchild_ids).update(parentparent_id) node.delete() rebuild_closure_all() def rebuild_closure_all(): Closure.objects.all().delete() for person in Person.objects.all().select_related(parent): node person depth 0 while node is not None: Closure.objects.create( ancestornode, descendantperson, depthdepth ) depth 1 node node.parent这段逻辑的核心是要删的人是某个分支的中间节点他的所有后代不能变成孤儿所以先把这些后代整体挂到被删节点的父节点下如果删的是根后代就全部变成新的根。然后node.delete()触发 Person 外键的 PROTECT但因为我们事先把子节点都改挂走了这里不会报错。Closure 表里与 node 相关的记录会被 CASCADE 清掉但子树里其它成员之间原有的闭包关系也被误删了所以最后直接全量重建。全量重建的复杂度是 O(人数 × 树深度)几千人的家谱在毫秒级完成完全可接受。重建函数把闭包表当缓存处理增量维护漏掉的坑全部被一次重建抹平。admin.register(Person) class PersonAdmin(admin.ModelAdmin): list_display (name, gender, birth_year, death_year, parent) list_select_related (parent,) search_fields (name,) autocomplete_fields (parent,)admin 这里有两个参数容易被忽略。list_select_related如果不写成员列表展示 parent 一列时每条记录都要查一次父节点和 4.1 里 select_related 是同一个问题。autocomplete_fields依赖对应外键字段在 PersonAdmin 里有 search_fields否则后台会直接报错它是给上万人的大族谱准备的几千人用普通下拉框就行。录入成功后跳转时带一个 URL 参数可以做轻量提示不必动用 session 或消息框架return redirect(reverse(genealogy:person_detail, args[person.pk]) ?created1)详情页模板顶部加一句判断{% if request.GET.created 1 %}成员已保存。{% endif %}。URL 参数的缺点是刷新页面提示就消失但对录入人员来说反而合适——提示只出现一次不需要点关闭。到这里录入、查询、删除三个方向的坑和参数都齐了剩下的是你在真实数据上把 rebuild_closure_all 放进管理命令里跑一次。本文还有配套的精品资源点击获取