ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Django工程结构与Models设计实战:构建可维护后端系统

Django工程结构与Models设计实战:构建可维护后端系统 1. 项目概述为什么从Django工程创建和models操作开始就决定了你能不能真正落地一个后端系统刚接触Django的新手常有个错觉装好Python、pip install django、django-admin startproject点开浏览器看到“It worked!”就以为自己已经会了。我带过二十多个转行做后端的学员超过七成卡在第一个真实业务需求上——比如“做一个图书管理系统能录入书名、作者、ISBN支持按作者搜索、修改库存、删除下架书”。他们翻遍教程却找不到“怎么让数据库里真多出一条记录”“为什么admin页面点了保存但数据库没变化”“models改了字段旧数据怎么迁移”这类问题的答案。这不是学得不够快而是跳过了Django最核心的“工程-模型-数据流”三位一体逻辑。Django不是一堆零散命令的集合它是一套有严格生命周期约束的数据驱动架构工程结构决定模块边界models定义数据契约而增删改查CRUD是这套契约在运行时的唯一合法表达方式。你写的每一行models代码都在悄悄生成SQL DDL语句你调用的每一个save()方法背后都触发了完整的验证、信号、事务封装流程。这正是为什么标题里把“工程创建”和“models定义”并列——没有规范的工程结构models就是无根浮萍没有严谨的models设计工程再漂亮也只是空壳。本文不讲“Django有多强大”只聚焦你明天就要动手敲的三件事如何创建一个经得起三个月迭代的工程骨架、怎样写出既满足业务又兼容Django ORM特性的models、以及在真实场景中安全执行增删改查的完整链路。所有内容基于Django 4.2 LTS版本实测适配MySQL 8.0和PostgreSQL 15避开那些“教程里能跑上线就报错”的坑。2. 工程创建与目录结构设计不是复制粘贴而是为未来三个月的迭代埋下伏笔2.1 为什么不能直接用 django-admin startproject很多教程第一步就是django-admin startproject mysite这没错但问题在于这个命令生成的默认结构是为单体演示项目设计的不是为真实业务准备的。我去年重构一个电商后台时接手的代码就是用这种默认结构起家的——所有app全塞在根目录下settings.py里硬编码了MySQL密码static文件夹里混着Vue打包产物和Django模板。当需要增加微信小程序API接口时团队花了两天时间才理清哪些配置该抽到base.py、哪些静态资源该走CDN。Django官方文档其实早暗示了最佳实践startproject只负责创建最外层容器真正的业务模块必须通过python manage.py startapp独立创建。这背后是清晰的分层逻辑project是部署单元对应一个WSGI进程app是功能单元可复用、可插拔的业务模块。比如你要做学生管理系统合理的结构应该是student_system/ # project根目录由startproject生成 ├── manage.py ├── student_system/ # project配置包含settings.py等 │ ├── __init__.py │ ├── settings/ │ │ ├── __init__.py │ │ ├── base.py # 公共配置DEBUGFalse, INSTALLED_APPS等 │ │ ├── dev.py # 开发环境数据库地址、DEBUGTrue │ │ └── prod.py # 生产环境SECRET_KEY从环境变量读取 │ ├── urls.py │ └── wsgi.py ├── students/ # 独立app由startapp生成 │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── models.py # 专注学生相关数据模型 │ ├── views.py │ └── migrations/ # 迁移文件专属目录 └── requirements.txt提示settings/子目录方案比单个settings.py更安全。base.py里定义DEBUG Falsedev.py里from .base import *; DEBUG Trueprod.py里from .base import *; SECRET_KEY os.environ.get(DJANGO_SECRET_KEY)。这样开发时不会误把测试密钥提交到Git上线时也无需手动改代码。2.2 创建工程的实操步骤与关键参数选择我们以学生管理系统为例一步步创建可维护的工程第一步创建虚拟环境并安装Django# 推荐使用venvPython 3.6内置避免全局污染 python -m venv venv_student source venv_student/bin/activate # Linux/Mac # venv_student\Scripts\activate # Windows pip install --upgrade pip pip install Django4.2,4.3 # 锁定LTS版本避免小版本升级破坏兼容性第二步生成project骨架# 注意这里不直接用django-admin而是用manage.py的替代方案 django-admin startproject student_system . # 最后的.很重要它让project目录和当前文件夹同级避免嵌套过深第三步重构settings目录# 在student_system/目录下创建settings子目录 mkdir student_system/settings mv student_system/settings.py student_system/settings/base.py touch student_system/settings/__init__.py touch student_system/settings/dev.py touch student_system/settings/prod.py编辑student_system/settings/base.py关键配置如下import os from pathlib import Path BASE_DIR Path(__file__).resolve().parent.parent.parent # 向上三级到project根目录 SECRET_KEY your-secret-key-here # 开发环境临时值生产环境从环境变量读取 DEBUG False # 默认关闭由dev.py覆盖 # 安全配置生产环境必须 ALLOWED_HOSTS [localhost, 127.0.0.1] CSRF_COOKIE_SECURE True SESSION_COOKIE_SECURE True # 应用注册按功能分组便于管理 INSTALLED_APPS [ django.contrib.admin, django.contrib.auth, django.contrib.contenttypes, django.contrib.sessions, django.contrib.messages, django.contrib.staticfiles, # 第三方应用按需添加 django_extensions, # 提供runserver_plus等调试命令 # 本地应用核心业务模块 students, # 学生管理模块 courses, # 课程管理模块后续扩展 ]第四步创建独立app# 在project根目录下执行确保manage.py存在 python manage.py startapp students此时students/目录自动创建但需要手动注册到INSTALLED_APPS中。打开student_system/settings/base.py在INSTALLED_APPS末尾添加students。实操心得我坚持让每个app只负责一个业务域。比如“学生”和“教师”看似相似但权限、关联数据、业务规则完全不同必须拆成students/和teachers/两个app。曾有个项目把所有用户类型塞进一个users/app结果当教务处要求教师能看到学生课表、而学生不能看教师课表时权限逻辑爆炸式增长最后重构成三个独立app才解决。2.3 目录结构背后的工程哲学为什么这样设计能少踩80%的坑这种结构的价值在于它强制你思考三个关键问题1. 配置隔离性dev.py和prod.py分离后数据库密码、邮箱SMTP配置、第三方API密钥等敏感信息永远不会出现在同一份文件里。我见过太多团队因为DEBUGTrue被提交到生产环境导致整个数据库结构暴露在/admin/页面上。而分环境配置后python manage.py runserver --settingsstudent_system.settings.dev和gunicorn student_system.wsgi:application --settingsstudent_system.settings.prod天然隔离。2. 模块可移植性students/app里的models.py只依赖Django ORM不耦合任何其他app的代码。这意味着你可以把它打包成PyPI包复用到另一个学校管理系统中。去年帮某教育科技公司迁移老系统时直接把students/整个目录拷贝过去只改了两行settings.py里的数据库配置三天就完成了核心模块对接。3. 迁移文件自治性每个app的migrations/目录独立存放自己的数据库变更历史。当你执行python manage.py makemigrations students时Django只会扫描students/models.py生成students/migrations/0001_initial.py。这避免了“改了一个字段所有app的迁移文件全乱套”的灾难。某次线上事故就是因为有人在courses/app里加了个字段却忘了运行makemigrations courses结果部署时Django试图用旧迁移文件同步新字段直接锁死数据库。3. Models定义不只是写class而是用Python描述现实世界的约束规则3.1 Models的本质Django ORM的“数据契约”思维很多新手把models当成数据库表的简单映射“字段名数据库列名类型int→IntegerField”。这是危险的简化。Django models其实是一份双向契约对开发者它声明了“数据应该长什么样”对Django框架它承诺了“我能提供符合Django约定的元数据”。举个例子models.CharField(max_length100)不只是告诉Django“建个VARCHAR(100)的列”它还隐含了该字段不能为空除非显式指定blankTrue表单验证时会检查长度不超过100Admin界面自动生成文本输入框__str__()方法默认返回字符串值查询时支持icontains模糊搜索所以定义models的第一原则是先想清楚业务规则再选Django字段类型。比如学生管理系统中的“学号”业务规则是“全局唯一、不可修改、长度固定10位数字”。对应的models定义绝不是models.CharField(max_length10)而应是# students/models.py from django.db import models from django.core.validators import RegexValidator class Student(models.Model): # 学号业务强约束用正则确保格式 student_id models.CharField( primary_keyTrue, # 主键不可为空且唯一 max_length10, validators[RegexValidator(r^\d{10}$, 学号必须是10位数字)], help_text10位数字如2023000001 ) # 姓名业务要求非空但允许空格如“欧阳修” name models.CharField( max_length50, blankFalse, # 表单验证时必填 nullFalse # 数据库层面不允许NULL ) # 入学年份业务要求必须是2010-2030之间的整数 enrollment_year models.PositiveSmallIntegerField( choices[(year, str(year)) for year in range(2010, 2031)], help_text入学年份范围2010-2030 ) # 创建时间自动记录业务上不可修改 created_at models.DateTimeField(auto_now_addTrue) updated_at models.DateTimeField(auto_nowTrue) class Meta: verbose_name 学生 verbose_name_plural 学生 ordering [-created_at] # 默认按创建时间倒序 def __str__(self): return f{self.name}({self.student_id})注意blankFalse和nullFalse的区别至关重要。blank控制Django表单验证前端/后台null控制数据库约束MySQL/PostgreSQL。两者都设为False才能保证数据在任何入口都严格校验。3.2 字段类型选择指南避开90%的性能与兼容性陷阱Django字段类型的选择直接影响查询性能、存储空间和跨数据库兼容性。以下是学生管理系统中高频字段的实战选型逻辑业务字段错误选型正确选型为什么实测影响手机号CharField(max_length20)CharField(max_length15, validators[RegexValidator(r^1[3-9]\d{9}$)])手机号有严格格式用正则提前拦截错误输入max_length15足够含国际区号减少80%无效数据入库Admin搜索时自动索引前缀头像URLTextField()URLField()URLField自带URL格式验证且在PostgreSQL中可利用citext扩展实现大小写不敏感搜索避免http://和https://混存导致重复记录年级CharField(choices...)PositiveSmallIntegerField(choices...)SmallIntegerField在MySQL中只占2字节CharField至少占1字节变长开销数值比较比字符串比较快3倍百万级数据时filter(grade__gte2)比filter(grade__in[大二,大三])快400ms备注TextField()TextField(blankTrue, nullTrue)备注是可选字段blankTrue允许表单为空nullTrue允许数据库存NULL节省空间避免NOT NULL约束导致插入失败NULL字段在InnoDB中不占用额外空间特别提醒永远不要用DateTimeField存储时间戳。Django的auto_now_add和auto_now是方便但它们在数据库层面无法被其他程序如数据分析脚本、备份工具识别。正确做法是# 错误依赖Django自动填充 created_at models.DateTimeField(auto_now_addTrue) # 正确显式控制兼容所有场景 created_at models.DateTimeField(defaulttimezone.now) # 在views.py中创建对象时显式赋值 student Student(created_attimezone.now(), ...)3.3 关系字段设计一对多、多对多、一对一的业务语义落地学生管理系统必然涉及关系。Django的关系字段不是技术选择而是业务建模的体现1. 一对多ForeignKey班级与学生业务规则“一个学生属于一个班级一个班级有多个学生”。注意on_delete参数不是可选项而是业务逻辑的声明class Class(models.Model): name models.CharField(max_length20) # 如“计算机2023级1班” class Student(models.Model): # 错误on_deletemodels.CASCADE会导致删班级时连带删所有学生 # class_obj models.ForeignKey(Class, on_deletemodels.CASCADE) # 正确业务上禁止删除有学生的班级用PROTECT class_obj models.ForeignKey( Class, on_deletemodels.PROTECT, # 删除班级时抛出ProtectedError related_namestudents, # 反向查询class_obj.students.all() help_text所属班级 )2. 多对多ManyToManyField学生与课程业务规则“一个学生可选多门课一门课可被多个学生选”。Django自动生成中间表但要注意class Course(models.Model): name models.CharField(max_length50) class Student(models.Model): # 自动创建student_course中间表 courses models.ManyToManyField( Course, throughEnrollment, # 指定自定义中间模型当需要存储选课时间、成绩等属性时 related_namestudents ) # 自定义中间模型当需要额外字段 class Enrollment(models.Model): student models.ForeignKey(Student, on_deletemodels.CASCADE) course models.ForeignKey(Course, on_deletemodels.CASCADE) enrollment_date models.DateField(auto_now_addTrue) grade models.CharField(max_length2, blankTrue) # 成绩可为空3. 一对一OneToOneField学生与档案业务规则“每个学生有且仅有一个学籍档案档案信息敏感需单独表存储”。这常被误用为“拆分大表”但实际是权限隔离class StudentProfile(models.Model): student models.OneToOneField( Student, on_deletemodels.CASCADE, primary_keyTrue, # 用student_id作为主键避免冗余ID related_nameprofile ) id_card_number models.CharField(max_length18, uniqueTrue) # 身份证号 address models.TextField() # 敏感字段集中在此表可单独设置数据库权限实操心得我在三个项目中发现90%的性能问题源于关系字段滥用。比如用ManyToManyField存储“学生-兴趣爱好”当兴趣爱好超过1000种时中间表查询变慢。正确做法是建立StudentInterest模型用limit_choices_to{status: active}限制可选范围并为student_id和interest_id添加联合索引。4. 增删改查CRUD的完整实现从命令行到视图函数的全链路解析4.1 命令行操作快速验证models与数据库连接在写视图前先用Django Shell验证基础CRUD是否通路。这是排查“为什么save()不生效”的第一现场# 进入Django Shell自动加载所有models python manage.py shell # 1. 创建Create from students.models import Student s Student(student_id2023000001, name张三, enrollment_year2023) s.save() # 触发INSERT SQL s.id # Django自动分配的自增ID如果没设primary_key 1 # 2. 查询Read # 主键查询最快走主键索引 Student.objects.get(student_id2023000001) Student: 张三(2023000001) # 条件查询注意get()只返回1条all()返回QuerySet Student.objects.filter(name__icontains张).count() 1 # 链式查询惰性执行直到调用len()或for循环 students Student.objects.filter(enrollment_year2023).order_by(-created_at) list(students) # 此时才真正执行SQL # 3. 更新Update s Student.objects.get(student_id2023000001) s.name 张三丰 s.save() # UPDATE语句只更新修改的字段 # 4. 删除Delete s.delete() # DELETE语句返回(1, {students.Student: 1})提示get()和filter()的区别是生死线。get()在无结果时抛DoesNotExist异常有多个结果时抛MultipleObjectsReturned异常filter()永远返回QuerySet可能为空。业务代码中必须用try...except包裹get()而filter()可直接.exists()判断。4.2 视图函数中的安全CRUD避免N1查询与事务陷阱Web请求中的CRUD必须考虑并发、事务和性能。以下是一个安全的学生创建视图# students/views.py from django.shortcuts import render, get_object_or_404, redirect from django.http import HttpResponse from django.db import transaction from django.contrib import messages from .models import Student def student_create(request): if request.method POST: # 1. 数据提取避免直接request.POST防止XSS student_id request.POST.get(student_id, ).strip() name request.POST.get(name, ).strip() enrollment_year request.POST.get(enrollment_year) # 2. 业务校验比models的validators更细粒度 if not student_id or len(student_id) ! 10 or not student_id.isdigit(): messages.error(request, 学号必须是10位数字) return render(request, students/create.html) if Student.objects.filter(student_idstudent_id).exists(): messages.error(request, 学号已存在) return render(request, students/create.html) # 3. 数据库操作包裹在事务中确保原子性 try: with transaction.atomic(): student Student.objects.create( student_idstudent_id, namename, enrollment_yearint(enrollment_year) ) messages.success(request, f学生 {student.name} 创建成功) return redirect(student_detail, pkstudent.pk) except Exception as e: messages.error(request, f创建失败{str(e)}) return render(request, students/create.html) return render(request, students/create.html) def student_list(request): # 优化预加载关联数据避免N1查询 # 如果Student有ForeignKey到Class则用select_related # students Student.objects.select_related(class_obj).all() # 分页避免一次性加载全部数据 from django.core.paginator import Paginator students Student.objects.all().order_by(-created_at) paginator Paginator(students, 20) # 每页20条 page_number request.GET.get(page) page_obj paginator.get_page(page_number) return render(request, students/list.html, {page_obj: page_obj})关键安全点解析事务包装transaction.atomic()确保创建学生和关联操作如发通知邮件要么全成功要么全回滚。防重复提交Student.objects.filter(student_id...).exists()在创建前检查避免并发时重复插入。分页强制Paginator防止Student.objects.all()加载百万级数据拖垮内存。消息系统messages框架传递用户反馈比HttpResponse更友好。4.3 Admin后台的深度定制不只是增删改查而是业务工作台Django Admin是被严重低估的生产力工具。它不只是CRUD界面而是可定制的业务工作台# students/admin.py from django.contrib import admin from django.urls import reverse from django.utils.html import format_html from .models import Student, Class admin.register(Student) class StudentAdmin(admin.ModelAdmin): # 1. 列表页显示字段避免select * list_display [student_id, name, enrollment_year, class_link, created_at] list_display_links [student_id, name] # 可点击进入编辑页 # 2. 添加自定义列关联字段链接 def class_link(self, obj): if obj.class_obj: url reverse(admin:students_class_change, args[obj.class_obj.pk]) return format_html(a href{}{}/a, url, obj.class_obj.name) return - class_link.short_description 班级 # 列标题 # 3. 搜索与过滤提升效率 search_fields [student_id, name, class_obj__name] # 支持跨表搜索 list_filter [enrollment_year, class_obj] # 右侧过滤栏 # 4. 表单定制隐藏敏感字段设置默认值 exclude [created_at, updated_at] # 隐藏自动时间字段 readonly_fields [student_id] # 学号不可编辑 # 5. 批量操作业务刚需 actions [mark_as_graduated] def mark_as_graduated(self, request, queryset): # 批量更新将选中学生标记为毕业假设新增graduated字段 updated queryset.update(graduatedTrue) self.message_user(request, f成功标记{updated}名学生为毕业) mark_as_graduated.short_description 标记为毕业 admin.register(Class) class ClassAdmin(admin.ModelAdmin): list_display [name, student_count] def student_count(self, obj): # 避免N1用annotate预计算 return obj.students.count() student_count.short_description 学生人数实操心得Admin定制的核心是“减少鼠标点击”。比如search_fields里加入class_obj__name运营人员就能直接搜“计算机2023级1班”找到所有学生不用先找班级再点进去。去年帮某高校做系统时把list_display从5个字段精简到3个核心字段配合search_fields教务员处理1000条数据的时间从45分钟降到8分钟。5. 常见问题与排查技巧实录那些文档里不会写的血泪教训5.1 迁移文件Migrations问题从“makemigrations不生成”到“migrate报错”的全链路排查问题1修改models后makemigrations不生成新文件现象改了Student.name的max_length100但python manage.py makemigrations输出“No changes detected”。原因Django的迁移检测基于migrations/目录下的历史文件而非数据库实际结构。如果之前有未应用的迁移文件Django认为“当前状态”就是那个未应用的状态。解决方案先检查是否有未应用的迁移python manage.py showmigrations如果有[ ]标记的先python manage.py migrate应用再makemigrations问题2migrate时报错“Table xxx doesnt exist”现象新建app后首次migrate提示表不存在。原因Django默认为每个app生成迁移文件但django_migrations表本身需要初始化。解决方案# 强制创建初始迁移仅第一次 python manage.py migrate --fake-initial # 后续正常迁移 python manage.py migrate问题3多人协作时迁移文件冲突现象A同学提交了0002_add_field.pyB同学本地也有0002_auto_2023.pymigrate报错。解决方案B同学先git pull拉取最新代码执行python manage.py makemigrations --empty students生成空迁移文件编辑该文件在dependencies中添加(students, 0002_add_field)在operations中手动写入B同学需要的变更如migrations.AddField(...)python manage.py migrate提示我强制团队使用--name参数为迁移文件命名如python manage.py makemigrations --name add_student_phone students避免auto_带来的命名混乱。5.2 查询性能问题从“页面加载慢”到“EXPLAIN分析”的定位路径问题学生列表页加载超10秒排查步骤开启Django Debug Toolbar在settings/dev.py中添加INSTALLED_APPS [debug_toolbar] MIDDLEWARE [debug_toolbar.middleware.DebugToolbarMiddleware] INTERNAL_IPS [127.0.0.1]观察SQL查询数如果显示“32 queries in 8.2s”说明存在N1问题。定位N1源头在视图中检查是否用了for student in students:然后访问student.class_obj.name。修复改为Student.objects.select_related(class_obj).all()使Django生成JOIN查询。问题filter(name__icontains张)慢原因icontains在MySQL中无法使用普通索引需全文索引或前缀索引。解决方案-- MySQL中为name字段添加前缀索引20字符足够中文姓名 ALTER TABLE students_student ADD INDEX idx_name_prefix (name(20));5.3 数据一致性问题事务、信号与缓存的三角困境问题学生创建后Redis缓存未更新导致前端看到旧数据场景用cache.set(student_list, students, 300)缓存列表但create视图中忘了cache.delete(student_list)。解决方案用Django信号解耦# students/signals.py from django.db.models.signals import post_save, post_delete from django.dispatch import receiver from django.core.cache import cache from .models import Student receiver([post_save, post_delete], senderStudent) def clear_student_cache(sender, **kwargs): cache.delete(student_list) # 更精细的缓存删除特定学生缓存 if hasattr(kwargs[instance], student_id): cache.delete(fstudent_{kwargs[instance].student_id})在apps.py中注册信号# students/apps.py from django.apps import AppConfig class StudentsConfig(AppConfig): default_auto_field django.db.models.BigAutoField name students def ready(self): import students.signals # 导入信号模块实操心得信号是双刃剑。我曾在一个项目中过度使用post_save发送邮件结果高并发时邮件队列堆积拖垮整个数据库。现在我的原则是信号只做轻量级操作如缓存清理、日志记录重操作如发邮件、调用外部API扔进Celery异步队列。5.4 安全漏洞规避从“SQL注入”到“越权访问”的防御清单风险类型危险代码安全写法原理SQL注入Student.objects.extra(where[name%s % request.GET.get(q)])Student.objects.filter(name__icontainsrequest.GET.get(q, ))Django ORM自动转义参数避免拼接SQL越权访问Student.objects.get(pkpk)无权限检查Student.objects.get(pkpk, userrequest.user)关联用户或get_object_or_404(Student, pkpk, ownerrequest.user)强制业务字段过滤避免用户篡改URL参数访问他人数据Mass AssignmentStudent.objects.create(**request.POST.dict())form StudentForm(request.POST); form.save()用ModelForm校验ModelForm自动过滤exclude字段防止恶意提交is_staffTrue等敏感字段最后分享一个真实案例某在线教育平台的学生信息导出功能后端用Student.objects.all().values()生成CSV但没加权限控制。攻击者构造?formatcsvlimit1000000直接拖走全部学生手机号。修复方案是在视图中强制分页并添加if not request.user.is_staff: raise PermissionDenied()。6. 工程收尾与持续演进从第一个CRUD到可维护系统的跨越完成上述步骤后你的学生管理系统已具备生产可用的基础。但真正的工程价值不在“能跑”而在“易维护”。我建议立即执行的三件事1. 初始化Git仓库并设置.gitignoregit init echo venv_student/ .gitignore echo __pycache__/ .gitignore echo *.pyc .gitignore echo student_system/settings/dev.py .gitignore # 敏感配置不提交 git add . git commit -m feat: initial student system with CRUD2. 编写第一个自动化测试# students/tests.py from django.test import TestCase from django.urls import reverse from .models import Student class StudentModelTest(TestCase): def setUp(self): self.student Student.objects.create( student_id2023000001, name测试学生, enrollment_year2023 ) def test_student_str_method(self): self.assertEqual(str(self.student), 测试学生(2023000001)) def test_student_creation(self): self.assertEqual(Student.objects.count(), 1)运行python manage.py test students。测试通过才是真正的“功能完成”。3. 部署前的最后检查清单[ ]python manage.py check --deployDjango部署检查如DEBUGFalse时ALLOWED_HOSTS是否为空[ ]python manage.py makemigrations --check确保无未提交的迁移[ ]python manage.py collectstatic --noinput收集静态文件Admin界面依赖[ ]python manage.py compilemessages编译国际化文件如有我坚持一个观点Django项目的成熟度不取决于功能多少而取决于第一个CRUD能否在30分钟内被新成员独立复现。当你把工程结构、models设计、CRUD链路、问题排查都沉淀为可复制的模式那些“后端开发除了增删改查还有什么”的困惑自然会变成“下一步要加什么业务规则”的思考。毕竟所有复杂的系统都是从一个学生、一本书、一次借阅开始的。
返回列表