从零搭建”一物一码”合格证查询系统——项目总览与技术栈选型

本文是”一物一码合格证查询系统”系列教程的第 1 篇(共 5 篇)。我们将从项目背景出发,梳理核心功能、敲定技术栈,并给出完整的工程目录结构与数据模型概览,帮助你在动手写代码之前建立全局认知。


项目背景与需求分析

在制造业和供应链领域,产品合格证是证明出厂质量最直接的凭据。然而,传统纸质合格证存在三个致命问题:

  1. 易伪造——一张 A4 纸加一个公章,仿冒成本极低。
  2. 难追溯——纸质证书没有版本链,一旦补发或作废,下游根本无法判断手里那张是不是最新版。
  3. 无数据沉淀——扫码率、查询热力、异常扫描等运营数据完全缺失。

“一物一码”(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
# requirements.txt —— 后端核心依赖清单

Django>=5.0,<6.0 # Web 框架,自带 ORM、Admin、Migration
djangorestframework>=3.15 # RESTful API 框架
djangorestframework-simplejwt # 无状态 JWT 认证,适配 SPA 前端
django-ratelimit # 接口限流,保护公开查询端点
drf-spectacular # 自动生成 OpenAPI 3.0 文档
django-environ # 通过 .env 文件管理配置,遵循 12-Factor 规范

cryptography>=42.0 # Ed25519 数字签名(高安全、高性能、无证书链负担)
reportlab>=4.0 # 服务端 PDF 生成,原生支持 CJK 字体
qrcode[pil] # QR 码图片生成
Pillow>=10.0 # 图像处理(QR 码渲染依赖)
wechatpy # 微信公众号 SDK(OAuth、消息推送)
openpyxl # Excel 读写

为什么选 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
# 签名性能对比(示意)
# Ed25519: 签名 ~0.05ms,验签 ~0.15ms,密钥仅 32 字节
# RSA-2048: 签名 ~1.5ms,验签 ~0.05ms,密钥 256 字节

from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
)

# 生成密钥对 —— 只需一行,无需指定曲线参数
private_key = Ed25519PrivateKey.generate()
public_key = private_key.public_key()

# 签名 —— 无需指定哈希算法(Ed25519 内置 SHA-512)
signature = private_key.sign(b"certificate-content-bytes")

# 验签 —— 失败会抛出 InvalidSignature 异常
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
# config/settings.py —— 通过 django-environ 实现环境感知的数据库配置

import environ

env = environ.Env()
environ.Env.read_env(".env") # 从 .env 文件加载配置

DATABASES = {
"default": env.db(
"DATABASE_URL",
# 未配置 DATABASE_URL 时,默认使用 SQLite(开发环境)
default="sqlite:///db.sqlite3",
)
}

# 生产环境只需在 .env 中设置:
# DATABASE_URL=mysql://user:pass@host:3306/qrcode_cert?charset=utf8mb4
# 或
# DATABASE_URL=postgres://user:pass@host:5432/qrcode_cert

这样做的好处:开发时零配置、即开即用;部署时通过环境变量切换,不需要改一行代码。

前端:为什么拆成两个 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
// frontend/cert-view/src/main.js
// 公开查询页入口 —— 极简依赖,只引入必要模块

import { createApp } from "vue";
import App from "./App.vue";
import axios from "axios";

const app = createApp(App);

// 配置 axios 基础路径,指向后端 API
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
// frontend/admin/src/main.js
// 管理后台入口 —— 完整功能集

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); // UI 组件库
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
# certs/models.py —— 核心模型关系概览

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", # product.cargos.all() 获取该产品下所有货物
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", # cargo.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", # old_cert.next_versions.all() 查看后续版本
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) # 如 "certificate.publish"
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) # SHA-256 哈希链
entry_hash = models.CharField("本条哈希", max_length=64) # hash(prev_hash + action + payload + timestamp)
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) # 如 "inspection_standard"
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 的信号机制和中间件还不太熟悉,下一篇会给出详细的代码解读。

敬请期待!