ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

校园来访预约小程序前后端完整方案:状态机与审核链路设计

2026/9/20 12:16:54 拓冰建站 浏览量
校园来访预约小程序前后端完整方案:状态机与审核链路设计 简介一套校园来访预约小程序前后端完整方案面向高校中小学校园管理人员、运维人员及小程序开发者针对性解决疫情防控常态化下校外访客登记审批繁琐、通行信息难追溯的问题。其基于腾讯云开发构建无需自备服务器与域名即可实现校园动态、来访须知、来访预约、来访审核、用户登记等完整闭环流程。资源压缩包共四百八十六个文件以JS逻辑、WXML页面结构、WXSS样式、JSON配置等前后端代码为主还附带表格导出打印、二维码签到、核销等工具脚本及两份安装使用手册整体仅二点七三兆字节小巧完整。目前已有122人学习下载。对于需要快速搭建校园访客预约系统的技术人员可直接部署使用对于学习小程序云开发的学生也是一份能看清前后端交互、数据校验与云函数调用的完整示例工程。从预约名单导出、二维码自助签到到审核状态同步均有对应页面与云函数支撑配合手册可逐模块拆解学习。1. 校园来访预约小程序不缺表单缺的是把审核链路定死校园来访预约小程序这类项目真正决定上线后好不好用的往往不是预约表单写得漂不漂亮而是审核链路和双端契约是否从一开始就定死。访客填单、保安审核、门卫放行每一步都牵扯用户登记、来访预约、审核状态变更和校园动态展示这几块数据任何一处状态算错最后都会变成“我预约了但进不了门”的客诉。这篇文章按一套常见做法把前后端完整方案拆开讲后端用 Spring Boot 提供预约与审核接口小程序端负责登记、填单和状态查询中间用状态机和字段约束兜住异常顺带把校园动态、来访须知、电子通行证这些周边功能落到可运行的位置。适合正在做毕业设计、外包项目或者想把校园访客流程数字化的开发者看。2. 数据模型与接口契约先把预约状态机定死前后端协作最容易翻车的点是两端对“预约单处于什么状态”的理解不一致。后端说 status1 是已通过前端却拿 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 MapInteger, SetInteger 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); } } }这段代码把状态机收敛在一个类里后端所有修改预约单状态的地方都调check。TRANSITIONS定义的是“允许从哪个状态到哪个状态”不在表里的流转直接抛异常。三个边界要特别说明已驳回不能直接改已通过访客必须重新提交一张新单已通过的预约只能核销成已完成不能回头改成待审核待审核状态下访客可以取消审核通过后不能取消只能走门卫核销。为什么把审核记录独立成 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 ResultLong 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取值为morning或afternoon不在 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 ListCampusNewsVO 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组件的modedate并设置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 删除成功但数据库更新失败时最终会出现“凭证已失效但状态还是已通过”此时需要一个定时任务扫描超时未核销的已通过订单超过预约日期当天凌晨自动置为已完成。本文还有配套的精品资源点击获取