做社区新生儿疫苗预约这个小程序项目,前后花了大概三周时间。核心需求很简单:社区医院或卫生服务中心的儿保科,每天要接待大量新生儿接种疫苗,电话预约、纸质登记、到现场排队,整个流程混乱且容易出错。家长们不知道什么时候有苗、没苗,只能一趟趟跑;护士们忙着登记、查记录、打电话通知,工作效率极低。最终我基于Spring Boot搭后端,小程序做家长端,实现了排期展示、在线预约、疫苗库存管理、接种提醒这一整套闭环流程——也就是标题里那个“springboot社区新生儿疫苗预约小程序”,附带完整源码,编号26885。这篇就把整个项目的设计思路、核心模块、关键代码和踩坑实录完整梳理一遍,给正在做同类需求或者想拿Java后端练手完整项目的朋友一个可直接参考的模板。
先说清楚这个项目到底解决了什么问题:它把“家长-宝宝-疫苗-预约-接种”这条链路全部线上化。家长在小程序端绑定宝宝信息,查看未来7天的疫苗排期和剩余号源,选择合适时段一键预约;后台自动锁定库存、生成预约单,并通过微信订阅消息提醒家长按时到站。管理员在PC端维护疫苗品种、批号、库存量和每日可预约人数,随时查看预约数据。整个流程省掉了大量人工沟通成本,“有没有苗”这种问题再也不需要打电话问。
先交代一下项目的整体技术选型:后端用的是Spring Boot 2.7.x(JDK 1.8完全够用,如果想体验新特性也可以上Spring Boot 3.x),持久层框架用的MyBatis-Plus,数据库MySQL 8.0,缓存用的Redis,文件存储这一块我直接用MinIO做本地化部署,用来存宝宝的接种本照片、家长头像等附件。小程序端用原生微信小程序开发,没有引入复杂的UI框架——因为预约类小程序页面结构相对固定,原生语法完全够用,而且原生写出来的包体积更小,冷启动更快。
如果你问我为什么后端一定要用Spring Boot而不是别的,我的答案很直接:生态成熟、上手门槛低、社区问题沉淀多。这个项目里涉及的微信登录、定时任务、Redis缓存、接口鉴权,Spring Boot全都有现成的starter或者成熟整合方案。更重要的是,对于社区医院这类场景,后续可能需要对接医保、his系统,Spring Boot在传统行业信息化的接受度远超Node.js或Go,后续维护交接也更容易找到人。编码时我没有把接口直接“裸奔”给小程序,而是统一做了Token鉴权,这个后面细说。
关于“附源码26885”这串编号,其实它就是资料打包时的一个代号,方便归档索引。源码里包含两个部分:springboot-server后端工程和miniapp-client小程序前端工程,外加一份部署说明文档。下载解压后可以看到整体目录结构如下:
springboot-server/ ├── src/main/java/com/community/vaccine/ │ ├── controller/ // 接口层,小程序端所有请求入口 │ ├── service/ // 业务逻辑层 │ ├── mapper/ // MyBatis-Plus数据访问层 │ ├── entity/ // 数据库实体 │ ├── config/ // 全局配置(Redis、WebMvc、微信参数) │ ├── common/ // 统一返回结构、异常处理、工具类 │ └── task/ // 定时任务(过期订单处理、库存回补) ├── src/main/resources/ │ ├── mapper/ // MyBatis XML文件 │ ├── application.yml // 核心配置 │ └── sql/ // 建表脚本 miniapp-client/ ├── pages/ │ ├── index/ // 首页排期 │ ├── reserve/ // 预约流程 │ ├── order/ // 订单列表 │ ├── profile/ // 个人中心 │ └── baby/ // 宝宝管理 ├── utils/ // 请求封装、工具方法 └── app.js拿到源码建议先别急着跑,一定要先看SQL脚本里的建表语句,把表结构理解一遍,所有的业务逻辑都是围绕这些表展开的。
1. 项目整体设计与方案选型
说实话,拿到这个需求的第一反应并不是写代码,而是先想清楚一个核心问题:预约系统的“库存”到底该怎么定义。疫苗和普通商品不一样,它有严格的批号管理、效期管理和冷链要求。同一个疫苗品种,可能同时存在多个批号,不同批号的库存数量不同、效期不同,甚至接种年龄要求也不同。如果简单粗暴地把库存设计成一个总数,后续对账会很痛苦。最终方案是“疫苗品种-批号-排期”三层结构,每一条排期记录关联到具体批号,预约时锁定的是某个排期下该批号的剩余可约数量。
1.1 为什么是Spring Boot + 小程序这套组合
对社区级预约系统来说,选型的第一原则不是“炫技”,而是“稳妥”。Spring Boot + 小程序的组合有几层考量。第一,微信小程序是家长端最自然的存在形态,不用下载App,微信里搜一下或者扫个码就能打开,对中老年带娃群体极其友好;第二,Spring Boot后端能同时兼顾预约接口、管理后台接口、定时任务,一个工程搞定全部,避免“一个项目拆三个服务”的过度设计;第三,这套组合网上案例极多,社区医院的信息科真要去改代码,遇到问题搜得到解决方案。第四,微信生态自带订阅消息能力,预约成功后由后端主动推送提醒,不需要家长装额外App或者关注公众号,体验和触达效率都很好。
1.2 核心功能模块拆解
整个系统按角色分为两类:家长端的“预约使用者”和管理端的“机构管理员”。
家长端小程序聚焦四个页面能力:
- 首页排期:展示未来7天可预约的疫苗列表,按日期分组,显示每个时段的剩余号源。
- 预约提交:选择一个排期时段,确认宝宝信息,提交预约。
- 订单列表:查看待接种、已完成、已取消状态的预约单,支持取消预约操作。
- 宝宝管理:新增/编辑宝宝档案,包括姓名、出生日期、疫苗本编号。
管理端实际做成了一个轻量的Web页面(Spring Boot的Thymeleaf模板),集中处理:
- 疫苗品种维护:疫苗名称、适用月龄、剂次说明。
- 批号与库存:批号、生产日期、效期、入库数量、剩余数量。
- 排期管理:每天每个疫苗品种开放多少个号、具体时段(如09:00-10:00、10:00-11:00)。
- 预约查询:按日期、疫苗、状态筛选,导出Excel。
还有一个容易被忽略但非常关键的能力——定时任务。每天凌晨扫描预约单,把前一天“已预约但未到站接种”的订单自动标记为过期,并回补对应时段的号源。这个逻辑不写的话,放鸽子的人一多,号源就慢慢被僵尸订单占满,护士得手动清理,特别痛苦。
1.3 技术栈与性能预期
整体技术栈列一张表,方便对照:
| 层面 | 技术选型 | 选型理由 |
|---|---|---|
| 后端框架 | Spring Boot 2.7.x | 稳定、资料多、适合快速交付 |
| ORM | MyBatis-Plus | 单表CRUD不用写SQL,复杂查询走XML |
| 数据库 | MySQL 8.0 | 存储事务性数据,预约扣库存必须有事务保证 |
| 缓存 | Redis 5.x | 号源扣减、token缓存、热点数据缓存 |
| 文件存储 | MinIO | 部署在局域网,不依赖公网OSS,数据自主可控 |
| 定时任务 | Spring @Scheduled | 单机部署足够,不需要引入XXL-Job这类重组件 |
| 小程序端 | 原生微信小程序 | 包体小、启动快、页面定制灵活 |
性能预期其实没必要做太高:社区医院一个接种点的日活预约量通常只有几百单,按每天500单、峰值QPS约20来估算,单机部署、MySQL连接池给到20,Redis做缓存扛住首页排期查询的读压力,完全绰绰有余。真正需要关注的是并发扣库存时的数据一致性,而不是无脑上微服务。
2. 核心业务逻辑与数据模型设计
预约系统的本质是“在有限资源下做资源分配”,所以数据模型设计必须围绕资源来展开。我这里最核心的几张表是:疫苗品种表(vaccine)、批号表(vaccine_batch)、排期表(vaccine_schedule)、预约单表(appointment)和宝宝表(baby)。排期表承担着“号源”这个核心角色,它决定了某一天、某个时段、某个疫苗批号最多可以预约多少人。排期表的字段大致如下:
CREATE TABLE `vaccine_schedule` ( `id` bigint NOT NULL AUTO_INCREMENT, `vaccine_id` bigint NOT NULL COMMENT '疫苗品种ID', `batch_id` bigint NOT NULL COMMENT '疫苗批号ID', `schedule_date` date NOT NULL COMMENT '接种日期', `time_slot` varchar(32) NOT NULL COMMENT '时间段 如09:00-10:00', `total_quota` int NOT NULL COMMENT '总号源数', `remain_quota` int NOT NULL COMMENT '剩余号源数', `status` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0停用', `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_date_vaccine` (`schedule_date`, `vaccine_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;表建完之后,业务上最关键的就是预约流程的状态管理。我把预约单一共设计了五个状态,流转关系必须清晰:
- 待接种:用户提交预约成功,号源已锁定,等待用户到站。
- 已完成:用户到站接种,管理员在后台确认完成。
- 已取消:用户主动取消,号源立即回补。
- 已过期:预约日期过了,用户没有到站,定时任务批量处理,号源回补。
- 已作废:管理员因突发情况作废预约单,比如疫苗临时缺货。
这里有一个容易搞错的细节:用户取消后号源回补是“立即”的,但是“过期回补”是“延迟”到第二天凌晨才执行的。为什么?因为当天还有现场接种的可能,护士现场登记时如果发现这个宝宝来了,可以直接把订单标记为已完成,而不是先让定时任务把号源回收掉,造成库存和实际到站人数对不上。
2.1 号源扣减的并发控制方案
预约场景的并发控制是核心中的核心。两个家长同时点击同一个时段的最后一个号,系统必须保证只有一个人能预约成功。最朴素的做法是直接对数据库行加锁,也就是SELECT ... FOR UPDATE,把那一行排期记录锁住,然后判断剩余号源并更新。这个方案正确性没问题,但锁表行期间其他预约请求全部阻塞,在高并发下性能不好看。而且社区接种点经常出现“某个时段一放号,几秒钟被抢完”的情况,数据库行锁撑得住,但体验一般。
我最终采用的是“Redis预扣 + 数据库兜底”的双层方案:
第一步:预约请求进来,先操作Redis,用DECR命令扣减该时段剩余号源的计数器。如果返回结果小于0,说明没号了,直接返回“已约满”,同时INCR把计数器加回来,保证计数器不为负。
第二步:Redis扣减成功后再走数据库事务。事务里先查排期记录(带悲观锁),确认数据库里的剩余号源确实大于0,然后插入预约单、更新剩余号源,事务提交。如果事务失败,必须补偿性把Redis计数器加回来。
核心代码:
@Transactional(rollbackFor = Exception.class) public AppointResult createAppointment(Long scheduleId, Long babyId) { String redisKey = "vaccine:quota:" + scheduleId; long remain = redisTemplate.opsForValue().decrement(redisKey); if (remain < 0) { redisTemplate.opsForValue().increment(redisKey); return AppointResult.fail("该时段已约满"); } try { VaccineSchedule schedule = scheduleMapper.selectForUpdate(scheduleId); if (schedule == null || schedule.getRemainQuota() <= 0) { throw new BusinessException("该时段已约满"); } Appointment appointment = new Appointment(); appointment.setScheduleId(scheduleId); appointment.setBabyId(babyId); appointment.setVaccineId(schedule.getVaccineId()); appointment.setAppointDate(schedule.getScheduleDate()); appointment.setTimeSlot(schedule.getTimeSlot()); appointment.setStatus(AppointmentStatus.PENDING); appointmentMapper.insert(appointment); schedule.setRemainQuota(schedule.getRemainQuota() - 1); scheduleMapper.updateById(schedule); return AppointResult.ok(appointment); } catch (Exception e) { redisTemplate.opsForValue().increment(redisKey); throw e; } }这个方案的好处是:Redis扣减扛住大部分并发流量,数据库悲观锁只是兜底,不会出现超卖。注意一点:Redis计数器初始化的时机,是管理员发布排期时顺便写入,并且要设置过期时间,避免排期数据长期占用Redis内存。
2.2 库存回补的幂等性设计
号源回补最怕的是“重复回补”。设想一下:用户先取消预约,随后定时任务又扫描到这张“已取消”的订单,把它当做过期单再回补一次,号源就多了,实际库存对不上账。
解决办法是给回补逻辑加状态前置条件。不管是用户取消还是定时任务过期处理,执行前都必须用UPDATE appointment SET status = 新状态 WHERE id = ? AND status = 原状态这种条件更新语句,只有更新影响行数为1时才执行号源回补。比如取消预约:
int updated = appointmentMapper.cancelIfPending(appointmentId); if (updated == 1) { scheduleMapper.increaseRemainQuota(scheduleId, 1); redisTemplate.opsForValue().increment("vaccine:quota:" + scheduleId); }cancelIfPending对应的SQL是UPDATE appointment SET status='已取消' WHERE id=#{id} AND status='待接种'。这个条件更新天然保证了幂等,哪怕同一个取消请求被重复提交,第二次因为状态已经不是待接种,更新行数为0,不会重复回补库存。
2.3 排期发布与库存初始化的联动
后台管理员发布排期时,要同时做三件事:插入排期记录、初始化Redis计数器、检查排期日期是否在疫苗效期内。这里还有一个业务细节容易被忽略——疫苗批次的效期。疫苗批次有生产日期和有效期,发布排期的时候如果没做效期校验,可能出现排期日期已经超过疫苗效期的情况,预约倒是成功了,但苗根本不能打。
我在排期发布接口里加了一层校验:scheduleDate必须在批次效期之前,否则直接拒绝发布。另外,同一个疫苗品种在同一天不能重复创建相同时间段的排期,否则页面展示会出现两个重复入口,家长根本不知道选哪个。
3. 核心模块实现与关键代码解析
整个后端工程里,接口层其实很薄,真正的复杂度集中在微信登录、预约下单、消息通知三个地方。下面按模块拆开讲。
3.1 微信登录与Token鉴权
小程序端用户点击“微信一键登录”,前端调用wx.login()拿到临时凭证code,传给后端/api/auth/login接口。后端用这个code去向微信接口换取openid和session_key:
public WxLoginResult wxLogin(String code) { String url = "https://api.weixin.qq.com/sns/jscode2session?appid=" + appId + "&secret=" + appSecret + "&js_code=" + code + "&grant_type=authorization_code"; String resp = restTemplate.getForObject(url, String.class); JSONObject json = JSON.parseObject(resp); String openid = json.getString("openid"); String sessionKey = json.getString("session_key"); // 查库,没有openid就自动注册,有就直接登录 User user = userMapper.selectByOpenid(openid); if (user == null) { user = new User(); user.setOpenid(openid); userMapper.insert(user); } // 生成自己的token,写入Redis,有效期7天 String token = UUID.randomUUID().toString().replace("-", ""); redisTemplate.opsForValue().set("login:token:" + token, openid, 7, TimeUnit.DAYS); return new WxLoginResult(token); }这里要特别提醒:session_key不要返回给前端,也不要自己存库。它是微信会话密钥,理论上只能保存在服务端,用于解密手机号或者某些敏感数据。实际开发中不少人把session_key直接塞到数据库里,这是不必要的安全风险。我们的token是自己生成的UUID,和微信的session_key完全隔离,后续所有接口只需要校验这个token对应的openid即可。
登录之后,所有请求统一走一个拦截器AuthInterceptor:
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader("Authorization"); if (StringUtils.isBlank(token)) { throw new BusinessException(401, "未登录"); } String openid = redisTemplate.opsForValue().get("login:token:" + token); if (StringUtils.isBlank(openid)) { throw new BusinessException(401, "登录已过期"); } request.setAttribute("openid", openid); return true; }把openid放到request的attribute里,后续controller通过(String) request.getAttribute("openid")就能拿到当前用户,省去每个接口重新解析token的重复代码。这里有个细节:用Redis存token的好处是可以随时踢人下线,比如用户更换手机登录或者后台封禁账号,删掉Redis key就能让旧token立即失效。如果用无状态JWT,做到这步就得维护黑名单,反而更麻烦。
3.2 预约下单接口的完整流程
预约接口是整个项目里最核心的一个接口,它的完整流程是这样的:
- 前端传
scheduleId和babyId,后端先校验宝宝是否属于当前openid下的用户。 - 检查排期状态是否为启用(status=1),日期不能是过去的日期。
- 走Redis预扣号源。
- 数据库事务里创建预约单、扣减库存。
- 事务提交后,异步发送微信订阅消息,告诉家长预约成功。
- 返回预约单详情给前端。
这里有一个RESTful接口前后端联调容易踩的坑:预约接口必须校验宝宝归属权。很多项目上线后才发现,只要知道babyId就能给别人的宝宝预约,这是严重越权漏洞。我用了最简单的办法:babyMapper.selectByIdAndOpenid(babyId, openid),一个条件SQL就把问题堵住了。
3.3 微信订阅消息的发送实现
微信订阅消息是预约系统的最佳“自动通知”手段。用户预约时小程序端需要先调用wx.requestSubscribeMessage,让用户确认授权接收通知,拿到一个requestId,然后把requestId传给后端。后端拿到这个凭证后,再结合预约单数据去调微信接口发送订阅消息。
后端发送的核心逻辑:
public void sendAppointmentNotify(Long appointmentId) { // 查预约单 + 宝宝信息 + 排期信息 Appointment appointment = appointmentMapper.selectDetailById(appointmentId); JSONObject data = new JSONObject(); data.put("thing1", new JSONObject().put("value", appointment.getVaccineName())); data.put("date2", new JSONObject().put("value", appointment.getAppointDate() + " " + appointment.getTimeSlot())); data.put("thing3", new JSONObject().put("value", "请按时携带接种本到社区中心")); JSONObject body = new JSONObject(); body.put("touser", appointment.getOpenid()); body.put("template_id", WxConfig.subscribeTemplateId); body.put("page", "pages/order/order"); body.put("data", data); // 请求微信接口发送 wxApiClient.sendSubscribeMessage(body); }注意value字段有字数限制,比如thing类型字段最长20个字符,超出会被微信接口拒绝。疫苗名称加上“请按时携带接种本到社区中心”这串文字,一定要提前做截断处理,不然线上会莫名其妙报43004错误——这个坑我踩过一次,排查了很久才发现是某个疫苗名称长了两个字。
3.4 定时任务处理过期预约单
定时任务用Spring自带的@Scheduled注解就够用了。我配置成每天凌晨1点执行:
@Scheduled(cron = "0 0 1 * * ?") public void handleExpiredAppointments() { // 查询所有预约日期小于今天的待接种订单 List<Appointment> expiredList = appointmentMapper.selectExpiredPending(); for (Appointment item : expiredList) { int updated = appointmentMapper.markExpiredIfPending(item.getId()); if (updated == 1) { scheduleMapper.increaseRemainQuota(item.getScheduleId(), 1); redisTemplate.opsForValue().increment("vaccine:quota:" + item.getScheduleId()); } } }这一步最关键的是“只处理预约日期早于今天的订单”。千万别用“状态为待接种且当前时间超过预约时段”这种条件,因为如果当天临时停电、停诊,所有当天订单都会在第二天被误判为过期,家长预约单明明还有效,号源却被回收了。我的方案是统一按“日期”维度处理:过期的定义是“预约日期 < 今天”,不是“预约时段已过”。一个月的运行下来,这个口径没有出过问题。
4. 小程序端实现要点与常见坑
小程序端页面不多,但每个页面都有自己的注意点。我按实际开发的顺序来说,方便对照源码看。
4.1 登录态维护和小程序冷启动
小程序的登录态维护不能只靠wx.login(),因为wx.login()拿到的code是一次性的,后端换的token虽然能存7天,但小程序本身可能被用户杀掉重开、或者7天没打开过,token早过期了。我在app.js的onLaunch里做了静默登录逻辑:先读本地storage里的token,带着token调/api/auth/check接口,返回有效就直接用;返回401就重新调wx.login()换取新token,然后继续跑业务。
// utils/request.js function ensureLogin() { return new Promise((resolve, reject) => { const token = wx.getStorageSync('token'); if (token) { request('/api/auth/check', { token }, { noAuth: true }) .then(() => resolve(token)) .catch(() => doWxLogin(resolve, reject)); } else { doWxLogin(resolve, reject); } }); }这里有个体验细节:登录操作不能阻塞首屏渲染。首页排期数据加载和登录流程是并行触发的,后端接口对于“未登录”的请求只返回401,前端拦截器捕获到401后执行登录,登录成功再重新发起原请求,而不是让用户白屏等待。具体实现是在request方法里加一层“401时自动重试一次”的逻辑。
4.2 首页排期的加载更多与下拉刷新
首页排期列表是“按日期分组展示疫苗排期”,一个月下来可能有几百条排期记录,不可能一次性全部加载。我用了分页加载:首次加载当前日期之后7天的排期,每次上拉触底时加载后续7天的数据,并把日期范围往后平移。
页面结构上,一个scroll-view包住整个列表,用bindscrolltolower触发加载更多。这里有一个小程序原生开发的经典坑——scroll-view的lower-threshold设得太大会导致连续触发多次加载,我设成50px,并且在数据加载中加一个loadingMore标志位,防止重复请求:
onReachBottom() { if (this.data.loadingMore || this.data.noMore) return; this.loadMoreSchedules(); }onReachBottom是页面级滚动触底事件,直接用就好,不需要手动绑定scroll-view的低滚动事件。另外,列表数据更新必须用setData替换整个数组,而不是push之后单独set某一条,否则视图层性能会明显变差。
4.3 日期选择器的坑:不能用picker的mode="date"直接限制范围
预约页需要选择一个接种日期,很多人的第一反应是用微信原生picker的mode="date"。但这个组件默认只能限制start和end字符串,并不能按“排期是否开放”来禁用日期。实际开发你会发现,用户随便选一个没排期的日子也能进页面,最后提交预约时才提示“该日期无排期”,体验非常差。
我的做法是在预约页用一个自定义的星期条(横向滚动的7天日期条),只展示后端接口返回的有排期的日期。这样用户能选的就一定是有效日期,后端接口只做兜底校验,前端展示层面就已经把无效日期过滤掉了。这个交互改动虽然增加了少量开发量,但对用户体验的提升非常明显。
4.4 订单状态的展示与主动刷新
订单列表页按状态Tab展示:待接种、已完成、已取消。这里又一个容易踩的坑:用户从首页预约成功后跳转到订单列表,列表页的数据可能是旧数据,因为页面在tab切换时不会重新触发onLoad。解决办法是在onShow生命周期里主动刷新当前tab的数据:
onShow() { if (this.data.currentTab) { this.loadOrders(this.data.currentTab); } }onShow每次页面显示都会触发,比onLoad更可靠。如果担心频繁请求,可以加一个“距上次刷新超过30秒才重新拉取”的节流逻辑。
5. 常见问题与排查技巧实录
这个项目从开发到上线,我收集了一堆实际踩过的问题,不少是网上搜不到的细节。整理成表格,方便直接对照排查。
| 症状 | 根因 | 解决方法 |
|---|---|---|
| 小程序请求后端全部404 | 后端端口用了8080,但小程序配置了80端口访问 | 确认application.yml的server.port,把小程序request的baseUrl改为http://IP:8080 |
| 预约提交时“已约满”,但页面显示还有号 | Redis计数器与数据库不一致 | 发布排期后强制初始化Redis计数器;每次库存变更后同步更新两个数据源 |
| 订阅消息发送报41030 | template_id错误或与小程序AppID不匹配 | 检查微信公众平台里选用的模板ID,必须是当前小程序账号添加的模板 |
| 订阅消息报43101 | 用户未授权订阅消息 | 前端必须调用wx.requestSubscribeMessage且用户点击允许;一次授权只能发送一次消息 |
| 开发者工具能调通,真机不行 | 局域网IP问题,或未配置合法域名 | 真机调试时后端地址不能写localhost,要用局域网IP;上线必须配置https域名 |
| 数据库连接超时 | 连接池太小或MySQL线程数打满 | 调整spring.datasource连接池参数,初始5、最大20即可 |
| 定时任务执行了两次 | 没加分布式锁,多实例部署导致 | 单机部署加@Scheduled即可;多实例需引入Redis分布式锁 |
5.1 微信开发者工具看网络请求的技巧
联调阶段很多人习惯在开发者工具里点“Network”面板,但有时候小程序发起的请求在Network面板里根本看不到,尤其是wx.request封装了promise且调用链比较深的情况。我的经验是:不要过度依赖Network面板,直接在request.js统一封装的函数里打console.log,输出请求路径、参数、返回数据。这种做法虽然“土”,但对调试后端接口最直观,还能顺手把请求耗时打出来:
console.log(`[API] ${url} params=`, data); console.time(url); wx.request({ ... success: (res) => { console.timeEnd(url); console.log(`[API] ${url} resp=`, res.data); }});如果你需要看真实的请求和响应体,也可以打开开发者工具的“调试器→Network”,但前提是勾选了“不校验合法域名”并且在真机调试时把域名校验关掉。线上环境建议把这类日志从代码里移除,避免敏感信息打到生产日志里。
5.2 数据库和Redis数据一致性排查方法
环境跑一段时间后,我最担心的是“页面展示余号数量”和“数据库实际库存”对不上。这个问题基本都出在异常链路:Redis扣减成功了,但数据库事务回滚了,没有把计数器加回来。排查思路是先对比两个数据源:
- 查数据库:
SELECT schedule_id, remain_quota FROM vaccine_schedule WHERE schedule_date >= CURDATE() - 查Redis:用
redis-cli批量查vaccine:quota:*的值
如果发现不一致,优先检查代码里所有decrement之后是否都有对应的increment补偿。这里给一个建议:把Redis扣减和数据库事务放在同一个方法里,并且补偿逻辑写在使用try-catch包裹的同一段代码中,不要分到不同方法,否则容易出现漏补偿。
5.3 部署上线后的域名与HTTPS问题
小程序正式上线要求后端接口必须是HTTPS,而且域名必须在小程序后台配置到request合法域名里。社区医院这种场景,一般没有现成的HTTPS域名和证书,建议用Nginx做反向代理,把证书配置在Nginx层,后端Spring Boot仍然跑HTTP端口,通过proxy_pass转发。
Nginx核心配置片段:
server { listen 443 ssl; server_name vaccine.example.com; ssl_certificate /etc/nginx/cert/fullchain.pem; ssl_certificate_key /etc/nginx/cert/privkey.pem; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这个方案的好处是后端代码不需要做任何改动,证书到期只需要在Nginx层替换。还有一点:小程序的request域名不能带端口,所以就算后端端口是8080,Nginx也必须监听443并且不带端口转发,否则真机请求会被微信拦截。
5.4 高并发瞬间的数据库连接打满处理
有一次实测,早上8点放号,瞬间涌入几百个预约请求。数据库连接池默认配置往往是10,直接被打满,部分请求超时。排查后发现是连接池太小,但更根本的原因是:每个预约请求都要先查排期、再插预约单、再更新库存,总共3次数据库交互,加上登录校验等,一次完整的预约流程要占用数据库连接接近1秒。解决方式有两个:一是调连接池,maximum-pool-size设置到20;二是把校验类逻辑尽量往Redis层挪,减少数据库交互次数。实测调整后,500个并发请求全部在2秒内处理完成,没有出现连接池耗尽。
6. 部署流程与后续可扩展方向
项目跑通只是第一步,能不能稳定跑起来、后续能不能扩展,才见真功夫。这段就讲清楚从源码到线上部署的完整链路。
6.1 后端打包与启动
后端是标准Maven工程,打包:
mvn clean package -DskipTests产物在target/springboot-vaccine-server.jar。部署到服务器上,用nohup启动:
nohup java -jar springboot-vaccine-server.jar \ --spring.profiles.active=prod \ --server.port=8080 \ > app.log 2>&1 &注意--spring.profiles.active=prod,生产环境的数据库连接、Redis地址、微信AppSecret这些配置别放在application.yml里明文写死,用环境变量或者--参数覆盖。我一般是维护一个application-prod.yml,其中数据库密码和AppSecret用${DB_PASSWORD}这类占位符,启动脚本从环境变量里注入,避免把密钥跟着源码一起传出去。
6.2 小程序端构建与发布
小程序端源码在miniapp-client目录,直接用微信开发者工具打开,填好自己的AppID,修改utils/config.js里的baseUrl,然后点击“上传”,在微信公众平台提交审核。审核通过后发布上线即可。
需要提醒的是:线上版的baseUrl必须是HTTPS域名,不能是http://服务器IP:8080这样的局域网地址。所以小程序的baseUrl配置要区分环境,开发版用http://192.168.x.x:8080,体验版和正式版用https://vaccine.example.com。我通常写一个环境判断:
const env = 'prod'; const baseUrl = env === 'prod' ? 'https://vaccine.example.com/api' : 'http://192.168.1.10:8080/api';6.3 扩展方向:从预约到接种闭环
当前版本聚焦预约,但社区医院其实还有更多可以深挖的场景。
第一个方向是“接种前自助建档”。家长在小程序里提前填写宝宝健康状况、过敏史、既往接种反应,护士到站后直接打印知情同意书,节省现场问询时间。这个需要后端增加一个健康档案表,并和预约单关联。
第二个方向是“电子接种证”。虽然疫苗本还是实体为主,但可以在小程序里展示宝宝的接种历史,每次接种完成后自动更新小程序里的“接种记录”页面,相当于电子版接种卡。这样做的好处是对家长透明,不容易漏种、错种。
第三个方向是“多社区入驻”。当前版本是单点部署,如果后续社区卫生服务中心有多个站点,可以在排期表和预约单里增加stationId字段,把系统升级成多站点共享模式。这块改动不大,但能显著提升系统的复用价值。
第四个方向是“疫苗库存预警”。管理员后台可以增加一个预警规则,比如某批次库存低于20%时自动提醒采购,或者某疫苗连续3天约满率超过90%时提醒增加放号量。这种数据分析功能实现难度不大,但对实际运营的帮助很大。
这些方向不需要推翻现有架构,都是在现有表结构上做加法。这也是我当时刻意控制代码耦合的原因——业务逻辑全部在service层,新增功能时基本不需要动controller和mapper的既有接口。
7. 源码使用说明与实际运行建议
最后把源码的打开方式和运行步骤完整列一遍。源码里我会放一个README.md,但很多细节只有真跑一遍才会发现。
7.1 本地开发环境要求
需要的软件版本:
- JDK 1.8 或以上(我用的JDK 1.8,Spring Boot 2.7.x兼容)
- Maven 3.6+
- MySQL 8.0(5.7也行,但字符集建议utf8mb4)
- Redis 5.x
- 微信开发者工具(最新稳定版)
先把SQL脚本springboot-server/src/main/resources/sql/init.sql导入MySQL,然后修改application.yml里的数据库连接、Redis连接、微信AppID和AppSecret。这里提醒:AppID和AppSecret必须是你自己的小程序账号下的,不能用别人的,否则微信登录接口会报40013或40125。
7.2 启动顺序
建议按以下顺序启动:
- 启动MySQL和Redis,确认端口可访问。
- 启动Spring Boot服务,看控制台日志是否输出“Vaccine Server Started”。
- 用Postman或浏览器直接访问
http://localhost:8080/api/health,确认返回{ "status": "UP" }。 - 打开微信开发者工具,导入
miniapp-client目录,修改utils/config.js里的baseUrl为http://localhost:8080/api。 - 在开发者工具里点“编译”,看首页是否正常加载排期数据。
如果你用的是微信开发者工具的“不校验合法域名”模式,本地联调时baseUrl直接写http://localhost:8080/api就能跑通。真机预览的话,手机和电脑必须在同一局域网,并且baseUrl要改成电脑的局域网IP。
7.3 运行一周后需要重点检查的指标
系统跑起来不是终点,稳定运行才是。我建议上线后第一周重点看四类数据:
- 每天预约成功数、约满时段数量,评估号源配置是否合理;
- 过期未到站的订单数,如果占比超过10%,说明家长对接种时间提醒不够敏感,建议增加预约日前一天的二次提醒;
- 接口平均响应时间和错误率,重点关注首次发放号源时段的P95耗时;
- 数据库库存和页面展示是否一致,每天做一次对账。
这套检查方法帮我发现过一个真实问题:某疫苗连续三天预约率不足30%,原因是放号时段只设置了上午9点到10点,很多家长上班不方便。后来在后台把时段扩展到下午,预约率立刻上来了。这类调优只有系统真正跑起来之后才能发现。
写到这里,我个人最大的体会是:社区级预约系统技术难度并不高,真正难的是对业务场景的理解。疫苗预约不是“抢票”,它要考虑库存的批次账、号源的有效期、家长不到站的情况、护士现场登记的灵活性,这些隐含约束在需求文档里往往只有一句话,但落到数据模型和接口设计上,每一个都可能是坑。如果你正在做或者准备做类似的小程序预约项目,建议先把每一张表、每一个状态流转画清楚,再动手写代码,这比任何框架选型都重要。源码里我已经把完整的表结构和状态机都整理好了,照着跑一遍再改成自己的业务,会比从零开始顺畅很多。