资讯详情 Django实战:中药材数据分析与可视化系统完整开发指南
📅 2026/10/10 10:41:19
简介基于Django构建的中药材数据分析与可视化系统聚焦中药材价格、产地、使用频率等数据的采集与展示面向中医药研究、市场分析及Python Web开发学习者可用于快速搭建专业的数据分析平台。压缩包共140个文件体积约3.98MB其中包含28个py源码文件、13个html页面、12个csv数据文件、8个js脚本及10个css样式等还附有sql数据库脚本目录结构清晰按功能模块组织便于直接部署和二次开发。系统实现了爬虫自动采集、数据清洗、Echarts交互可视化、用户注册登录和管理员权限管理等功能可展示价格对比柱状图、产地占比饼图及历史趋势折线图并支持自定义关键词搜索药材信息。已有125人学习下载资源既适合作为课程设计、毕业设计的完整参考方案也可为Python开发者提供Django结合爬虫与可视化的工程实战示范。1. 中药材数据分析与可视化系统django 实现一个能交作业也能落地的完整项目中药材价格波动、产地分布、功效分类这些数据单靠 Excel 透视表很难看清趋势更没法让非技术人员自己上手查。这套基于 django 实现的中药材数据分析与可视化系统把数据建模、接口查询、ECharts 图表展示、后台权限管理串成一条完整链路是我拆过的最适合做实战项目的 django 成品之一。它能解决的不只是“画几张图”而是从数据入库到前端渲染、再到权限控制的一整套工程问题。适合正在做 django 项目实战的新手也适合课程设计、毕业设计需要完整源码包的从业者。2. 数据建模与数据入库四张核心表的设计与 ORM 迁移2.1 中药材业务表怎么拆价格、产地、功效、库存四个维度拿到一批中药材数据第一件事不是急着写页面而是想清楚“要分析什么”。我这个项目里锁定了四个分析维度价格走势、产地分布、功效分类、库存变化。围绕这四个维度数据表就拆成四张核心表加一张关联表。MedicinalMaterial 存药材基础信息比如名称、别名、拉丁名、药用部位PriceRecord 存价格记录关联药材 ID、价格、单位、采集日期OriginPlace 存产地信息包括产区、省份、海拔、气候特点StockRecord 存库存变动记录入库量、出库量、结存量。四张表的关系是药材一对多价格、一对多产地、一对多库存。这样拆的核心原因是分析场景不同价格趋势要按时间聚合产地分布要按省份分组库存预警要按结存量排序。如果全部塞进一张大宽表后续每个查询都要写一堆条件而且字段越多索引越难优化。拆成四张表后每一张表都能独立做聚合Django ORM 的 annotate 和 values 也能发挥最大作用。2.2 模型定义与 Django ORM 迁移落地模型定义直接决定了后续接口好不好写。先看 models.py 的核心代码这里面有几个值得注意的细节from django.db import models class MedicinalMaterial(models.Model): name models.CharField(max_length100, uniqueTrue, verbose_name药材名称) alias models.CharField(max_length200, blankTrue, verbose_name别名) part_used models.CharField(max_length50, blankTrue, verbose_name药用部位) efficacy_tags models.CharField(max_length500, blankTrue, verbose_name功效标签) created_at models.DateTimeField(auto_now_addTrue, verbose_name创建时间) class Meta: db_table medicinal_material verbose_name 中药材基本信息 def __str__(self): return self.name class PriceRecord(models.Model): material models.ForeignKey( MedicinalMaterial, on_deletemodels.CASCADE, related_nameprices, verbose_name关联药材 ) price models.DecimalField(max_digits10, decimal_places2, verbose_name价格) unit models.CharField(max_length20, default元/公斤, verbose_name计量单位) record_date models.DateField(db_indexTrue, verbose_name采集日期) class Meta: db_table price_record ordering [-record_date] def __str__(self): return f{self.material.name} {self.record_date} {self.price}逻辑说明Material 表的 name 字段加了 uniqueTrue避免重复录入同一药材PriceRecord 的 material 外键用 related_nameprices 定义反向查询名这样在模板或序列化里可以直接用 material.prices.all() 取全部价格记录。record_date 加 db_indexTrue是因为后面所有趋势分析都会按日期范围过滤这个索引能明显提升查询速度。这里还有一个容易忽略的参数是 DecimalField 而不是 FloatField。价格数据用 Decimal 可以避免浮点精度丢失比如 0.10.2 在 float 下会变成 0.30000000000000004入库后展示出来很难看聚合求平均值也会有偏差。定义完之后依次执行python manage.py makemigrations python manage.py migrate系统会自动生成建表 SQL 并同步到数据库。如果需要反向查看某张表的实际结构可以用 python manage.py sqlmigrate 应用名 迁移编号 打印出原生 SQL这个习惯在排查字段类型问题时特别有用。2.3 CSV 批量导入从原始数据到数据库的管理命令实际项目里数据量不会只有十条八条靠 admin 后台一条条录不现实。我的做法是自定义一个 Django 管理命令把 CSV 批量导入封装成 python manage.py import_data 的形式。import csv from django.core.management.base import BaseCommand from stats.models import MedicinalMaterial, PriceRecord class Command(BaseCommand): help 批量导入中药材价格CSV文件 def add_arguments(self, parser): parser.add_argument(csv_file, typestr, helpCSV文件路径) parser.add_argument(--encoding, defaultutf-8, help文件编码默认utf-8) def handle(self, *args, **options): csv_path options[csv_file] encoding options[encoding] material_cache {} success_count 0 with open(csv_path, r, encodingencoding) as f: reader csv.DictReader(f) for row in reader: name row[药材名称].strip() if name not in material_cache: material, created MedicinalMaterial.objects.get_or_create( namename, defaults{alias: row.get(别名, ), part_used: row.get(药用部位, )} ) material_cache[name] material else: material material_cache[name] PriceRecord.objects.create( materialmaterial, pricefloat(row[价格]), record_daterow[采集日期], unitrow.get(单位, 元/公斤) ) success_count 1 self.stdout.write(self.style.SUCCESS(f导入完成{success_count} 条价格记录))逻辑说明material_cache 是一个进程内字典用来缓存已查询过的药材对象避免每行数据都查一次数据库这个优化在几千行数据时能省下大量时间。get_or_create 是 Django 提供的“存在即返回、不存在则创建”的快捷方法defaults 参数里的字段只在新建时生效已存在记录不会覆盖原有别名和药用部位。参数说明--encoding 参数默认 utf-8但实际下载的中药材数据集很多是 GBK 或 GB2312 编码Windows 下导出的 CSV 尤其常见。导入前不确定编码时我一般先用 Notepad 或 VS Code 打开看右下角编码再用对应编码导入否则会出现 UnicodeDecodeError 或者中文乱码入库。3. 数据分析与可视化接口从聚合查询到 ECharts 图表接收 JSON3.1 聚合统计放查询集还是放视图Django ORM 的 annotate 实践很多初学者会把统计逻辑写在 Python 循环里比如遍历所有价格记录再手动求和。这个做法在小数据量时看不出问题但中药材价格数据一旦覆盖多年、多产地、多批次页面响应会从毫秒级退化到秒级。正确的思路是让数据库完成聚合Django ORM 里对应的是 annotate 和 aggregate 两个方法。我以“各药材平均价格排行”为例说明接口视图怎么写from django.db.models import Avg, Max, Min from django.http import JsonResponse from stats.models import PriceRecord def material_avg_price(request): 返回每种药材的平均价格、最高价、最低价 queryset ( PriceRecord.objects .values(material__name) .annotate( avg_priceAvg(price), max_priceMax(price), min_priceMin(price), ) .order_by(-avg_price) ) data [ { name: item[material__name], avg_price: round(item[avg_price], 2), max_price: float(item[max_price]), min_price: float(item[min_price]), } for item in queryset ] return JsonResponse({code: 0, data: data})逻辑说明values(material__name) 会先按药材名称分组annotate 里的 Avg(price) 对每组计算均价order_by(-avg_price) 按均价降序。整个过程只生成一条 SQL 的 GROUP BY 查询而不是在 Python 里做循环。这里有个细节annotate 之后字段名变成 material__name因为它是跨表分组的结果前端如果直接读 name 会拿到 None必须在序列化时做映射。参数说明Max 和 Min 返回的 Decimal 类型不能直接 JSON 序列化所以用 float() 转换avg_price 用 round 保留两位小数。如果你还要做“某药材在各省份的平均价格”只需要把 values 参数改成 values(material__name, material__originplace__province)其他逻辑不变一张二维分组表就出来了。3.2 路由注册与 Django 创建 app 的完整流程接口视图写好了路由不注册等于白写。Django 项目里我习惯按功能拆 app数据分析相关代码单独放在 stats 这个 app 里。用命令创建python manage.py startapp stats创建完之后在项目的主 urls.py 里注册子路由同时在 stats 应用内部再建一个 urls.py 管理本应用的路由代码结构会清晰很多# 项目主 urls.py from django.contrib import admin from django.urls import path, include urlpatterns [ path(admin/, admin.site.urls), path(api/, include(stats.urls)), ]# stats/urls.py from django.urls import path from stats.views import material_avg_price, price_trend, origin_distribution urlpatterns [ path(material/avg_price/, material_avg_price, namematerial_avg_price), path(material/price_trend/, price_trend, nameprice_trend), path(origin/distribution/, origin_distribution, nameorigin_distribution), ]这里 url 名称 name 参数的作用是供模板里的 url 标签或者前端代码反向解析同时也能在 Django admin 站点的“查看站点”里直接跳转。接口路径用 /api/ 前缀是为了后续如果分离部署前端nginx 层面可以统一代理转发不用逐个接口配 location。还有一个容易被忽略的步骤是新建 app 后必须去 settings.py 的 INSTALLED_APPS 里注册否则 makemigrations 不会扫描这个应用的模型。检查配置INSTALLED_APPS [ django.contrib.admin, django.contrib.auth, django.contrib.contenttypes, django.contrib.sessions, django.contrib.messages, django.contrib.staticfiles, stats, # 新注册的数据分析应用 ]3.3 ECharts 前端渲染折线图、饼图、柱状图一次对接后端接口返回 JSON 只是第一步可视化系统最终要落在图表上。以价格趋势折线图和产地分布饼图为例我用原生 ECharts 加 fetch 对接 Django 接口不引入 Vue 或 React目的是让系统保持轻量模板直接渲染 HTML 就能跑。!DOCTYPE html html head meta charsetutf-8 title中药材价格趋势分析/title !-- 使用本地 static 目录下的 echarts.min.js避免外网 CDN 加载失败 -- script src/static/js/echarts.min.js/script /head body div idtrendChart stylewidth:100%; height:400px;/div div idoriginChart stylewidth:100%; height:400px;/div script fetch(/api/material/price_trend/?material当归) .then(res res.json()) .then(data { const chart echarts.init(document.getElementById(trendChart)); chart.setOption({ title: { text: 当归价格走势 }, xAxis: { type: category, data: data.dates }, yAxis: { type: value, name: 价格(元/公斤) }, series: [{ type: line, data: data.prices, markPoint: { data: [{ type: max }, { type: min }] } }] }); }); fetch(/api/origin/distribution/) .then(res res.json()) .then(data { const chart echarts.init(document.getElementById(originChart)); chart.setOption({ title: { text: 产地分布占比 }, tooltip: { trigger: item }, series: [{ type: pie, radius: 60%, data: data.items }] }); }); /script /body /html逻辑说明这里的 fetch 请求用的是相对路径 /api/如果前端页面和 Django 服务在同一个域名下就无需处理跨域。趋势接口返回的数据结构约定为 {dates: [...], prices: [...]}饼图接口为 {items: [{name: 甘肃, value: 32}, ...]}约定在前端代码里写死后端改结构时前端会拿到 undefined这一点要先和后端同学达成一致。需要注意 markPoint 的 max/min 是 ECharts 内置的标点类型不需要额外传具体数值它会自动从 series 的 data 里找最大最小值。如果数据量特别大比如十年每日价格建议在前端只展示近 90 天数据后端接口里做 date__gte 过滤整页渲染会更流畅。4. Django 常见问题与避坑N1 查询、时区、跨域、静态文件四件事4.1 N1 查询药材列表页响应从 1 秒变到 8 秒现象药材列表页只有 200 条数据但数据库查询日志里出现了 200 多条 SQL页面响应时间接近 8 秒。原因视图中先查询所有 MedicinalMaterial 对象然后在模板里循环取 material.prices.all() 时每取一个药材的价格列表就触发一次新的数据库查询这就是经典的 N1 查询问题。解决在查询集上使用 prefetch_related 预取外键反向关联from stats.models import MedicinalMaterial # 修改前 materials MedicinalMaterial.objects.all() # 修改后 materials MedicinalMaterial.objects.prefetch_related(prices)加了 prefetch_related 之后Django 会先查询全部药材再查询这些药材关联的所有价格记录然后在内存中完成关联映射数据库查询次数从 200 多次降为 2 次。如果只是取单个外键字段而不是反向查询集应该用 select_related(origin_place)这两个方法的使用场景在这类项目里要分清。4.2 时区配置导致日期统计少一天现象导入 CSV 里的价格日期是 2024-01-01前端折线图上显示成了 2023-12-31每天都偏移一天。原因settings.py 里 USE_TZ True数据库按 UTC 时区存储时间而本地时区是 Asia/Shanghai。虽然模型字段用的 DateField 不受影响但如果前端格式化或者接口里用了 datetimes 方法就会发生偏移。解决检查 settings.py 的关键配置TIME_ZONE Asia/Shanghai USE_TZ True # 如果只是纯日期统计不涉及跨时区协作可以直接 USE_TZ False我一般建议数据分析类项目直接设置 USE_TZ False因为价格趋势只关心自然日不需要 UTC 转换。如果是日志类系统需要保留精确时间点再保持 USE_TZ True 并配合 timezone.localtime() 处理。4.3 开发环境跨域拦截前端页面在 8080 端口调 8000 端口接口报错现象前端测试页面跑在 localhost:8080Django 跑在 8000浏览器控制台报 CORS 错误请求被拦截。原因浏览器同源策略限制跨端口访问Django 默认不允许跨域请求。解决安装 django-cors-headers 并在 settings 中配置pip install django-cors-headersINSTALLED_APPS [ # ... corsheaders, ] MIDDLEWARE [ corsheaders.middleware.CorsMiddleware, # 注意顺序一般放在 CommonMiddleware 前面 django.middleware.common.CommonMiddleware, # ... ] CORS_ALLOWED_ORIGINS [ http://localhost:8080, ]注意 CORS_ALLOWED_ORIGINS 不要写成 CORS_ORIGIN_ALLOW_ALL True生产环境会造成安全隐患。调试完了就把 8080 这个源从列表里删掉。4.4 静态文件 404本地 runserver 没问题部署后样式全丢现象collectstatic 执行成功nginx 也指向了静态目录但页面样式 404。原因Django 开发环境的 runserver 会自动服务 /static/ 路径但部署后由 nginx 处理如果 nginx 的 location 配置没对上 STATIC_ROOT 路径或者 collectstatic 之前没有把自定义静态目录加进 STATICFILES_DIRS就会出现这个问题。解决确保 settings.py 配置完整STATIC_URL /static/ STATICFILES_DIRS [BASE_DIR / static] # 开发时自定义静态文件目录 STATIC_ROOT BASE_DIR / staticfiles # collectstatic 收集到的生产目录部署时执行 python manage.py collectstatic --noinput并仔细核对 nginx 配置里的 alias 路径与 STATIC_ROOT 一致。这里最容易翻车的是 STATICFILES_DIRS 和 STATIC_ROOT 指向了同一个目录Django 会直接报错提示不允许覆盖。4.5 CSRF 校验拦截 POST 接口自己的脚本也提交不了数据现象用自写的爬虫脚本向 Django 接口提交数据返回 403 ForbiddenCSRF 验证失败。原因Django 默认对 POST 请求启用 CSRF 防护这是安全的默认行为但不适合纯 API 接口。解决对 API 类视图使用 csrf_exempt 装饰器from django.views.decorators.csrf import csrf_exempt from django.http import JsonResponse csrf_exempt def import_price_api(request): if request.method POST: return JsonResponse({code: 0, message: 接收成功}) return JsonResponse({code: 1, message: 仅支持 POST})需要强调的是这个装饰器只应该用在无状态 API 上凡是涉及登录态的操作都不应该加。如果是要让第三方系统安全调用正确做法是配置 Django REST Framework 的认证和权限控制单纯禁用 CSRF 是饮鸩止渴。5. 权限控制与后台管理把系统交给业务人员前的最后一道配置5.1 Django admin 定制列表筛选、搜索、联动查看价格记录如果要展示增量数据管理员只需要配置 ModelAdmin让运营人员看到能理解的信息。默认的 model 列表连外键显示的都是对象名字不实用。from django.contrib import admin from stats.models import MedicinalMaterial, PriceRecord class PriceRecordInline(admin.TabularInline): model PriceRecord extra 0 fields [price, unit, record_date] readonly_fields [record_date] admin.register(MedicinalMaterial) class MedicinalMaterialAdmin(admin.ModelAdmin): list_display [name, alias, part_used, efficacy_tags, created_at] search_fields [name, alias, efficacy_tags] list_filter [part_used] list_per_page 20 inlines [PriceRecordInline]逻辑说明PriceRecordInline 的作用是在药材详情页里直接看该药材的历史价格记录extra 0 控制新增行数量为 0避免页面默认出现三个空白行。readonly_fields 里的 record_date 防止录入时误改采集日期。list_filter 用 part_used 做侧栏筛选运营按“根茎类”“花叶类”筛起来很快。5.2 用户组与权限分配业务人员只读管理员可写Django 自带的 auth 应用提供了完整权限体系。我给系统分配了三类角色管理员可以增删改所有数据运营人员只能查看页面和导出数据访客只能看到公开分析页面。创建用户组并授权的操作可以写成脚本也可以在 admin 后台里点。我建议写成 fixture 或者管理命令方便部署时一键还原python manage.py shell -c from django.contrib.auth.models import Group, Permission view_group, _ Group.objects.get_or_create(nameviewers) perms Permission.objects.filter(codename__in[view_medicinalmaterial, view_pricerecord]) view_group.permissions.set(perms) 视线代码中用 get_or_create 防止重复建组permissions.set() 是整体覆盖新的权限列表会替换原有权限如果只加不减可以用 add()。页面上控制菜单显示逻辑视图函数里用 request.user.has_perm(stats.view_pricerecord) 判断没有权限的用户直接返回 403 或跳转到首页。5.3 生产配置拆分不要在 settings.py 里写死 DEBUG 和数据库密码部署环节最容易踩的坑是把开发配置和生成配置混在一个文件里。我的习惯是拆成 settings_base.py、settings_dev.py、settings_prod.py 三个文件启动时用环境变量指定配置文件# manage.py 里自动选择配置 import os DJANGO_ENV os.getenv(DJANGO_ENV, dev) if DJANGO_ENV prod: os.environ.setdefault(DJANGO_SETTINGS_MODULE, config.settings_prod) else: os.environ.setdefault(DJANGO_SETTINGS_MODULE, config.settings_dev)生产配置中强制关闭 DEBUGDEBUG False ALLOWED_HOSTS [your-domain.com] SECURE_SSL_REDIRECT True SESSION_COOKIE_SECURE True CSRF_COOKIE_SECURE True如果你用的是 Nginx uWSGI这组配置能让会话和 CSRF Cookie 只在 HTTPS 下传输避免中间人截获。配置文件的数据库密码推荐直接读取环境变量不要把明文写到版本库里。6. 上线后我每天必做的一组自查脚本数据质量与接口健康度系统上线后真正花时间的不是写功能是确保数据不出错。中药材价格数据来源多元同一药材在不同年份可能有不同计量单位产地名称也存在简写和别名混用的情况。我每天都会跑一个自定义自查命令检验数据质量和接口健康度。from datetime import date, timedelta from django.core.management.base import BaseCommand from django.db.models import Count from stats.models import PriceRecord, MedicinalMaterial class Command(BaseCommand): help 每日数据质量自检 def handle(self, *args, **options): problems [] # 1. 检查负价格和异常大价格 bad_prices PriceRecord.objects.filter(price__lt0) if bad_prices.exists(): problems.append(f存在 {bad_prices.count()} 条负价格记录) # 2. 检查近7天价格波动超过30%的药材 last_week date.today() - timedelta(days7) abnormal PriceRecord.objects.filter(record_date__gtelast_week)\ .values(material__name)\ .annotate(cntCount(id)) # 3. 检查药材重复名称理论上被 unique 挡住但历史数据可能有脏数据 dup_names MedicinalMaterial.objects.values(name)\ .annotate(cntCount(id)).filter(cnt__gt1) if dup_names.exists(): problems.append(存在重复药材名称) if problems: for p in problems: self.stdout.write(self.style.ERROR(p)) else: self.stdout.write(self.style.SUCCESS(自检通过无数据问题))每天定时执行生产环境用 crontab 写入 python manage.py data_check输出重定向到日志文件。这套逻辑跑一个月后我发现价格波动的异常检测不能只看近 7 天均值因为中药材有季度性波动当归每年 3 月涨价是常态需要把去年同期数据也拉进来做对比否则误报率太高。这些年踩下来的体会是数据分析系统的技术栈不难难的是你敢不敢把数据质量问题暴露在台面上。Django 给的数据校验能力很完整但从 CSV 导入开始就做好每天的体检后面跑报表才不会突然翻车。从那以后我每次上线新数据源都强制走一遍自查脚本再调整可视化页面的口径希望你也能从这套流程里少走几个弯路希望帮到你。本文还有配套的精品资源点击获取