从零搭建”一物一码”合格证查询系统——项目总览与技术栈选型
本文是”一物一码合格证查询系统”系列教程的第 1 篇(共 5 篇)。我们将从项目背景出发,梳理核心功能、敲定技术栈,并给出完整的工程目录结构与数据模型概览,帮助你在动手写代码之前建立全局认知。
项目背景与需求分析
在制造业和供应链领域,产品合格证是证明出厂质量最直接的凭据。然而,传统纸质合格证存在三个致命问题:
- 易伪造——一张 A4 纸加一个公章,仿冒成本极低。
- 难追溯——纸质证书没有版本链,一旦补发或作废,下游根本无法判断手里那张是不是最新版。
- 无数据沉淀——扫码率、查询热力、异常扫描等运营数据完全缺失。
“一物一码”(One Product, One Code)的核心思路非常简单:为每一件出厂产品生成一枚唯一的 QR 码,绑定到一份数字化的检验合格证上。终端用户(经销商、消费者、监管方)只需用手机扫码,即可在浏览器中查看该产品对应的合格证详情,无需下载 App,无需登录。
整个流程可以概括为:
1 2 3
| 厂家录入产品 → 创建货物批次 → 绑定合格证 → 审核发布 → 生成 QR 码贴纸 ↓ 终端用户扫码 → 公开查询页面
|
这套系统除了基本的”扫码查证”之外,还需要解决防篡改(数字签名)、可审计(审计链)、权限管控(双人复核)等企业级需求。接下来我们逐一展开。
系统功能概览
基础管理模块
系统围绕三个核心实体展开 CRUD 操作:
| 实体 |
说明 |
| Product(产品) |
如”304 不锈钢法兰”,包含名称、型号、规格等元数据 |
| Cargo(货物) |
一批具体出厂的货物,归属于某个产品,携带批次号、生产日期等 |
| Certificate(合格证) |
与货物一对一绑定的检验合格证,包含检验项目、签发人、有效期等 |
支持批量操作:一次创建数百条货物记录、批量绑定合格证、批量导出 QR 码贴纸(PDF 格式),大幅提升工厂操作效率。
合格证生命周期
合格证不是”一填了事”,而是有完整的状态流转:
1 2 3 4
| draft(草稿)→ pending_review(待审核)→ published(已发布) ↓ 可补发(reissue)→ 新版本,旧版本自动标记为 superseded 可作废(revoke) → 标记为 revoked,扫码时提示已作废
|
关键设计:双人复核(Dual Control)——操作员提交后,必须由管理员审核通过才能发布,杜绝”自己签发自己审批”的内控漏洞。
补发时,新证书通过 prev_certificate 外键指向旧证书,形成版本链,任何人都可以从最新版本一路回溯到最初版本。
防伪与审计
- Ed25519 数字签名:每份合格证在发布时,由服务器私钥对证书内容做签名;公开查询页面可用公钥验签,确保内容未被篡改。
- Append-only 审计链:每次关键操作(创建、审核、发布、作废等)都会写入一条
AuditEntry,每条记录包含前一条的哈希值,形成类似区块链的哈希链,任何中间篡改都会导致链断裂。
扫码公开查询
用户扫码后打开一个无需登录的轻量级 H5 页面,展示合格证详情、验签状态、版本历史。同时记录一条 ScanLog(含 IP、User-Agent、微信 OpenID),为运营分析提供数据。
其他能力
- 角色权限:
superadmin / admin / operator 三级,基于 DRF 自定义 Permission 实现。
- 仪表盘:证书数量统计、即将过期预警、扫码趋势图表。
- 微信集成:OAuth 2.0 网页授权获取用户 OpenID,追踪扫码用户身份。
- PDF 生成:单张合格证 PDF、批量 QR 码贴纸 PDF。
- Excel 导入/导出:支持从 Excel 批量导入产品和货物数据,也可导出查询结果。
技术栈选型
技术选型不是”流行什么用什么”,而是为当前项目的约束条件找到最合适的工具。下面逐一说明每个选择背后的理由。
后端
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
Django>=5.0,<6.0 djangorestframework>=3.15 djangorestframework-simplejwt django-ratelimit drf-spectacular django-environ
cryptography>=42.0 reportlab>=4.0 qrcode[pil] Pillow>=10.0 wechatpy openpyxl
|
为什么选 Django 而不是 FastAPI / Flask?
对于这个项目,Django 的”全家桶”特性是决定性优势:
- ORM + Migration:10 多个模型、大量外键关联、版本链——手写 SQL 或用 SQLAlchemy 要多花不少时间。
- Admin 后台:开发期可以直接用 Django Admin 查看和调试数据,省掉一半排查时间。
- 成熟的中间件和信号机制:审计链的自动写入用
post_save 信号实现,非常优雅。
- DRF 生态:Serializer、ViewSet、Permission、Throttle 一应俱全,不用重复造轮子。
为什么选 Ed25519 而不是 RSA?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import ( Ed25519PrivateKey, )
private_key = Ed25519PrivateKey.generate() public_key = private_key.public_key()
signature = private_key.sign(b"certificate-content-bytes")
public_key.verify(signature, b"certificate-content-bytes")
|
Ed25519 签名速度极快、密钥体积小、不需要像 RSA 那样管理证书链,非常适合”服务端签、客户端验”的场景。
数据库策略:开发用 SQLite,生产切 MySQL/PostgreSQL
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
|
import environ
env = environ.Env() environ.Env.read_env(".env")
DATABASES = { "default": env.db( "DATABASE_URL", default="sqlite:///db.sqlite3", ) }
|
这样做的好处:开发时零配置、即开即用;部署时通过环境变量切换,不需要改一行代码。
前端:为什么拆成两个 SPA?
| 维度 |
Admin 管理后台 |
公开查询页 |
| 用户 |
内部员工 |
终端消费者 |
| 设备 |
PC 为主 |
手机扫码 |
| 功能 |
表格、表单、图表、批量操作 |
单页展示合格证详情 |
| 体积要求 |
可接受较大 bundle |
必须极致轻量 |
| 核心依赖 |
Vue 3 + Element Plus + Pinia + ECharts |
Vue 3 + axios + html5-qrcode |
如果把管理后台和公开页面打包成一个 SPA,用户扫码后要下载几百 KB 的 Element Plus 和 ECharts 代码——这对移动端的首屏体验是不可接受的。拆成两个 SPA,公开查询页的打包体积可以控制在 50KB 以内(gzip 后),在 3G 网络下也能秒开。
1 2 3 4 5 6 7 8 9 10 11 12 13
|
import { createApp } from "vue"; import App from "./App.vue"; import axios from "axios";
const app = createApp(App);
axios.defaults.baseURL = import.meta.env.VITE_API_BASE || "/api/v1";
app.mount("#app");
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
|
import { createApp } from "vue"; import App from "./App.vue"; import ElementPlus from "element-plus"; import "element-plus/dist/index.css"; import { createPinia } from "pinia"; import router from "./router";
const app = createApp(App);
app.use(ElementPlus); app.use(createPinia()); app.use(router);
app.mount("#app");
|
两个 SPA 共享同一套后端 API,通过 JWT 区分已登录用户和匿名访问者。公开查询接口不需要 Token,管理接口需要在请求头携带 Authorization: Bearer <token>。
项目目录结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| qrcode_refresh/ ├── backend/ │ ├── config/ # Django 项目配置 │ │ ├── settings.py # 数据库、中间件、JWT、限流等全局配置 │ │ ├── urls.py # 根路由,挂载 API 和 OAuth 路由 │ │ └── wsgi.py # WSGI 入口(部署用) │ │ │ ├── certs/ # 核心业务 App │ │ ├── api/ # DRF 层 │ │ │ ├── viewsets.py # ViewSet(Product/Cargo/Certificate/AuditEntry...) │ │ │ ├── serializers.py # 序列化器(含嵌套、只读、写入分离) │ │ │ ├── permissions.py # 自定义权限类(IsSuperAdmin/IsAdminOrAbove/IsOperator) │ │ │ ├── filters.py # 筛选器(按状态、日期、关键词过滤) │ │ │ └── urls.py # 路由注册(DefaultRouter) │ │ │ │ │ ├── services/ # 业务逻辑层(与 API 层解耦) │ │ │ ├── cert_service.py # 合格证生命周期(提交审核、发布、补发、作废) │ │ │ ├── signing_service.py # Ed25519 签名与验签 │ │ │ ├── pdf_service.py # PDF 生成(单证 + 批量贴纸) │ │ │ ├── qr_service.py # QR 码生成与管理 │ │ │ └── wechat_service.py # 微信 OAuth 与消息推送 │ │ │ │ │ ├── management/ │ │ │ └── commands/ # 自定义 manage.py 命令 │ │ │ ├── seed_data.py # 填充测试数据 │ │ │ └── rotate_keys.py # 签名密钥轮换 │ │ │ │ │ ├── models.py # 10+ 数据模型(详见下一节) │ │ ├── middleware.py # CurrentActorMiddleware(将当前用户注入线程变量) │ │ ├── signals.py # 审计链信号(post_save 自动写入 AuditEntry) │ │ └── views.py # 微信 OAuth 回调视图 │ │ │ ├── requirements.txt # Python 依赖清单 │ └── manage.py # Django CLI 入口 │ ├── frontend/ │ ├── admin/ # 管理后台 SPA │ │ ├── src/ │ │ │ ├── views/ # 页面组件(Dashboard/Product/Cargo/Certificate...) │ │ │ ├── stores/ # Pinia 状态仓库 │ │ │ ├── router/ # Vue Router 路由配置 │ │ │ ├── api/ # axios 请求封装 │ │ │ └── components/ # 通用组件(表格、弹窗、上传...) │ │ ├── vite.config.js │ │ └── package.json │ │ │ └── cert-view/ # 公开查询页 SPA(轻量) │ ├── src/ │ │ ├── CertView.vue # 合格证详情展示 │ │ └── ScanPage.vue # 扫码入口页(可选,支持浏览器扫码) │ ├── vite.config.js │ └── package.json │ └── docs/ # 项目文档(API 说明、部署指南等)
|
分层设计要点:
api/ 层只负责 HTTP 协议相关的事情(参数校验、序列化、权限检查),不包含业务逻辑。
services/ 层封装所有业务规则(状态流转、签名、PDF 生成),可以被 ViewSet 调用,也可以被 management command 调用,实现真正的逻辑复用。
signals.py 利用 Django 的信号机制在模型保存后自动追加审计记录,业务代码无需显式调用。
数据模型概览
下面是核心模型及其关系的精简定义。完整字段将在第 2 篇文章中详细展开。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114
|
from django.db import models from django.conf import settings
class Product(models.Model): """产品:如"304不锈钢法兰",是货物的分类模板""" name = models.CharField("产品名称", max_length=200) model_number = models.CharField("型号", max_length=100)
class Cargo(models.Model): """货物:一批具体出厂的货物,归属于某个产品""" product = models.ForeignKey( Product, on_delete=models.CASCADE, related_name="cargos", verbose_name="所属产品", ) batch_number = models.CharField("批次号", max_length=100) qr_code = models.UUIDField("QR 码唯一标识", unique=True)
class Certificate(models.Model): """合格证:与货物一对一绑定,支持版本链"""
class Status(models.TextChoices): DRAFT = "draft", "草稿" PENDING = "pending_review", "待审核" PUBLISHED = "published", "已发布" SUPERSEDED = "superseded", "已被替代" REVOKED = "revoked", "已作废"
cargo = models.OneToOneField( Cargo, on_delete=models.CASCADE, related_name="certificate", verbose_name="关联货物", ) status = models.CharField("状态", max_length=20, choices=Status.choices, default=Status.DRAFT)
prev_certificate = models.ForeignKey( "self", on_delete=models.SET_NULL, null=True, blank=True, related_name="next_versions", verbose_name="前一版本", )
signature = models.TextField("Ed25519 签名(Base64)", blank=True) signed_at = models.DateTimeField("签名时间", null=True, blank=True)
class AuditEntry(models.Model): """审计记录:Append-only 哈希链,确保操作历史不可篡改""" action = models.CharField("操作类型", max_length=50) actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) payload = models.JSONField("操作详情", default=dict) prev_hash = models.CharField("前一条哈希", max_length=64) entry_hash = models.CharField("本条哈希", max_length=64) created_at = models.DateTimeField(auto_now_add=True)
class ScanLog(models.Model): """扫码记录:追踪每一次 QR 码扫描""" cargo = models.ForeignKey(Cargo, on_delete=models.CASCADE, related_name="scan_logs") ip_address = models.GenericIPAddressField("IP 地址") user_agent = models.TextField("User-Agent") wechat_openid = models.CharField("微信 OpenID", max_length=64, blank=True) scanned_at = models.DateTimeField(auto_now_add=True)
class SigningKey(models.Model): """签名密钥元数据:支持密钥轮换""" key_id = models.UUIDField("密钥 ID", unique=True) public_key_pem = models.TextField("公钥(PEM 格式)") is_active = models.BooleanField("是否启用", default=True) created_at = models.DateTimeField(auto_now_add=True)
class EditLog(models.Model): """字段级变更记录:哪个用户在什么时间改了哪个字段""" content_type = models.ForeignKey("contenttypes.ContentType", on_delete=models.CASCADE) object_id = models.PositiveIntegerField() field_name = models.CharField("字段名", max_length=100) old_value = models.TextField("旧值", blank=True) new_value = models.TextField("新值", blank=True) changed_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) changed_at = models.DateTimeField(auto_now_add=True)
class Notification(models.Model): """站内通知:支持去重""" recipient = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) title = models.CharField("标题", max_length=200) dedup_key = models.CharField("去重键", max_length=200, db_index=True) is_read = models.BooleanField("已读", default=False) created_at = models.DateTimeField(auto_now_add=True)
class DropdownOption(models.Model): """动态字典:前端下拉框选项(如检验标准、产品类别)""" category = models.CharField("分类", max_length=50) label = models.CharField("显示名", max_length=100) value = models.CharField("值", max_length=100) sort_order = models.IntegerField("排序", default=0)
|
模型之间的关系可以这样理解:
1 2 3 4 5 6 7 8 9
| Product ──1:N── Cargo ──1:1── Certificate │ │ │ └── prev_certificate (self FK,版本链) │ └──1:N── ScanLog(扫码记录)
Certificate ──触发── AuditEntry(审计链) ──触发── EditLog(字段变更) ──关联── SigningKey(签名密钥)
|
系列导航
| 篇目 |
主题 |
状态 |
| 第 1 篇 |
项目总览与技术栈选型(本文) |
✅ 已发布 |
| 第 2 篇 |
后端架构深度解析——模型、服务层与审计链 |
📝 敬请期待 |
| 第 3 篇 |
API 设计与安全——JWT 认证、权限、限流 |
📝 敬请期待 |
| 第 4 篇 |
前端实现——管理后台与公开查询页 |
📝 敬请期待 |
| 第 5 篇 |
部署与运维——Docker、Nginx、密钥管理 |
📝 敬请期待 |
下篇预告
在第 2 篇文章中,我们将深入后端架构:完整的模型定义(含所有字段与约束)、services/ 层的设计哲学、Ed25519 签名的完整实现、审计哈希链的自动构建(基于 Django Signals),以及 CurrentActorMiddleware 如何在不侵入业务代码的前提下追踪”谁在操作”。如果你对 Django 的信号机制和中间件还不太熟悉,下一篇会给出详细的代码解读。
敬请期待!