ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

微信小程序传染病防控系统实战:源码拆解与Docker一键部署

微信小程序传染病防控系统实战:源码拆解与Docker一键部署

做这类“传染病防控”小程序最怕什么?不是功能写不出来,而是做出来之后没人敢用、没人认账。疫情信息涉及健康隐私,用错一个字段、漏传一次数据,都可能引发合规和信任问题。我自己前后做过几套类似的课题和项目,从“单机demo”到“可上线的多端系统”都趟过一遍,今天这篇就把一套微信小程序传染病防控系统的完整落地流程拆给你看:从源码怎么组织,到核心功能怎么实现,再到最后怎么用Docker一键部署起来,全程不绕弯子。

这篇文章适合三种人:第一种是要做毕业设计或课程项目的同学,想找一个“功能完整、能跑通、有技术亮点”的参考案例;第二种是刚转行前端、想了解小程序全栈开发套路的开发者,这里会有代码级的过程;第三种是学校、园区的小型项目负责人,需要快速判断“这种系统到底怎么落地、有什么坑”。我会把踩过的坑和同行的经验都写进去,尽量让每个环节都能直接对着操作。

顺便说一句:由于卫生信息场景的特殊性,文章里不会提供任何真实病人数据或机构信息,里面的案例和配置都做了抽象处理,你只需要关心技术路径本身。下面开始。

1. 系统定位与需求拆解

1.1 这类小程序究竟在解决什么问题

传染病防控工作在实际执行中,最难的不是制定流程,而是“信息收集的速度和准确性”。比如一个园区有上千人,如果靠人工填表汇总,从下发到回收再到统计,至少耗费半天,而且漏报错报无法纠正。小程序防控系统要解决的核心就是两件事:一是让每个人在手机上花几十秒完成信息上报,二是让管理员在一个后端页面实时看到统计结果和异常记录。

所以这套系统的本质是“轻量级信息流 + 规则引擎”,不是大数据分析平台,也不是严格意义上的医院信息管理系统。它关注的是“本人健康状态、行程轨迹、接触史”这三类高频数据的快速采集与比对。如果一开始就把架构设计得很重,比如引入复杂的流计算引擎和AI模型,小项目的维护成本就会失控。个人建议至少在v1.0版本里,把系统做“薄”,只保留四类核心动作:用户登录、每日打卡、异常上报、后台统计。

1.2 核心功能模块清单

我把场景设定为一个普通高校或企业园区的防控管理,系统的使用角色分为两类:普通用户和管理员。普通用户通过小程序完成每日健康信息填报、查看个人打卡记录、接收通知;管理员通过后台Web页面查看汇总数据、导出报表、处理异常申诉。为了让源码解析更直观,我列了一个功能矩阵:

模块用户端管理端核心数据字段
身份认证微信授权登录账号密码登录openid、role、department
每日打卡填报体温、症状、行程查看填报率、明细user_id、temperature、symptom、location、report_date
风险预警收到风险提示设置中高风险区zone_name、risk_level、update_time
数据统计个人记录查询日期筛选、图表、导出聚合查询结果

注意:“行程”字段不是让你做GPS定位,而是让用户手动选择近7天去过的地方(比如“校内食堂”“图书馆”等固定选项)。一来减少开发复杂度,二来避免过度采集个人位置信息造成合规问题。这是很多没经验的同学容易踩的雷,以为必须用地图API实现轨迹记录,其实普通防控场景完全可以用“区域勾选”代替。

1.3 技术选型背后的取舍

小程序端我选的是原生微信小程序框架,而不是uni-app或Taro。原因很简单:这个项目不涉及多端复用,原生框架开发工具链最稳定,组件和API说明也最全。虽然语法比不上Vue好写,但遇到问题查官方文档最快。

后端我选择了Spring Boot + MyBatis Plus + MySQL + Redis,这是Java技术栈里最“稳妥”的组合。学院派项目都看得懂,招聘市场上Java岗位最多,后续想扩展定时任务、消息队列都方便。如果求轻量,你也可以用Node.js Express或Python FastAPI,但考虑到并发场景(学生每天集中在早上8点打卡),Java的线程模型和生态会更让你省心。

用Redis主要是为了做“每日打卡幂等”和“高频读取缓存”。简单说,就是同一个用户同一天内,不管手滑提交多少次,后端只算第一次有效;首页的统计面板和风险区域配置,直接读Redis而不是每次查MySQL,抗住几百QPS没问题。如果你做的规模再大一点,还可以引入消息队列削峰,但这就是后话了。

2. 源码结构从入口到后端服务

2.1 小程序端目录设计

一个结构清晰的小程序项目,应该像一本书,看目录就能大概知道内容。我习惯这样组织:

mini-program/ ├── app.js # 全局入口,检查登录态 ├── app.json # 页面路径和窗口配置 ├── pages/ │ ├── index/ # 首页:快捷打卡入口和今日状态 │ ├── report/ # 每日打卡页 │ ├── records/ # 历史记录页 │ ├── profile/ # 个人中心 │ └── admin/ # 管理员入口(根据角色显示) ├── components/ # 自定义组件(温度计、日期选择等) ├── utils/ │ ├── request.js # 封装wx.request,统一加header和错误处理 │ ├── auth.js # 登录态管理 │ └── format.js # 日期格式化等工具函数 ├── static/ # 图片、图标等资源 └── project.config.json # 项目配置文件

记住一个开发原则:页面文件里只负责onLoad、onShow和数据绑定,所有与后端交互的逻辑扔到utils/request.js里。这样后期换接口地址、做mock数据,都不用翻遍每个页面。我们项目里有一个常见思路是:在app.js的onLaunch里先调用wx.login拿到code,发给后端换openid和session,再包装成一个自定义的登录态token,后续每次请求都带上。

2.2 核心页面的数据流

每日打卡页是整个系统的“心脏”。打开这个页面时,页面要做的第一件事不是让用户填东西,而是先查询“今天是否已经打过卡”。如果已经打过,就直接展示一个“今日已完成”的卡片,防止重复提交。这个查询我用了一个很轻的接口:GET /api/report/today?uid=xxx。

用户提交打卡时,前端收集的数据包括:体温、是否有咳嗽/乏力等症状、近期是否去过中高风险区域、当前所在地(手动选择或文本输入)。点击提交后,utils/request.js会把数据POST给后端,后端做完校验和幂等处理后返回success: true,页面再跳转到完成状态。

有人会问:“用户每天填的都是一样的数据,能不能做个‘一键和昨天一样’?”技术上可以,后端增加一个缓存昨天最后一条记录即可。但这会带来一个体验误区:如果用户已经出现症状却懒得改,一键复制就变成了漏报。所以我建议不要做“完全一键复制”,可以做成“预览昨天记录,允许修改后提交”,从产品层面减少误操作。

2.3 后端API的设计规范与权限控制

后端目录我用的是标准分层的包结构:

src/main/java/com/example/epidemic/ ├── controller/ # 接口请求入口 ├── service/ # 业务逻辑 ├── mapper/ # MyBatis Plus数据访问 ├── entity/ # 数据库实体 ├── common/ # 通用返回体、异常处理 ├── config/ # 拦截器、Redis配置等 └── util/ # JWT、加密工具

接口设计遵循RESTful风格,但不追求极致。核心接口只有这几个:

POST /api/user/login # 登录,参数: code,返回token GET /api/user/info # 获取当前用户信息 POST /api/report/submit # 提交打卡 GET /api/report/today # 获取今日打卡状态 GET /api/report/list # 分页获取历史记录 POST /api/admin/stat # 管理员获取统计报表 POST /api/admin/export # 导出Excel

关于权限控制,我用了双保险:拦截器层面,所有/api/user/**和/api/admin/**都需要带token,并且解析出用户角色;业务层面,管理员接口还会校验role == "admin"。有人说“小程序前端可以隐藏管理员入口啊,为什么后端还要校验?”就是因为接口可以被直接抓包调用,如果你不做服务端校验,随便一个普通用户POST一个role=admin就可能拿到后台数据,这是非常严重的越权漏洞。凡是涉及管理功能的接口,必须服务端鉴权,这是底线。

3. 核心代码模块实现详解

3.1 用户登录与身份绑定

小程序登录和网页登录最大的不同是,它有一个“安静登录”的流程:wx.login拿到的code是一次性的,后端用code到微信服务器换openid。这里千万不要自己拼appid + secret + code去调微信接口,虽然老教程都这么写,但明文暴露secret的风险极大。正确的做法是:小程序端只传给后端一个code,由后端请求微信接口,拿到openid后再在本地数据库里匹配用户。

来个核心代码示例(我简化了一些异常处理):

@PostMapping("/user/login") public Result login(@RequestBody LoginDTO dto) { String url = String.format( "https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code", appid, secret, dto.getCode()); String resp = restTemplate.getForObject(url, String.class); JSONObject json = JSON.parseObject(resp); String openid = json.getString("openid"); User user = userMapper.selectOne( new LambdaQueryWrapper<User>().eq(User::getOpenid, openid)); if (user == null) { // 如果用户首次登录,自动创建账号并默认绑定为“普通用户” user = new User(); user.setOpenid(openid); user.setRole("user"); userMapper.insert(user); } String token = JwtUtil.createToken(user.getId(), user.getRole()); return Result.success(token); }

需要注意的是,生产环境里appid和secret不能硬编码在配置文件中提交到仓库,建议使用环境变量或者配置中心的占位符。我在部署脚本里用“从环境变量注入”的方式,防止代码仓库泄露密钥。

3.2 每日健康打卡的完整链路

打卡逻辑看起来简单,但最容易出bug的是“重复提交”和“数据篡改”。先看正常的链路:用户进入打卡页,填体温和症状,点击提交,前端把数据POST给后端。后端第一件事不是写库,而是检查Redis里有没有这个用户当天的打卡记录,有就直接返回“已打卡”,没有才继续。

核心的幂等逻辑可以这样写:

public synchronized SubmitReportResult submit(ReportDTO dto) { String key = "report:" + dto.getUserId() + ":" + LocalDate.now(); boolean first = redisTemplate.opsForValue() .setIfAbsent(key, "1", Duration.ofHours(24)); if (!first) { return SubmitReportResult.error("今日已打卡,无需重复提交"); } // 这里写真正的数据库插入和异常判断 Report report = new Report(); report.setUserId(dto.getUserId()); report.setTemperature(dto.getTemperature()); report.setSymptom(dto.getSymptom()); report.setLocation(dto.getLocation()); reportMapper.insert(report); // 插入后检查温度是否超过阈值,如果是,记录一条风险日志 if (dto.getTemperature() >= 37.3) { riskRecordMapper.insert(new RiskRecord(dto.getUserId(), "HIGH", "体温异常")); } return SubmitReportResult.ok(); }

这段代码有两个细节值得展开。第一,synchronized修饰方法在单体架构下有效,但如果是多实例部署就不够了,需要换成Redis分布式锁,或者直接用setIfAbsent本身的原子性就够用。这里用了Redis的原子操作,天然就是分布式安全的。第二,体温数据要校验范围,前端可以做0~50的限制,后端也必须做一次校验,防止有人写-1或999。这种“前后双校验”不是重复劳动,而是防御恶意请求的关键。

还有一个小坑:日期格式的问题。如果用LocalDate.now(),数据库时区必须和小程序所在时区一致,否则在晚上接近24点的时候,前端显示“已打完卡”,后端却是第二天,容易产生隔天数据错乱。我在部署环境里统一把JVM时区和MySQL时区都设置成Asia/Shanghai,这个问题才彻底消失。

3.3 后台数据统计与导出实现

后台统计页是管理员的日常使用界面,用来查看当天打卡率、异常人数、各区域分布。这里不需要前端做图表组件(用echarts小程序版也可以,但会增加包体),我的做法是后端返回统一格式的JSON,前端用简单的canvas或者CSS条形图展示,够用就行。

导出Excel是导师和领导最喜欢的功能,虽然技术上不复杂,但容易在处理大数据时卡死。我的建议是:导出操作不要和预览操作用同一个接口,这是很多新人容易踩的坑。比如点击“查看统计”是实时查询几万条记录,点击“导出Excel”如果也实时生成,同步等待时间过长可能让用户以为系统崩溃了。稳妥的做法是:导出时先异步生成文件,然后通过消息通知或轮询的方式下载。

下面是异步导出的一个简化思路:

public void exportReport(User admin, ExportDTO dto) { // 1. 写一条导出任务记录,状态为PROCESSING ExTask task = new ExTask(); task.setUserId(admin.getId()); task.setStatus("PROCESSING"); exTaskMapper.insert(task); // 2. 提交到线程池或消息队列 executor.submit(() -> { List<Report> list = reportMapper.selectByDate(dto.getStartDate(), dto.getEndDate()); File file = ExcelGenerator.generate(list); task.setFileUrl(uploadToOss(file)); task.setStatus("SUCCESS"); exTaskMapper.updateById(task); }); }

这种模式既保证了“点击导出秒回”,又不会卡死请求线程。如果你不想引入太重的中间件,直接用Spring自带的@Async加一个线程池也完全满足小规模项目需求,不要一上来就上Kafka,杀鸡焉用牛刀。

4. 一键部署方案:从Dockerfile到容器编排

4.1 为什么选择容器化部署

很多开发同学有个误区:能本地跑起来就算交付了。但实际到了服务器上,环境不一致、依赖缺失、端口冲突都会让人崩溃。尤其这种有前端、后端、数据库、缓存的系统,全靠手动部署,出一次错就要排查一个小时。

用Docker的好处是:把环境固化到镜像里,开发时是什么环境,部署时还是什么环境。尤其是Java应用,最烦的就是服务器上的JDK版本和本地不一致,用Docker FROMopenjdk:8-jdk-alpine直接规避掉。数据库、Redis也分别用官方镜像,互相隔离,删除重建都非常干净。

4.2 Dockerfile与镜像构建实录

后端镜像我用了一个很常规但好用的两阶段构建:

# 第一阶段:构建项目 FROM maven:3.8-openjdk-8 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests # 第二阶段:运行镜像 FROM openjdk:8-jre-alpine WORKDIR /app COPY --from=builder /app/target/epide-p-0.0.1-SNAPSHOT.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "/app/app.jar"]

构建命令很简单:docker build -t epide-backend:v1 .。这里有个小优化:把mvn dependency:go-offline单独放一层,后续如果只改业务代码,Maven依赖层能走缓存,构建速度会快很多。我在实际部署时,第一次构建用了近10分钟,后面每次只改代码,基本2分钟就完成,体验提升很大。

前端(小程序)其实不需要构建成镜像,因为小程序不是直接在服务器上运行的,它打包成代码包上传到微信平台了。那部署什么呢?部署的是“ 后端管理平台的Web页面 ”,比如给管理员用的统计页面,这个是Web应用,可以用Nginx跑。它的Dockerfile更简单:

FROM nginx:alpine COPY dist/ /usr/share/nginx/html/ COPY nginx.conf /etc/nginx/conf.d/default.conf

4.3 使用docker-compose拉起整套服务

现在很多教程喜欢直接敲docker run跑N个容器,这样一次两次没问题,可一旦要重建就很痛苦。我推荐用一个docker-compose.yml管理整套服务,代码托管时也可以把编排文件放在项目根目录,别人拿到仓库只需要一条命令就能起服务,这也是“一键部署”的真正含义。

下面是我在某模拟项目X中使用的简化版编排配置:

version: "3.8" services: mysql: image: mysql:8.0 container_name: epide-mysql environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: epide TZ: Asia/Shanghai ports: - "3306:3306" volumes: - ./mysql-data:/var/lib/mysql networks: - epide-net redis: image: redis:6.2 container_name: epide-redis command: redis-server --appendonly yes ports: - "6379:6379" networks: - epide-net backend: build: ./backend container_name: epide-backend depends_on: - mysql - redis environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/epide?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai SPRING_REDIS_HOST: redis TZ: Asia/Shanghai ports: - "8080:8080" networks: - epide-net web: build: ./web-admin container_name: epide-web depends_on: - backend ports: - "80:80" networks: - epide-net networks: epide-net: driver: bridge

注意几个点:第一,数据库密码不要写在文件里,用.env文件配合${MYSQL_ROOT_PASSWORD}来注入,.env一定加进.gitignore;第二,depends_on只是控制启动顺序,不代表MySQL已经完全可用,所以后端启动时最好加一个“等待数据库就绪”的机制,要么在启动脚本里做健康检查,要么在Spring Boot配置里加上spring.datasource.hikari.connection-timeout并把重试时间调长;第三,生产环境尽量给MySQL和Redis的端口做内网隔离,不要暴露到公网,但自己学习阶段为方便可以走默认端口。

执行流程走到这里,就只剩三行命令了:

docker-compose up -d mysql redis docker-compose up -d --build backend docker-compose up -d --build web

如果镜像都准备好了,直接docker-compose up -d也一样。想停止时就docker-compose down,数据卷会保留,重启不会丢数据。

4.4 HTTPS与域名绑定经验

小程序正式上线有一个门槛:所有请求的接口地址必须走HTTPS,而且域名必须是在微信公众平台后台配置过的合法域名。很多新手把后端跑了,但图省事用IP+端口,依旧无法请求,因为微信只允许特定域名的请求。

我自己的做法是:自己有一个备案域名,比如api.example.com(虚构示例),用Nginx做反向代理,把/api/转发到http://127.0.0.1:8080/。然后申请免费SSL证书,配置到Nginx上,就可以实现https://api.example.com/api/user/login这样的合法API。

下面是一段简化的Nginx配置,部署在Web容器里:

server { listen 443 ssl; server_name api.example.com; ssl_certificate /etc/nginx/ssl/example.com.crt; ssl_certificate_key /etc/nginx/ssl/example.com.key; location /api/ { proxy_pass http://backend:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }

这里最关键的是配置proxy_pass http://backend:8080;,因为在docker-compose的网络里,服务名backend就是容器的DNS名,不需要写成127.0.0.1。如果你是在宿主机上直接跑了Nginx,就要写成http://127.0.0.1:8080,很容易弄混。

5. 常见问题与排查记录

5.1 小程序上传数据偶发超时

有个同学跟我反馈,打卡接口偶尔要超过10秒才返回,但数据库查询都很正常。后来排查发现,是上线前把wx.request的timeout设置成了默认60秒,但后端在高峰期线程池被打满,请求排队时间过长。

这里有两层优化:第一层,前端把timeout改成15秒,避免用户等太久以为卡死;第二层,后端给HikariCP连接池做了配置,最大连接数从默认10调到了30,并且给关键接口加了Redis缓存,把高频读数据的耗时降到毫秒级。调整之后,打卡接口平均响应稳定在600ms以内。

5.2 数据库连接池爆满排查

还有一次是部署到服务器后,第二天早上管理员说系统打不开,登录日志里全是“连接池无空闲连接”。我一看是数据库连接池线程都在等待,但查询SHOW PROCESSLIST发现没有真正的慢查询,说明问题不在SQL,而是连接泄漏。

很多初学Spring Boot的人不知道,当自己手动开了数据库连接又没关时,连接会一直占用。后来我把项目中所有JdbcTemplate或MyBatis的调用改成使用try-with-resources,同时在application.yml里加了spring.datasource.hikari.max-lifetime: 1800000,让连接最多存活30分钟,才能被回收。这个配置看起来小,但能避免不少隐性事故。

最终的连接池配置可以参考:

spring: datasource: hikari: maximum-pool-size: 30 minimum-idle: 10 connection-timeout: 30000 max-lifetime: 1800000

5.3 部署后微信分享白屏的坑

我们的系统有一个“风险提醒”页面,用户点击分享按钮,想把页面转发给同学。结果测试时发现,在微信里打开这个分享卡片,点击后会进入一个白屏页面。排查了半天,发现原因是小程序页面路径写错了——因为我的页面路径是pages/admin/index/index,但分享配置里写成了pages/index/index,路径前面的二级目录被丢了。

更重要的是,所有分享出去的页面,必须确认该页面是否需要登录态。如果用户A分享给用户B,B打开时没有token,页面一上来就请求数据,token为空直接被后端拒绝,页面就渲染不出来。正确的做法是在分享页面的onLoad里做好未登录时的容错处理,要么引导用户先去登录,要么展示一个“非本人身份访问”的提示页,而不是把错误信息暴露出来。

有一说一,这种“微信分享白屏”的坑极其零碎,但在实际运营中很影响体验。后来我在utils/request.js里加了一个统一处理:请求返回401时,自动跳转到登录页。这样共享页面至少不会直接白屏,而是提醒你“身份已过期,请重新登录”,用户能明确知道下一步该怎么做。

我还整理过一份“上线前巡检清单”,这里直接分享出来:

检查项说明
合法域名配置小程序后台是否已经添加HTTPS接口域名
用户隐私协议是否在App.json里声明需要收集的信息字段
服务端幂等打卡接口是否防止重复提交
权限校验管理端接口是否做了角色校验
时区设置MySQL和JVM是否统一为Asia/Shanghai
数据备份是否配置了MySQL定时备份
日志级别生产环境是否把日志级别调成了INFO以上

写在最后

这套系统我从0到1的完成时间,前后大概用了两周。第一周把小程序端和后端接口全跑通,第二周才专门处理部署和安全性调优。到最后你会发现,真正费时间的不是写代码,而是把各种边界条件和环境问题磨平。尤其是像“同一用户同一天只能打一次卡”“后端接口必须鉴权”“部署时密码不能写死”这些事,虽然不起眼,但没做好的话,后面维护的每一分钟都在还债。

如果你正在做类似的东西,我给你的实际建议只有一条:先把本地的整套流程跑顺,再谈容器化。不要一开始就想着上K8s、上微服务,这个项目用Docker Compose已经足够体面。等你的数据量真的到了需要弹性扩展的那天,再考虑迁移也不迟。另外,所有代码都建议配套写一份简单的README,把部署命令和测试账号写清楚,不然等过了一个月再回来看,自己都不一定记得密码放在哪一个环境变量里。

希望这篇实战拆解能帮你少踩几个坑。要是你在复现过程中还有别的奇怪问题,可以带着报错日志和现象再聊,排障那部分永远比写功能更精彩。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进