基于Django的藏品管理系统实战:数据建模与状态机设计解析
简介基于Python和Django框架构建的博物馆藏品数字化管理系统设计与实现资料包面向文博行业信息化建设者、后端开发工程师及全栈学习者系统以藏品为核心涵盖藏品档案、分类管理、库位管理、出入库与修复管理、权限控制和审计日志覆盖入藏、编目、保管、流转、修复全生命周期提供一套可落地的数字化解决方案。压缩包内仅一个DOCX文档大小114KB内容编排完整从项目背景、目标与挑战到业务模型、数据表结构、状态机设计、RESTful API接口和前端调用均有详细讲解尤其适合作为复杂业务系统的综合案例学习。目前已有122人下载学习。文档不仅给出模型、序列化器、视图集与路由配置的代码片段还系统梳理了出入库状态控制、多条件检索、统计聚合、图片与数字资源存储、权限与审计日志等难点解法并包含数据库设计、界面设计要点与部署应用思路目录按项目阶段组织便于按专题查阅是理解Django工程化落地和前后端分离开发流程的实用参考资料。读者可从数据模型、业务接口与前端交互三条主线切入深入掌握复杂业务系统的设计方法。1. 藏品管理系统的落点从纸质台账到状态机做过文博信息化的人都有一个共识藏品管理最难的不是录入而是状态一致性和责任追溯。传统模式里一件藏品的基本信息可能躺在 Excel 里照片散落在共享文件夹借展记录写在本子上修复报告又是另一套 PDF。当藏品数量到几千件、出入库频率上来之后同一个编号在三个地方的状态完全可能不一样——系统里是在库纸质登记是已借出而实物其实在修复室。这个系统的核心价值就是把藏品当作业务中心用 Django 的 ORM 建出一套关系型数据模型让每件藏品的档案、图片、出入库记录、修复记录、审计日志形成一条完整的数据链。整套方案使用的是前后端分离架构Django 提供 RESTful API前端用 Vue.js 构建界面MySQL 负责存储结构化数据图片资源走独立文件目录。这篇文章会把模型设计、状态机控制、检索算法和部署调优的关键细节拆开讲清楚包括建表 SQL、ORM 模型、序列化器和视图集的完整写法以及踩过的一些坑。2. 领域模型与关系型数据结构先定库表再写代码藏品管理系统的数据模型核心是回答三个问题一件藏品是什么、放在哪里、经历了什么。围绕这三个问题需要设计出至少六张相互关联的核心表它们分别是藏品的分类字典表、库房库位表、藏品主档案表、图片资源表、修复记录表、出入库记录表以及贯穿全文的审计日志表。2.1 分类字典与库位分层把自由文本变成受控选项分类字典表是整个系统的数据基石。这里说的分类不只是青铜器书画这种门类还包括材质、工艺、时代、收藏级别这些多维度的受控词汇。用字典表而不是直接在藏品表里写字符串原因在于同一个材质在不同人的录入习惯下会产生青铜铜器铜质三种表达后续检索和统计的时候会直接崩掉。先看分类和字典表的设计CREATE TABLE collection_category ( id INT PRIMARY KEY AUTO_INCREMENT, parent_id INT DEFAULT NULL COMMENT 父级分类ID支持多级分类, category_code VARCHAR(32) NOT NULL UNIQUE COMMENT 分类编码, category_name VARCHAR(128) NOT NULL COMMENT 分类名称, sort_order INT DEFAULT 0 COMMENT 排序值, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (parent_id) REFERENCES collection_category(id) ); CREATE TABLE dict_entry ( id INT PRIMARY KEY AUTO_INCREMENT, dict_type VARCHAR(64) NOT NULL COMMENT 字典类型material/era/level/source, dict_code VARCHAR(64) NOT NULL COMMENT 字典编码, dict_label VARCHAR(128) NOT NULL COMMENT 显示名称, is_active TINYINT DEFAULT 1, UNIQUE KEY uk_dict_type_code (dict_type, dict_code) );这两个表的设计要点在于parent_id自关联支持任意层级的分类树dict_type加上dict_code的联合唯一键确保每个业务维度下的字典项不重复。加载的时候通常会把整个字典表缓存到内存里构建一个两级 Map类型→编码→标签避免每条藏品记录都去连表查询。2.2 藏品主档案表把一件文物的维度铺开藏品主档案表是整条数据链的主干。每个字段的选择都有业务背景不做统一约束的话后续的检索和统计都会很吃力。以下是一张实际可用的建表语句CREATE TABLE collection_item ( id INT PRIMARY KEY AUTO_INCREMENT, inventory_no VARCHAR(64) NOT NULL UNIQUE COMMENT 全馆唯一登记编号, name VARCHAR(256) NOT NULL COMMENT 藏品名称, category_id INT NOT NULL COMMENT 所属分类ID, era VARCHAR(64) COMMENT 文化时期/年代, material VARCHAR(64) COMMENT 主要材质, size_desc VARCHAR(256) COMMENT 尺寸描述保留自由文本, weight_gram DECIMAL(10,2) COMMENT 重量克, completeness VARCHAR(16) COMMENT 完残情况完整/残缺/修复, source_info VARCHAR(512) COMMENT 来源信息, acquisition_type VARCHAR(32) COMMENT 入藏方式征集/捐赠/拨交, collect_date DATE COMMENT 入藏日期, level VARCHAR(16) COMMENT 收藏级别一级/二级/三级/未定级, status VARCHAR(16) DEFAULT in_stock COMMENT 状态机in_stock/on_exhibit/repairing/on_loan, location_id INT COMMENT 当前库位ID, custodian VARCHAR(64) COMMENT 责任保管人, description TEXT COMMENT 描述支持长文本, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_inventory_no (inventory_no), KEY idx_category_id (category_id), KEY idx_era (era), KEY idx_material (material), KEY idx_status (status), FOREIGN KEY (category_id) REFERENCES collection_category(id), FOREIGN KEY (location_id) REFERENCES storage_location(id) );在 Django 的 models.py 里对应模型的定义方式稍有不同需要显式声明关联关系和索引from django.db import models class CollectionItem(models.Model): STATUS_CHOICES [ (in_stock, 在库), (on_exhibit, 展出中), (repairing, 修复中), (on_loan, 借展中), ] inventory_no models.CharField(max_length64, uniqueTrue, verbose_name登记编号) name models.CharField(max_length256, verbose_name藏品名称) category models.ForeignKey(CollectionCategory, on_deletemodels.PROTECT, verbose_name分类) era models.CharField(max_length64, blankTrue, verbose_name年代) material models.CharField(max_length64, blankTrue, verbose_name材质) size_desc models.CharField(max_length256, blankTrue, verbose_name尺寸描述) weight_gram models.DecimalField(max_digits10, decimal_places2, nullTrue, blankTrue) completeness models.CharField(max_length16, choices[(complete, 完整), (incomplete, 残缺), (repaired, 修复)]) source_info models.CharField(max_length512, blankTrue, verbose_name来源) acquisition_type models.CharField(max_length32, blankTrue, verbose_name入藏方式) collect_date models.DateField(nullTrue, blankTrue, verbose_name入藏日期) level models.CharField(max_length16, blankTrue, verbose_name收藏级别) status models.CharField(max_length16, choicesSTATUS_CHOICES, defaultin_stock, verbose_name状态) location models.ForeignKey(StorageLocation, on_deletemodels.SET_NULL, nullTrue, blankTrue) custodian models.CharField(max_length64, blankTrue, verbose_name保管人) description models.TextField(blankTrue, verbose_name描述) created_at models.DateTimeField(auto_now_addTrue) updated_at models.DateTimeField(auto_nowTrue) class Meta: db_table collection_item indexes [ models.Index(fields[era]), models.Index(fields[material]), models.Index(fields[status]), ]这里有两个容易踩坑的点。第一on_deletemodels.PROTECT用于分类外键目的是防止删除一个已经被藏品引用的分类。如果用CASCADE删分类会连带把藏品全部删掉这是生产环境绝对不能接受的。第二inventory_no要加唯一索引并且在写入前检查重复。实际运营中经常发生的问题是两个录入员几乎同时录入同一批征集品导致编号撞车。2.3 图片、修复与出入库模型建立业务动作的数据闭环有了主档案还需要为藏品的动态数据建模。这里设计了三个关联表分别覆盖数字影像、修复过程和流转行为class CollectionImage(models.Model): collection models.ForeignKey(CollectionItem, on_deletemodels.CASCADE, related_nameimages) image_type models.CharField(max_length16, choices[ (main, 主图), (detail, 细节图), (pattern, 纹饰图), (compare, 修复对比图) ], defaultdetail) file_path models.CharField(max_length512, verbose_name文件存储路径) thumbnail_path models.CharField(max_length512, blankTrue) file_size models.IntegerField(default0, verbose_name文件大小字节) md5_hash models.CharField(max_length32, verbose_name文件MD5用于完整性校验) copyright_info models.CharField(max_length128, blankTrue, verbose_name版权信息) uploaded_by models.CharField(max_length64, blankTrue) uploaded_at models.DateTimeField(auto_now_addTrue) class RepairRecord(models.Model): collection models.ForeignKey(CollectionItem, on_deletemodels.CASCADE, related_namerepairs) disease_type models.CharField(max_length128, verbose_name病害类型) disease_desc models.TextField(verbose_name病害描述) treatment_plan models.TextField(verbose_name处理方案) materials_used models.CharField(max_length512, blankTrue, verbose_name使用材料) repairer models.CharField(max_length64, verbose_name修复人员) start_date models.DateField() end_date models.DateField(nullTrue, blankTrue) before_image models.ForeignKey(CollectionImage, on_deletemodels.SET_NULL, nullTrue, related_name) after_image models.ForeignKey(CollectionImage, on_deletemodels.SET_NULL, nullTrue, related_name) class InOutRecord(models.Model): collection models.ForeignKey(CollectionItem, on_deletemodels.CASCADE, related_nameinout_records) io_type models.CharField(max_length8, choices[(in, 入库), (out, 出库)]) purpose models.CharField(max_length256, verbose_name出库用途) receiver_unit models.CharField(max_length256, blankTrue, verbose_name接收单位) handler models.CharField(max_length64, verbose_name经办人) approver models.CharField(max_length64, verbose_name审批人) apply_time models.DateTimeField(auto_now_addTrue) approve_status models.CharField(max_length16, defaultpending, choices[ (pending, 待审批), (approved, 已批准), (rejected, 已驳回) ]) expected_return_date models.DateField(nullTrue, blankTrue) actual_return_time models.DateTimeField(nullTrue, blankTrue) remark models.TextField(blankTrue)图片表的md5_hash字段值得单独说。文件存储路径可以改但MD5是内容的指纹。上传完成后计算一次MD5存进库里后续做完整性巡检的时候重新算一遍文件MD5比对就知道文件有没有损坏或被替换。图片外键在修复记录里用related_name的目的是取消反向关联——从图片对象反向查找修复记录没有业务意义反而会拖慢查询。3. 出入库状态机与事务锁定状态为什么不能直接改藏品状态是整个系统里最敏感的字段。一套合理的状态管理机制不是让操作员去下拉框里手动改状态而是通过出库申请、审批、归还这一套流程自动完成状态迁移。这里涉及状态机的设计、事务边界的划分以及并发控制。3.1 状态迁移的事件驱动设计状态机的迁移逻辑可以抽象成以下规则表当前状态触发事件目标状态前置条件in_stock出库审批通过on_exhibit / on_loan待审批记录存在且审批人通过in_stock开始修复repairing有修复任务且修复人员确认on_exhibit归还入库in_stock实物清点无异常repairing修复完成in_stock修复报告已提交on_loan归还入库in_stock接收方确认归还在代码层面用 Django 的transaction.atomic()包裹业务操作确保状态字段、出入库记录、审计日志三者在同一个数据库事务里提交from django.db import transaction, IntegrityError transaction.atomic def approve_outbound(record_id, approver): try: record InOutRecord.objects.select_for_update().get(idrecord_id) # 记录处于pending状态并且拥有者是当前申请单 if record.approve_status ! pending: raise ValueError(f该申请单已处理当前状态: {record.approve_status}) collection CollectionItem.objects.select_for_update().get(idrecord.collection_id) if collection.status ! in_stock: raise ValueError(f藏品当前状态为{collection.status}不允许出库) record.approve_status approved record.approver approver record.save() collection.status on_loan if record.purpose 借展 else on_exhibit collection.save() AuditLog.objects.create( target_typeinout, target_idrecord.id, actionapprove_outbound, operatorapprover, before_statuspending, after_statusapproved ) except IntegrityError: raise ValueError(并发操作冲突本次审批已回滚)这里的关键是select_for_update()。它会对命中的行加数据库级排他锁直到事务结束。没有这把锁的话两个审批人同时打开同一张申请单第一个人的更新会被第二个人的覆盖这在藏品出库场景里是不可接受的。事务回滚也是自动的任何一个步骤抛出异常前面的save()操作全部撤回不会出现记录显示已审批但藏品状态没变的不一致问题。3.2 权限分级与审计日志的实现权限这块用的是 Django 自带的django.contrib.auth框架加上自定义的权限位点。首先要定义清楚角色边界系统管理员拥有全部模块的权限藏品管理员负责主档案的新增和编辑库房管理员只接触库位和出入库模块修复人员只能新建和编辑修复记录普通研究人员只读。这里把权限控制的代码剥出来看from django.contrib.auth.models import Group, Permission from django.contrib.contenttypes.models import ContentType # 初始化角色组 roles { admin: [add_collectionitem, change_collectionitem, delete_collectionitem, approve_inout, view_auditlog], collection_manager: [add_collectionitem, change_collectionitem], storage_manager: [add_inoutrecord, change_inoutrecord, view_storagelocation], restorer: [add_repairrecord, change_repairrecord], researcher: [view_collectionitem, view_repairrecord], } for role_name, perms in roles.items(): group, _ Group.objects.get_or_create(namerole_name) for perm_codename in perms: try: perm Permission.objects.get(codenameperm_codename) group.permissions.add(perm) except Permission.DoesNotExist: pass配合视图集里的权限控制在 Django REST Framework 的序列化器视图里用get_permissions方法做动态判断。出库审批接口只允许管理员角色通过修复记录的新建接口则校验修复人员角色。4. 多条件检索算法与统计聚合组合查询的具体实现藏品检索是系统里最考验性能的能力点。字段多、数据量大、用户习惯差异大——有人记的是名称有人记的是编号有人只记得大概年代和材质。这需要一个支持组合条件的检索接口并且排序策略要合理。4.1 基于 Q 对象的动态条件拼接Django 的 ORM 用Q对象可以方便地实现动态条件组合。核心思路是接收前端传过来的查询参数逐个判断是否为空非空的参数通过Q对象拼接进查询集from django.db.models import Q, Count from rest_framework.views import APIView from rest_framework.response import Response from .models import CollectionItem class CollectionSearchView(APIView): def get(self, request): params request.query_params queryset CollectionItem.objects.all() # 登记编号前缀模糊匹配 普通模糊匹配 inventory_no params.get(inventory_no) if inventory_no: queryset queryset.filter(inventory_no__icontainsinventory_no) # 名称支持两个维度的匹配完整包含和分词后的任意词命中 name params.get(name) if name: queryset queryset.filter( Q(name__icontainsname) | Q(description__icontainsname) ) # 分类传的是分类树的节点ID需要连同子分类一起查 category_id params.get(category_id) if category_id: category_ids [int(category_id)] from .models import CollectionCategory # 递归获取所有子分类ID child_ids CollectionCategory.objects.filter(parent_idcategory_id).values_list(id, flatTrue) category_ids.extend(child_ids) queryset queryset.filter(category_id__incategory_ids) # 数值范围查询重量区间 weight_min params.get(weight_min) weight_max params.get(weight_max) if weight_min: queryset queryset.filter(weight_gram__gtefloat(weight_min)) if weight_max: queryset queryset.filter(weight_gram__ltefloat(weight_max)) # 状态精确匹配 status params.get(status) if status: queryset queryset.filter(statusstatus) # 保管人 custodian params.get(custodian) if custodian: queryset queryset.filter(custodiancustodian) # 排序策略默认更新时间倒序指定字段时按指定字段排序 ordering params.get(ordering, -updated_at) queryset queryset.order_by(ordering) # 分页 page int(params.get(page, 1)) page_size int(params.get(page_size, 20)) total queryset.count() offset (page - 1) * page_size items queryset[offset:offset page_size] from .serializers import CollectionListSerializer serializer CollectionListSerializer(items, manyTrue) return Response({ total: total, page: page, page_size: page_size, results: serializer.data })5. 数据看板与统计聚合GROUP BY 在 Django 里的正确姿势藏品数据的统计聚合是管理层最常使用的功能也是信息量密度最高的数据看板部分。按分类统计收藏量、按年代分布统计数量、按保存状态统计风险分布以及计算最近三十天的出入库趋势都是典型的聚合查询场景。直接看代码实现。这里需要按分类统计、按年代统计、按状态统计三组聚合同时展示总数和最近一个月的新增藏品数。from django.db.models.functions import TruncDate from django.utils import timezone from datetime import timedelta class DashboardStatsView(APIView): def get(self, request): queryset CollectionItem.objects.all() from django.db.models import Count # 按分类聚合统计 category_stats queryset.values(category__category_name).annotate( cntCount(id) ).order_by(-cnt) # 按年代聚合统计 era_stats queryset.values(era).annotate( cntCount(id) ).order_by(-cnt)[:10] # 按状态聚合统计 status_stats queryset.values(status).annotate( cntCount(id) ) # 最近30天新增藏品趋势 thirty_days_ago timezone.now() - timedelta(days30) daily_additions queryset.filter( created_at__gtethirty_days_ago ).annotate( dayTruncDate(created_at) ).values(day).annotate(cntCount(id)).order_by(day) return Response({ total_collections: queryset.count(), by_category: [{ category: item[category__category_name], count: item[cnt] } for item in category_stats], by_era: list(era_stats), by_status: [{ status: item[status], count: item[cnt] } for item in status_stats], last_30_days: [{ date: item[day].strftime(%Y-%m-%d), count: item[cnt] } for item in daily_additions] })这段代码里有两个值得留意的点。第一values(category__category_name)会做一次 JOIN 连表操作把分类名直接取回来省去了遍历字典的二次查询。第二TruncDate是把 datetime 字段截断成日期这样才能对同一天的数据做分组。需要特别注意分组字段的 NULL 值问题Filter掉空字符串和 None 值否则统计结果里会出现一个空白分组查数据的时候很难排查。6. 前端页面与联调Vue3 组合式 API 对接 Django 后端后端 API 的地址是/api/v1/collections/端口默认运行在8000前端项目使用 Vue3 加 Vite 构建。前后端联调的第一步是配置跨域。用 Cors 中间件处理跨域请求然后在settings.py里配置允许的源。# settings.py 部分配置 INSTALLED_APPS [ # ...其他应用 corsheaders, rest_framework, ] MIDDLEWARE [ # ...其他中间件 corsheaders.middleware.CorsMiddleware, ] CORS_ALLOWED_ORIGINS [ http://localhost:5173, http://127.0.0.1:5173, ] # API 基础路径配置 REST_FRAMEWORK { DEFAULT_PAGINATION_CLASS: rest_framework.pagination.PageNumberPagination, PAGE_SIZE: 10, DEFAULT_AUTHENTICATION_CLASSES: [ rest_framework.authentication.SessionAuthentication, rest_framework.authentication.BasicAuthentication, ], DEFAULT_PERMISSION_CLASSES: [ rest_framework.permissions.IsAuthenticated ], }前端使用 Vite 启动的默认地址是http://localhost:5173。生产环境需要把 domain 换成实际的域名或 IP跨域白名单不能使用*。API 接口的设计要注意基础路径/api/v1带版本号后续后端改接口不会影响到已上线的旧版本调用方。6.1 藏品列表页面组件列表页面的核心逻辑是进入页面时先通过 Axios 请求第一页数据把总数、当前页数据渲染到前端表格里同时在页面下面渲染分页按钮或滚动加载。分页用的 DRF 自带的PageNumberPagination前端通过page参数控制页数。template main classcollection-list header h2藏品检索/h2 el-input v-modelsearchForm.inventory_no placeholder登记编号 clearable / el-input v-modelsearchForm.name placeholder藏品名称 clearable / el-button typeprimary clickfetchList(1)查询/el-button el-button clickresetForm重置/el-button /header el-table :datatableData stripe el-table-column propinventory_no label登记编号 width180 / el-table-column propname label名称 min-width200 / el-table-column propcategory_name label分类 width120 / el-table-column propera label年代 width120 / el-table-column propstatus label状态 width100 / el-table-column label操作 width150 template #default{ row } el-button sizesmall clickgoDetail(row.id)详情/el-button /template /el-table-column /el-table el-pagination v-model:current-pagecurrentPage :totaltotal :page-sizepageSize layouttotal, prev, pager, next current-changefetchList(currentPage) / /main /template script setup import { ref, reactive, onMounted } from vue import axios from axios const baseURL /api/v1 const api axios.create({ baseURL, timeout: 10000, headers: { Content-Type: application/json, Authorization: Token ${localStorage.getItem(token)} } }) const searchForm reactive({ inventory_no: , name: }) const tableData ref([]) const currentPage ref(1) const total ref(0) const pageSize ref(10) async function fetchList(page 1) { const params { page, page_size: pageSize.value } if (searchForm.inventory_no.trim()) { params.inventory_no searchForm.inventory_no.trim() } if (searchForm.name.trim()) { params.name searchForm.name.trim() } const resp await api.get(/collections/, { params }) // 后端返回的是 { total, results } 结构 tableData.value resp.data.results.map(item ({ ...item, category_name: item.category ? item.category.category_name : 未分类 })) total.value resp.data.total } function resetForm() { searchForm.inventory_no searchForm.name fetchList(1) } onMounted(() { fetchList(1) }) /script这段代码在模板里展示了列表页所需的完整交互逻辑查询条件绑定到表单数据对象、点击查询或重置后重新请求第一页、表格数据从接口响应里的results字段取出来、映射分类字段。注意api这个 Axios 实例的封装方式Authorization头部从 localStorage 里取 Token这在登录后写入后续所有接口自动携带。如果想要做一个真正完整的调用还需要在 CDN 上接入静态资源。在这个 GUI 场景下代码运行的关键语言是 Python 和 JavaScript如果你在本地复现需要注意 Python 解释器版本保持在 3.10 以上兼容 Django 4.2 LTS。7. MySQL 部署的细节与验证方法在本地环境里把整套系统跑通的顺序建议先起数据库、再初始化 Django 数据表、最后启动开发服务器做接口联调。这套流程在 Windows 10 以上和 Ubuntu 22.04 都能走通只是 MySQL 的启动命令略有差异。7.1 MySQL 环境准备与 Django 配置Django 连接 MySQL 需要在项目根目录的settings.py里配置数据库连接信息。要注意 MySQL 8.0 之后的认证插件默认是caching_sha2_passwordDjango 的 MySQL 客户端需要 PyMySQL 库配合驱动。pip install pymysql配置数据库连接import pymysql pymysql.install_as_MySQLdb() DATABASES { default: { ENGINE: django.db.backends.mysql, NAME: museum_db, USER: museum_admin, PASSWORD: your_password_here, HOST: 127.0.0.1, PORT: 3306, OPTIONS: { charset: utf8mb4, init_command: SET sql_modeSTRICT_TRANS_TABLES, }, CONN_MAX_AGE: 300, } }数据库连接池这一块的优化更偏向长期运行的稳定性设置CONN_MAX_AGE为 300 秒可以让 Django 复用已有的数据库连接减少每次请求重新建立连接的开销。utf8mb4字符集是必须的MySQL 8.0 默认就是utf8mb4能存 emoji 和生僻字。STRICT_TRANS_TABLES让 MySQL 在写入超长字段时直接报错而不是截断这能避免脏数据静默入库。7.2 初始化数据表和测试数据创建项目应用到数据库里python manage.py makemigrations collection # 根据 models.py 生成迁移文件 让迁移文件生成与模型定义对应的数据表 python manage.py migrate # 应用迁移在数据库里真实建表 python manage.py createsuperuser # 创建管理员账号用于登录 Django Admin 后台初始化完数据库下一步是把测试数据导入。系统的资源里已经包含了一份 MySQL 初始数据脚本通常是init_data.sql这样命名直接执行mysql -u museum_admin -p -h 127.0.0.1 museum_db init_data.sql数据文件导入完成后可以用 Django shell 验证核心表的数据行数快速判断导入是否成功python manage.py shell -c from collection.models import CollectionItem; print(藏品总数:, CollectionItem.objects.count())如果输出藏品总数: 0说明导入失败或数据文件路径不对检查 SQL 文件里有没有单独的DROP TABLE语句或字符集不匹配的问题。7.3 启动服务与接口调试开发环境用 Django 内置服务器启动前端用 Vite 启动python manage.py runserver 0.0.0.0:8000启动后浏览器直接访问http://localhost:8000/api/v1/collections/应该能看到 JSON 格式的藏品列表响应。前端在独立终端启动npm create vitelatest frontend -- --template vue cd frontend npm install axios element-plus vue-router npm run dev联调时经常碰到的问题是前端显示 401 未认证。原因是 Vue 前端没有先调用登录接口获取 Token。正确流程是先 POST 一段账号密码到/api/v1/auth/login/拿到返回的 Token 写进 localStorage之后所有接口都会自动带上。7.4 针对数据检索和查询优化对于上千件藏品的查询还需要考虑索引和查询性能的额外优化。主要是针对检索接口使用频率高的字段做额外的索引处理让组合查询尽量走索引而不是全表扫描。python manage.py shell进到 Django shell 之后输入下列语句看 SQL 执行计划from django.db import connection from collection.models import CollectionItem queryset CollectionItem.objects.filter(statusin_stock, material青铜).only(id, name) # 打印生成的SQL语句 print(queryset.query) # 查看实际执行计划 with connection.cursor() as cursor: cursor.execute(EXPLAIN str(queryset.query)) rows cursor.fetchall() for row in rows: print(row)如果看到type: ALL或者Extra: Using filesort就说明这条查询没走索引是逐行扫描。优化方式是在模型里对组合条件加联合索引。在 Django Model 的 Meta 类里这样加class Meta: db_table collection_item indexes [ models.Index(fields[status, material], nameidx_status_material), models.Index(fields[era, level], nameidx_era_level), ]联合索引的字段顺序有讲究最左侧的字段必须是查询里最常用的过滤条件。加了索引之后重新migrate再看执行计划就会发现变成type: ref和key: idx_status_material查询时间显著下降。在数据量超过十万行时这个差别能到十倍以上。这套系统做二次开发时最推荐做的第一个改动就是把查询接口里的Material主图切换成ListAPI配合SearchFilter和OrderingFilter把通用搜索的耦合度降下来。做一个标准的 DRF 视图集同时拿到搜索、排序、分页三件套会让整体代码量减少三分之一。后续如果想引入向量检索也能在统一封装的接口层平稳升级。本文还有配套的精品资源点击获取