校园来访预约小程序前后端完整方案:状态机与审核链路设计
2026/9/20 12:16:51 网站建设 项目流程

简介:一套校园来访预约小程序前后端完整方案,面向高校中小学校园管理人员、运维人员及小程序开发者,针对性解决疫情防控常态化下校外访客登记审批繁琐、通行信息难追溯的问题。其基于腾讯云开发构建,无需自备服务器与域名,即可实现校园动态、来访须知、来访预约、来访审核、用户登记等完整闭环流程。资源压缩包共四百八十六个文件,以JS逻辑、WXML页面结构、WXSS样式、JSON配置等前后端代码为主,还附带表格导出打印、二维码签到、核销等工具脚本及两份安装使用手册,整体仅二点七三兆字节,小巧完整。目前已有122人学习下载。对于需要快速搭建校园访客预约系统的技术人员,可直接部署使用;对于学习小程序云开发的学生,也是一份能看清前后端交互、数据校验与云函数调用的完整示例工程。从预约名单导出、二维码自助签到到审核状态同步,均有对应页面与云函数支撑,配合手册可逐模块拆解学习。

1. 校园来访预约小程序不缺表单,缺的是把审核链路定死

校园来访预约小程序这类项目,真正决定上线后好不好用的,往往不是预约表单写得漂不漂亮,而是审核链路和双端契约是否从一开始就定死。访客填单、保安审核、门卫放行,每一步都牵扯用户登记、来访预约、审核状态变更和校园动态展示这几块数据,任何一处状态算错,最后都会变成“我预约了但进不了门”的客诉。这篇文章按一套常见做法把前后端完整方案拆开讲:后端用 Spring Boot 提供预约与审核接口,小程序端负责登记、填单和状态查询,中间用状态机和字段约束兜住异常,顺带把校园动态、来访须知、电子通行证这些周边功能落到可运行的位置。适合正在做毕业设计、外包项目,或者想把校园访客流程数字化的开发者看。

2. 数据模型与接口契约:先把预约状态机定死

前后端协作最容易翻车的点,是两端对“预约单处于什么状态”的理解不一致。后端说 status=1 是已通过,前端却拿 2 当通过;后端允许从已驳回直接改成已通过,前端却不知道什么时候该显示“重新提交”。这些问题的根源都一样:没有在数据库表和接口层把状态机定死。

2.1 五张核心表的结构与设计理由

校园来访预约小程序虽然功能看着多,拆开就是五张表。

表名关键字段设计说明
visitoropenid, name, phone, id_card, face_url, create_time每个来访人一条记录,id_card 加唯一索引,防止同一个人被重复登记
visit_orderid, visitor_id, visit_date, time_slot, visitor_count, status, version, create_time一次预约一条记录,version 用于审核场景的并发控制
audit_logid, order_id, old_status, new_status, auditor_id, remark, audit_time审核历史只追加不修改,出现问题能回溯是谁在什么时间改的
campus_newsid, title, content, cover_url, status, publish_time校园动态列表的数据源,status 控制上架下架,不做物理删除
visit_noticeid, category, content, is_active, update_time来访须知按类别分条存储,比如“入校时间”“停车规定”“门禁要求”

需要注意,visit_order 里只存 visitor_id,不冗余姓名电话。冗余字段会让审核页面少一次联表查询,但一旦访客修改手机号,历史预约单上的手机号就跟着错乱。校园访客场景往往涉及安保追溯,宁可多一次 join,也要保证历史数据不可变。

2.2 预约单状态机的定义与流转边界

预约单的状态我一般定义为五个:0 待审核、1 已通过、2 已驳回、3 已取消、4 已完成。流转边界必须写进代码,不能让前端传什么就更新什么。

public class VisitOrderStatus { public static final int PENDING = 0; public static final int APPROVED = 1; public static final int REJECTED = 2; public static final int CANCELED = 3; public static final int FINISHED = 4; private static final Map<Integer, Set<Integer>> TRANSITIONS = Map.of( PENDING, Set.of(APPROVED, REJECTED, CANCELED), APPROVED, Set.of(FINISHED), REJECTED, Set.of(CANCELED) ); public static void check(int from, int to) { if (!TRANSITIONS.getOrDefault(from, Set.of()).contains(to)) { throw new IllegalStateException("非法状态流转: " + from + " -> " + to); } } }

这段代码把状态机收敛在一个类里,后端所有修改预约单状态的地方都调checkTRANSITIONS定义的是“允许从哪个状态到哪个状态”,不在表里的流转直接抛异常。三个边界要特别说明:已驳回不能直接改已通过,访客必须重新提交一张新单;已通过的预约只能核销成已完成,不能回头改成待审核;待审核状态下访客可以取消,审核通过后不能取消,只能走门卫核销。

为什么把审核记录独立成 audit_log 而不是在 visit_order 上叠 auditor 和 remark?因为一次预约可能被反复审核,初次审核通过后如果后续发现信息有误,还需要作废重审。audit_log 存在,每次变更都能对账,接口排查问题时会轻松很多。

2.3 接口清单与双端联调约定

接口路径方法入参要点说明
/api/auth/loginPOSTcode微信登录,返回 token 和 visitorId
/api/visitor/registerPOSTname, phone, idCard完善访客实名信息
/api/order/createPOSTvisitorId, visitDate, timeSlot, visitorCount提交来访预约
/api/order/myGET当前用户预约列表
/api/order/auditPOSTorderId, targetStatus, remark保安或管理员审核
/api/order/detailGETorderId预约详情,含审核记录
/api/news/listGETpage, pageSize校园动态分页
/api/notice/listGET来访须知列表

所有接口统一返回{ code, msg, data }结构,登录态通过请求头Authorization: Bearer <token>传递。这里有个约定要提前讲清:状态字段只允许后端改,前端提交审核时传 targetStatus 可以,传 oldStatus 校验也可以,但绝不能让前端直接传一整条 order 覆盖后端数据。把这条写进接口文档,能省掉后面一大堆扯皮。

3. Spring Boot 后端:预约、审核、动态三个核心模块怎么落地

后端模块里工作量最大的是预约提交、预约审核和校园动态列表。分开写,代码都能直接抄。

3.1 预约提交接口的最小实现

预约提交的核心不只是 insert 一条订单,而是提交前的时间校验、名额校验和查询校验。

@PostMapping("/api/order/create") public Result<Long> create(@RequestBody CreateOrderReq req) { Visitor visitor = visitorService.getById(req.getVisitorId()); if (visitor == null) { throw new BizException("访客不存在,请先登记"); } LocalDate date = LocalDate.parse(req.getVisitDate()); if (date.isBefore(LocalDate.now()) || date.isAfter(LocalDate.now().plusDays(30))) { throw new BizException("只能预约未来30天内的日期"); } long count = orderMapper.countByDateAndSlot(req.getVisitDate(), req.getTimeSlot()); if (count >= configService.maxOrdersPerSlot()) { throw new BizException("该时间段名额已满,请选择其他时段"); } VisitOrder order = new VisitOrder(); order.setVisitorId(visitor.getId()); order.setVisitDate(req.getVisitDate()); order.setTimeSlot(req.getTimeSlot()); order.setVisitorCount(req.getVisitorCount()); order.setStatus(VisitOrderStatus.PENDING); order.setVersion(0); orderService.save(order); return Result.ok(order.getId()); }

visitDate要求yyyy-MM-dd格式,timeSlot取值为morningafternoon,不在 The 里做具体时间解释。30 天限制来自学校访客场景的通行许可周期,太长容易失效。名额校验用的是先查后插,并发情况下可能超卖,所以建唯一索引uk_date_slot_visitor兜底,同一个人同一天同一时段只能有一条待审核订单。

3.2 审核接口与并发防重

审核接口最容易出的问题是重复审核:两个管理员同时打开一张单,一个点了通过,另一个点了驳回,如果直接用 update 语句覆盖,最终状态完全看谁最后执行。处理方式是查询时加行锁。

@Transactional public void audit(Long orderId, Long auditorId, Integer targetStatus, String remark) { VisitOrder order = orderMapper.selectByIdForUpdate(orderId); if (order == null) { throw new BizException("预约单不存在"); } VisitOrderStatus.check(order.getStatus(), targetStatus); order.setStatus(targetStatus); orderMapper.updateById(order); AuditLog log = new AuditLog(); log.setOrderId(orderId); log.setOldStatus(order.getStatus() - 1); log.setNewStatus(targetStatus); log.setAuditorId(auditorId); log.setRemark(remark); auditLogMapper.insert(log); }

selectByIdForUpdate会在事务内锁住这行记录,第二个审核请求必须等第一个事务提交后才会读到最新状态,此时check就会拦截非法流转。oldStatus这里不能用order.getStatus() - 1硬算,应该先保存旧值再改状态。如果不想用行锁,也可以在 update 语句里带where version = #{oldVersion}做乐观锁,失败就重新查单提示“已被其他人处理”。

3.3 校园动态与来访须知的缓存策略

校园动态和来访须知都是读多写少的数据,没必要每次都打数据库。动态列表加 Spring Cache 就能扛住大部分访问压力。

@Cacheable(cacheNames = "campusNews", key = "#page") public List<CampusNewsVO> listNews(int page, int pageSize) { return newsMapper.selectPage(page, pageSize); }

管理端发布或下架动态时执行@CacheEvict(cacheNames = "campusNews", allEntries = true)清空整个列表缓存。须知数据量小,常见做法是启动时全量载入内存,管理员更新后通过版本号让前端重拉。用版本号而不是直接清缓存,是因为须知内容可能很长,前端需要判断到底要不要刷新页面。

3.4 必调参数清单与常见误用

参数建议值说明
pageSize10校园动态列表分页大小
maxOrdersPerSlot50单个时间段可预约单数上限
maxDaysAhead30最大可预约天数
maxVisitorCount10单次预约来访人数上限

常见误用有三个:一是把审核状态直接交给前端传,后端不做状态机校验,前端 bug 直接污染数据;二是单次预约人数不限制,一张单进来二十人,现场核销对不上;三是时间段只存字符串不做交叠判断,导致“上午”和“09:30-12:00”同时在库里出现。建议时间槽位用枚举或字典表维护,前端展示文案,后端存固定 key。

4. 小程序端:用户登记、预约提交与审核状态的完整闭环

小程序端常见做法是 uni-app 写一套代码发布到微信小程序,也可以直接写原生小程序。下面的代码按 uni-app 给,原生写法逻辑一致,只是 API 名换成wx.前缀。

4.1 微信登录与前后端请求 token 处理

用户登记的前提是先拿到 openid,并让后端返回一个自定义登录态 token。小程序端完整流程是:uni.login拿临时 code,传给后端,后端拿 code 换 openid,生成 token 返回。

uni.login({ provider: 'weixin', success: async (loginRes) => { const res = await request.post('/api/auth/login', { code: loginRes.code }); uni.setStorageSync('token', res.data.token); uni.setStorageSync('visitorId', res.data.visitorId); uni.navigateTo({ url: '/pages/register/register' }); } });

loginRes.code是微信临时凭证,有效期五分钟且只能用一次。后端拿到 code 后调微信接口换 openid,并把 openid、session_key 落库。token 建议用 UUID 或 JWT,统一存在请求拦截器里塞进Authorization头。这里有个容易被忽略的点:uni.login每个用户每次进来拿到的 code 都不一样,所以后端不能只靠 code 判断用户是否已登记,要额外返回一个visitorId存本地,登记页判断这个值有没有值,决定显示“去登记”还是“直接预约”。

4.2 来访预约表单的校验与提交

表单字段一般包括来访人姓名、手机号、来访日期、时间段、来访人数、来访事由。前端校验是给用户即时反馈,后端校验才是真正的安全边界,两层都不能省。

function beforeSubmit(form) { if (!form.name) { uni.showToast({ title: '请填写来访人姓名', icon: 'none' }); return false; } if (!/^1[3-9]\d{9}$/.test(form.phone)) { uni.showToast({ title: '手机号格式不正确', icon: 'none' }); return false; } if (!form.visitDate) { uni.showToast({ title: '请选择来访日期', icon: 'none' }); return false; } if (form.visitorCount < 1 || form.visitorCount > 10) { uni.showToast({ title: '来访人数需在1到10人之间', icon: 'none' }); return false; } return true; }

日期选择用picker组件的mode="date",并设置start为今天、end为今天加 30 天。时间段用两个单选按钮。提交成功进入我的预约列表,列表页根据 status 显示不同状态。

4.3 审核结果在小程序端的轮询与展示

审核是异步操作,前端需要拿到最新状态。校园访客场景对实时性要求不高,没必要上 WebSocket,常见做法是下拉刷新加定时轮询。

async function loadMyOrders() { const res = await request.get('/api/order/my'); this.orderList = res.data.map(item => { switch (item.status) { case 0: item.statusText = '待审核'; break; case 1: item.statusText = '已通过'; break; case 2: item.statusText = '已驳回'; break; case 3: item.statusText = '已取消'; break; default: item.statusText = '已完成'; } return item; }); }
状态值页面文案操作区显示
0待审核显示“取消预约”按钮
1已通过显示“电子通行证”按钮
2已驳回显示驳回原因和“重新预约”按钮
3已取消灰色标签,无操作
4已完成绿色标签,显示核销时间

轮询间隔 30 秒比较合适,太频繁浪费请求,太慢影响体验。页面onShow时拉一次,切后台再回来能立刻刷新;onHide时清掉定时器,避免页面不可见时还在发请求。setInterval里嵌套网络请求会出现请求堆积,建议每次请求返回后再设置下一次定时。

4.4 校园动态列表的分页与导航栏标题动态设置

校园动态列表用onPullDownRefresh触发刷新,onReachBottom触底加载下一页。这个逻辑本身不复杂,但要注意动态标题配置:加载数据后用uni.setNavigationBarTitle设置当前页面的标题。如果同一套页面复用于校园动态和来访须知,category参数不同,导航栏标题应该随内容类型变化,而不是写死在页面配置里。

5. 审核通过之后:生成电子通行证与核销校验

预约审核通过后,如果只给访客一个“已通过”状态,进校门时门卫无法确认这张单是不是本人、有没有被冒用。更稳妥的做法是审核通过时生成一次性电子通行证,门卫处扫码核销,核销完这张单作废。

生成端逻辑放在审核通过之后:

public String issueTicket(Long orderId) { String ticket = UUID.randomUUID().toString().replace("-", ""); redis.set("ticket:" + orderId, ticket, Duration.ofHours(2)); return "CAMPUS-" + ticket.substring(0, 6).toUpperCase(); }

ticket用随机字符串而不是 orderId 拼接,这样即使有人猜到别人的 f_orderId 也构造不出一张有效凭证。Redis key 带订单号便于核销时反查,过期时间设两小时,和门岗处理高峰时段匹配。小程序端把这个值渲染成二维码,门卫用手机扫码后调核销接口。

核销接口需要做的校验有三层:第一层查 Redis 里是否存在该订单的 ticket,不存在直接提示“无效或已过期”;第二层比对请求里的 ticket 和缓存中的值是否一致;第三层校验订单状态已是“已通过”,避免已核销的订单被重复使用。

public void verify(String orderId, String ticket) { String cached = redis.get("ticket:" + orderId); if (cached == null || !cached.equals(ticket)) { throw new BizException("通行证无效或已过期"); } VisitOrder order = orderService.getById(orderId); if (order.getStatus() != VisitOrderStatus.APPROVED) { throw new BizException("当前订单状态不可核销"); } redis.del("ticket:" + orderId); orderService.updateStatus(orderId, VisitOrderStatus.FINISHED); }

核销成功后立即删除 Redis 中的 key,保证一张凭证只能进一次。如果出现门卫扫码成功但手机没网的情况,核销请求不会发出,所以不会误删 key。如果整个核销流程做进同一个事务,注意 Redis 操作和数据库更新的一致性,Redis 删除成功但数据库更新失败时,最终会出现“凭证已失效但状态还是已通过”,此时需要一个定时任务扫描超时未核销的已通过订单,超过预约日期当天凌晨自动置为已完成。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询