简介:基于Spring Boot框架的来访管理系统源码包,面向需要开发访客登记、预约与来访统计场景的Web开发者,覆盖管理员与访客两类角色,包含个人中心、访客管理、访客预约管理、访客来访管理等核心模块,体现前后端分离的分层设计与权限控制思想。压缩包共361个文件,大小约9.07MB,以java后端逻辑、vue前端页面为主体,辅以xml配置、sql数据库脚本、bat构建运行脚本、yml环境配置及svg/png静态资源,结构清晰便于直接导入IDE运行调试。具体功能包括列表、分页、新增、修改、删除等常规操作,以及按日期范围、特定字段筛选的提醒接口,并支持按值、按时间、分组统计,适合课程设计、毕业设计或企业内部访客系统的二次开发。已有27人学习/下载,源码附带完整前端页面与工具脚本,可帮助理解Spring Boot整合Vue、MyBatis等主流技术的实际落地方法。1. 从“登记本”到“数据闭环”:Spring Boot 来访管理系统到底在解决什么问题如果你去问门卫大爷,一套来访管理系统最核心的价值是什么,他大概率会说是“别让闲杂人进来”。但你要是站在运维和行政的角度看,答案完全不同:你需要知道谁来了、什么时候来的、来找谁、什么时候走的,以及这些数据能不能变成可追溯的台账。现实里很多单位还在用纸质登记本,高峰期排队不说,字迹潦草、信息造假、事后查证全凭感觉。换成一款基于 Spring Boot 框架的来访管理系统,核心工作就是把“人、事、时、地”串成一条数据链路:访客预约、审批、现场登记、出入核验、离场销账,每一步都有记录,每一步都能回溯。这篇文章我会按一线落地的方式,把这套系统的表结构、接口设计、状态流转和部署参数拆开讲清楚,给正在规划或已经在开发同类项目的同学一条能直接照做的路径。2. 先弄清业务模型和状态流转:表结构不是凭空设计出来的2.1 访客、员工、黑名单,三类实体决定了系统的边界做管理系统最忌讳一上来就写代码。先花半天时间把角色和实体梳理清楚,后面能少改一半需求。来访管理系统里业务实体并不复杂:访客、被访人(通常是员工)、预约单、进出记录,以及黑名单。但每个实体都有自己的“生命周期”,这个生命周期就是状态机。访客不是注册用户,多数情况是临时录入,录入后要查重,如果是黑名单直接拒绝。员工从组织架构里拉取,但多数小项目没有现成的组织架构接口,常见做法是在系统里维护一张简单的部门表和员工表,或者对接企业微信、钉钉的通讯录API。如果只做纯软件交付,我会建议先做成手动维护的员工表,把对接留作二期扩展,避免一期被外部系统卡住。预约单是最核心的对象。它的状态建议设计为:待审批、已通过、已拒绝、已签到、已完成、已取消、已过期。这里有个关键点:“已签到”和“已完成”要区分,已签到是访客到现场刷二维码或身份证后产生的状态,已完成是离场登记后的状态。很多团队把这两个状态合并,最后统计“访客是否还在园区”就完全没法算,只能靠猜。2.2 核心表结构:一张预约主表加三张辅助表,别过度设计访客管理系统最怕做成“千人千面”的大平台。我一般会控制表的数量,核心就是四张表:访客表、来访预约表、进出记录表、黑名单表。如果需要审批流,审批意见直接冗余在预约表里,用一个字段存最后审批结果,不需要单独建审批记录表,除非你有多级审批硬需求。访客表字段建议用手机号作为业务唯一键。很多人问要不要给访客建账号、密码?不要。访客不是系统用户,不需要登录,最多短信验证码核验身份。预约表要冗余访客姓名、手机号、证件号,因为同一访客可能多次来访,冗余后可以避免每次查询都去关联访客主表,统计效率更高。黑名单表要区分“拉黑类型”:临时拉黑和永久拉黑,还要有拉黑原因和操作人。这里有个容易被忽略的点:黑名单不能只按身份证号匹配,因为很多人不填身份证,必须支持手机号+姓名模糊匹配,否则黑名单形同虚设。进出记录表是流水表,只做插入,不做更新,配合预约状态来判定是否离场。下面给出一个简化但可落地的建表脚本片段,使用 MySQL 方言:-- 访客主表:手机号作为业务唯一键 CREATE TABLE visitor ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, phone VARCHAR(20) NOT NULL, id_card VARCHAR(32), created_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_phone_name(phone, name) ); -- 来访预约表:核心状态机载体 CREATE TABLE visit_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, visitor_id BIGINT NOT NULL, employee_id BIGINT NOT NULL COMMENT '被访人ID', visit_date DATE NOT NULL, start_time TIME NOT NULL, end_time TIME NOT NULL, purpose VARCHAR(255), status TINYINT NOT NULL DEFAULT 0 COMMENT '0待审批 1已通过 2已拒绝 3已签到 4已完成 5已取消 6已过期', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, audit_time DATETIME NULL, audit_user BIGINT NULL, INDEX idx_employee_date(employee_id, visit_date) ); -- 进出记录表:只追加,不更新 CREATE TABLE visit_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, type TINYINT NOT NULL COMMENT '1入场 2离场', device_no VARCHAR(64) COMMENT '门禁设备编号', record_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_order_time(order_id, record_time) );字段注释写清楚状态枚举,后面写状态机就不会乱。员工表、部门表、黑名单表这里不单独展开,结构都很直白。要注意的是时间字段全部用 DATETIME,不要用字符串,否则后期做统计区间查询时索引直接失效,这在生产环境是实打实的翻车现场。2.3 状态机怎么落地:用常量类而不是魔法数上面表里 status 用了 TINYINT,但代码里不能到处写数字。Spring Boot 项目里我习惯建一个枚举类或常量类,把状态流转规则收敛到一个地方。状态机不一定要用状态机框架,对大部分来访系统来说,一个带校验的服务方法就够了,但状态流转的合法性必须校验,否则会出现“已拒绝的预约单又被签到”这种逻辑漏洞。代码示例:public enum VisitStatus { PENDING(0, "待审批"), APPROVED(1, "已通过"), REJECTED(2, "已拒绝"), CHECKED_IN(3, "已签到"), COMPLETED(4, "已完成"), CANCELED(5, "已取消"), EXPIRED(6, "已过期"); private final int code; private final String desc; VisitStatus(int code, String desc) { this.code = code; this.desc = desc; } }@Service public class VisitOrderService { // 允许的状态流转 private static final Map<Integer, List<Integer>> STATE_MACHINE = new HashMap<>(); static { STATE_MACHINE.put(VisitStatus.PENDING.getCode(), List.of(VisitStatus.APPROVED.getCode(), VisitStatus.REJECTED.getCode(), VisitStatus.CANCELED.getCode())); STATE_MACHINE.put(VisitStatus.APPROVED.getCode(), List.of(VisitStatus.CHECKED_IN.getCode(), VisitStatus.CANCELED.getCode(), VisitStatus.EXPIRED.getCode())); STATE_MACHINE.put(VisitStatus.CHECKED_IN.getCode(), List.of(VisitStatus.COMPLETED.getCode())); } public void changeStatus(Long orderId, int nextStatus) { VisitOrder order = visitOrderMapper.selectById(orderId); List<Integer> allowed = STATE_MACHINE.get(order.getStatus()); if (allowed == null || !allowed.contains(nextStatus)) { throw new BusinessException("非法状态流转: " + order.getStatus() + " -> " + nextStatus); } // 更新状态 } }逻辑说明:这是把状态流转规则从业务方法里抽出来,做成一个 Map,每次状态变更都先查合法路径。参数说明:PENDING 作为初始态,允许被审批、被取消;APPROVED 之后允许签到、取消,或在过期任务里把状态刷为过期;CHECKED_IN 只能走向 COMPLETED。如果有人想从 REJECTED 直接改成 CHECKED_IN,这里直接抛出异常。实际项目里还可以配合定时任务,把过期预约单批量刷成 EXPIRED,这个后面细说。3. 项目搭建与最小可运行闭环:从空目录到能跑通的 REST 接口3.1 依赖选型和目录结构:用最熟悉的组合,别在选型上创造需求Spring Boot 项目搭建本身没有悬念。依赖选择上我习惯只引入必要的内容:spring-boot-starter-web、mybatis-plus(或 MyBatis,看团队熟悉程度)、mysql-connector-j、lombok、validation、redis 可选(做二维码和会话用)。Redis 在早期不是必需的,黑名单和预约信息直接查数据库完全够用,等到有性能瓶颈再引入缓存也来得及。目录结构按职责分包,不要按三层架构把 controller、service、mapper 三个包铺满全部业务类,而是按业务模块分包:visitor、order、record、blacklist、user。这样后期找一个功能,进对应的包就能看到完整的 controller、service、mapper,比满屏的类堆在一个 service 包里好维护得多。最小可运行闭环是这样的:用 Spring Initializr 生成项目,写一个访客登记接口,存储到数据库,再写一个查询接口,把数据读出来。这一步跑通之后,剩下的功能都是在这个骨架上的重复扩展。3.2 配置文件里的参数陷阱:连接池、时区、驼峰映射一次说清很多新手在配置文件上栽跟头,不是依赖不好使,而是前后端字段对不上,或者数据库时间差 8 小时。这里直接给一份我常用的 application.yml 配置模板,参数注释已经标明用途:server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/visit_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto spring.jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai参数说明:serverTimezone 必须设置,否则高版本 JDBC 会报错或者时间偏移;HikariCP 的 maximum-pool-size 并不一定越大越好,对单机小系统 20 足够,太大会拖垮数据库连接数;map-underscore-to-camel-case 必须打开,否则数据库里的 visit_date 映射不到 visitDate 字段;jackson 时间格式统一,否则前端拿到的时间串不友好。3.3 从接口到数据库:写一个完整的访客登记闭环用一个小接口把整套链路串起来。访客登记的逻辑是:先按手机号查访客表,存在就直接用,不存在就新增;然后创建预约单,状态为待审批。这个接口是后面所有功能的地基。@RestController @RequestMapping("/api/visitor") public class VisitorController { @Resource private VisitorService visitorService; @Resource private VisitOrderService visitOrderService; @PostMapping("/register") public Result register(@RequestBody @Valid VisitorRegisterRequest request) { // 1. 查找或创建访客主档 Visitor visitor = visitorService.findOrCreate(request.getPhone(), request.getName()); // 2. 创建待审批预约单 VisitOrder order = new VisitOrder(); order.setVisitorId(visitor.getId()); order.setEmployeeId(request.getEmployeeId()); order.setVisitDate(request.getVisitDate()); order.setStartTime(request.getStartTime()); order.setEndTime(request.getEndTime()); order.setStatus(VisitStatus.PENDING.getCode()); visitOrderService.create(order); return Result.success(order); } }逻辑说明:第一步先做访客幂等处理,避免同一个人预约两次产生两条重复主档。第二步组装预约单,状态固定为待审批,因为预约必须经过被访人确认,不能直接进入已通过状态,除非你做成免审批场景。参数说明:ditRequest 里的 employeeId 必须校验存在且在职,可以在 Service 里做;visitDate 和 startTime/endTime 要做时间校验,结束时间不能早于开始时间,预约日期不能是过去日期,否则后面统计会全是脏数据。这个接口跑通后,用 Postman 或 curl 发送一条 JSON,能看到数据库里多一条记录,整个最小闭环就算完成。后面要做的审批、签到、离场、黑名单,本质上都是对这条预约记录做状态流转。4. 把核心业务写实:审批、签到、黑名单和统计报表4.1 预约审批:被访人确认,但审批权限要可控审批流程分两种:被访人本人确认,和部门管理员代审。在小系统里通常做成一件事:预约单生成后,被访人收到通知,点“同意”或“拒绝”,后台记录操作人和时间。如果被访人长时间未处理,需要一个超时提醒,一般是定时任务扫“待审批且预约日期超过 N 天”的单子,发提醒短信或做列表标红。审批接口的代码逻辑很直接,关键在权限判断。这里给出一个常用的做法:@PostMapping("/{orderId}/audit") public Result audit(@PathVariable Long orderId, @RequestBody AuditRequest request) { VisitOrder order = visitOrderMapper.selectById(orderId); if (!order.getEmployeeId().equals(CurrentUser.getId())) throw new BusinessException("只能审批发给自己的预约"); order.setStatus("approve".equals(request.getAction()) ? VisitStatus.APPROVED.getCode() : VisitStatus.REJECTED.getCode()); order.setAuditUser(CurrentUser.getId()); order.setAuditTime(new Date()); visitOrderMapper.updateById(order); return Result.success(); }参数说明:action 字段传 “approve” 或 “reject”,不传数字,避免接口层直接暴露枚举值。权限校验放在 Service 或 Controller 都行,但必须做,否则任意员工都能审批别人的单子。这里 CurrentUser 是假想上下文,实际项目用 Token 解析后塞进 ThreadLocal。4.2 现场签到与离场:二维码只是入口,真正的校验在服务端访客到现场,前台看预约单号或访客手机号,确认后做签到。常见做法是预约通过后生成二维码,门卫扫码自动签到。但二维码不能只存一个预约单号,必须有签名防伪造,否则别人截图就能进来。我一般用服务端生成一个临时签名串,包含 orderId、过期时间、随机数,用 HMAC-SHA256 签名,二维码里只放签名串。服务端扫码后先验签、再查单、再状态流转。这个设计不复杂,但能挡掉绝大多数低成本的伪造攻击。签到接口核心代码:@PostMapping("/checkin") public Result checkin(@RequestBody CheckinRequest request) { String sign = request.getSign(); // 1. 验签 String expected = SignUtil.sign(request.getOrderId(), request.getExpireTime(), secretKey); if (!expected.equals(sign) || request.getExpireTime() < System.currentTimeMillis()) { throw new BusinessException("二维码无效或已过期"); } VisitOrder order = visitOrderMapper.selectById(request.getOrderId()); if (order == null || !VisitStatus.APPROVED.equals(order.getStatus())) { throw new BusinessException("预约单不在可签到状态"); } // 2. 创建入场记录,状态改为已签到 order.setStatus(VisitStatus.CHECKED_IN.getCode()); visitOrderMapper.updateById(order); visitRecordMapper.insert(new VisitRecord(order.getId(), 1)); return Result.success(); }逻辑说明:先验签再查单,顺序不能反,避免拿到非法订单号去查库做无谓开销。参数说明:expireTime 建议设置 5 分钟,二维码随用随失效;secretKey 放在服务端配置文件,不要下发到前端。离场接口逻辑相同,只是把状态从 CHECKED_IN 改成 COMPLETED,并插入一条 type=2 的进出记录。到这里,一次完整来访的闭环就通了:预约 -> 审批 -> 签到 -> 离场。4.3 黑名单拦截:别只查一张表,要做多条件匹配黑名单在这个系统里的地位被很多人低估。实际运营中,黑名单的拦截点至少有三个:预约时、签到时、审批时。预约时拦截,能减少审批人无效工作量;签到时拦截,是防止预约后拉黑的情况;审批时拦截,是兜底。黑名单匹配的坑在于,访客不一定会填身份证号,但一定会填手机号,手机号才是最高频的匹配键。匹配逻辑建议这样写:public void checkBlacklist(String phone, String name) { LambdaQueryWrapper<Blacklist> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Blacklist::getPhone, phone) .or(w -> w.eq(Blacklist::getName, name).and(q -> q.isNull(Blacklist::getPhone))); Blacklist black = blacklistMapper.selectOne(wrapper); if (black != null) { throw new BusinessException("访客已被拉黑,原因:" + black.getReason()); } }这里的 or 条件要谨慎,括号必须分组,否则逻辑会变成“同名的人全部被拦”。参数说明:我这里的 or 子查询表达的是“手机号命中,或者姓名命中且没有手机号”的情况。实际项目里,如果确认来访人证件号可靠,也可以加入身份证匹配,但优先级要把手机号放最前面。4.4 统计报表:基于状态机做口径,避免数字对不上来访管理系统上线后,管理层最常问的就是“今天来了多少人”“上月来了几批”。报表做不好,系统会被质疑数据不准。这里必须定义一个规则:入口记录数 = 已签到数量,离场记录数 = 已完成数量,在园人数 = 已签到 - 已完成。千万不要一个个状态去数,那样数字一定对不上。用 SQL 聚合,按照状态分组统计,保证口径一致:SELECT visit_date, SUM(status = 3) AS checkin_count, SUM(status = 4) AS complete_count, SUM(status = 3) - SUM(status = 4) AS staying_count FROM visit_order WHERE visit_date BETWEEN #{start} AND #{end} GROUP BY visit_date ORDER BY visit_date;这是一个典型的按日统计查询。SUM(status = 3) 语法在 MySQL 里是合法的,返回的是满足条件的行数。参数说明:start/end 用日期字符串,前端传 "2024-05-01" 这种格式,MyBatis 会直接映射给 DATE 类型字段,不需要额外转换。如果后续要按部门和被访人维度拆,只需要把 employee_id 放到 GROUP BY 里,并关联员工表取部门名。5. 避坑指南:来访系统最常见的 5 个翻车现场5.1 时间字段的时区问题:数据库时间比本地慢了 8 小时现象:本地插入一条记录,查库发现 create_time 比当前时间少 8 小时;或者反过来,页面显示时间比实际多 8 小时。原因:JDBC 连接串没有指定 serverTimezone,或者指定的是 UTC,而服务器和数据库不在一个时区。解决:连接串统一加 serverTimezone=Asia/Shanghai,同时在 Spring 配置文件里设置 jackson time-zone=Asia/Shanghai。这两个地方少一个都会出问题。5.2 手机号查重导致同一访客有多条主档现象:同一个访客用手机号登记两次,生成了两条 visitor 记录,预约单分别挂在两条记录下,统计去重人数翻倍。原因:访客登记逻辑没有做“存在即复用”,或者唯一键只建在 id 上。解决:建表时把 phone 和 name 做成联合唯一键,代码里先按 phone 查,查到就复用,查不到才创建。还要注意身份证号非必填,不能拿它做唯一键。5.3 状态流转没有校验,已拒绝的预约单也能签到现象:测试人员把一条已拒绝的预约单改状态为已签到,系统没有拦截,最终报表数据出现负数或者逻辑矛盾。原因:服务层没有状态机校验,接口层直接用了更新方法。解决:把 2.3 节的状态流转校验加到所有变更入口,不只在审批接口,签到、取消、过期都要走同一个校验方法。5.4 黑名单查询把同名的人都拦截了现象:黑名单里有一个叫“张三”的人,结果所有叫“张三”的都预约失败。原因:查询黑名单时用了 name 字段做 or 条件,没有给手机号与非空条件分组。解决:参照 4.3 节的写法,手机号精确命中优先,姓名命中必须附加手机号为空的兜底条件才能生效,或者干脆不用姓名匹配,只按手机号匹配。5.5 二维码可被截图重复使用现象:访客截图二维码让同事也扫,结果两个人都签到入场。原因:二维码里只有 orderId,没有过期时间和签名,服务端也没有校验当前单是否已经签到。解决:二维码内容换成签名串,服务端验签+有效期校验+状态校验三重兜底,签到成功后状态变成 CHECKED_IN,再次扫码直接拒绝。这条要写进测试用例,不能只靠人为自觉。6. 进阶技巧:把项目从“能用”做到“好用”的校验与部署细节到这里,系统的主流程已经完整。但作为交付给业务方使用的产物,必须补上几件事:定时任务过期单处理、接口幂等、以及部署时的环境差异问题。先说过期单。预约通过的访客可能没来,预约单就一直停留在已通过状态,数据报表里“在园人数”会出现永远不减少的情况。定时任务每天凌晨扫描一次,把 visit_date 小于当天且状态为已通过的预约单批量刷成已过期:0 0 2 * * ? * # cron 表达式:每天凌晨 2 点执行对应的任务方法里做一个 UPDATE,把状态从 1 改成 6,顺便把未执行的通行码作废。这里要注意,批量更新时必须带状态条件,用UPDATE visit_order SET status=6 WHERE status=1 AND visit_date < CURDATE(),不然会把已取消的单也翻一遍。然后说接口幂等。访客登记接口如果网络抖动,前端重试,可能插入两条预约单。常见做法是前端生成一个请求唯一流水号(requestId),服务端拿它做去重。最简单的是在预约表加一个 request_no 字段并加唯一索引,重复插入直接报错,异常翻译成“请勿重复提交”。这个改造量很小,但对用户体验的改善非常明显。关于部署,本地开发用 H2 或 MySQL 没争议,但生产环境要注意两个细节。第一个是数据库密码不要写在 application.yml 里,用环境变量或配置中心管理;第二个是前端静态资源如果打包进 Spring Boot 的 jar 里,要记得配置静态资源路径,否则部署后页面白屏。常规做法是前端单独部署到 Nginx,后端只提供 REST 接口,这样前后端各自扩容,互不干扰。最后说一个我自己的习惯:每次上线前,我会手动过一遍五条核心链路——访客预约、审批通过、扫码签到、离场完成、黑名单拦截,并打印每个环节的状态值和耗时。这套系统的复杂度不高,业务逻辑一旦稳定,真正让我们掉头发的永远是数据口径和状态一致性。把状态机焊死、把时间格式统一、把二维码签名加上,这三件事做完,后续迭代的压力会小很多。希望我的这些踩坑经验能让你在这个方向上少走弯路,也希望你做完之后,能在这个基础上加入人脸识别或访客车辆管理,把这套骨架真正长成适合你业务的模样。本文还有配套的精品资源,点击获取
简介:基于Spring Boot框架的来访管理系统源码包,面向需要开发访客登记、预约与来访统计场景的Web开发者,覆盖管理员与访客两类角色,包含个人中心、访客管理、访客预约管理、访客来访管理等核心模块,体现前后端分离的分层设计与权限控制思想。压缩包共361个文件,大小约9.07MB,以java后端逻辑、vue前端页面为主体,辅以xml配置、sql数据库脚本、bat构建运行脚本、yml环境配置及svg/png静态资源,结构清晰便于直接导入IDE运行调试。具体功能包括列表、分页、新增、修改、删除等常规操作,以及按日期范围、特定字段筛选的提醒接口,并支持按值、按时间、分组统计,适合课程设计、毕业设计或企业内部访客系统的二次开发。已有27人学习/下载,源码附带完整前端页面与工具脚本,可帮助理解Spring Boot整合Vue、MyBatis等主流技术的实际落地方法。1. 从“登记本”到“数据闭环”:Spring Boot 来访管理系统到底在解决什么问题如果你去问门卫大爷,一套来访管理系统最核心的价值是什么,他大概率会说是“别让闲杂人进来”。但你要是站在运维和行政的角度看,答案完全不同:你需要知道谁来了、什么时候来的、来找谁、什么时候走的,以及这些数据能不能变成可追溯的台账。现实里很多单位还在用纸质登记本,高峰期排队不说,字迹潦草、信息造假、事后查证全凭感觉。换成一款基于 Spring Boot 框架的来访管理系统,核心工作就是把“人、事、时、地”串成一条数据链路:访客预约、审批、现场登记、出入核验、离场销账,每一步都有记录,每一步都能回溯。这篇文章我会按一线落地的方式,把这套系统的表结构、接口设计、状态流转和部署参数拆开讲清楚,给正在规划或已经在开发同类项目的同学一条能直接照做的路径。2. 先弄清业务模型和状态流转:表结构不是凭空设计出来的2.1 访客、员工、黑名单,三类实体决定了系统的边界做管理系统最忌讳一上来就写代码。先花半天时间把角色和实体梳理清楚,后面能少改一半需求。来访管理系统里业务实体并不复杂:访客、被访人(通常是员工)、预约单、进出记录,以及黑名单。但每个实体都有自己的“生命周期”,这个生命周期就是状态机。访客不是注册用户,多数情况是临时录入,录入后要查重,如果是黑名单直接拒绝。员工从组织架构里拉取,但多数小项目没有现成的组织架构接口,常见做法是在系统里维护一张简单的部门表和员工表,或者对接企业微信、钉钉的通讯录API。如果只做纯软件交付,我会建议先做成手动维护的员工表,把对接留作二期扩展,避免一期被外部系统卡住。预约单是最核心的对象。它的状态建议设计为:待审批、已通过、已拒绝、已签到、已完成、已取消、已过期。这里有个关键点:“已签到”和“已完成”要区分,已签到是访客到现场刷二维码或身份证后产生的状态,已完成是离场登记后的状态。很多团队把这两个状态合并,最后统计“访客是否还在园区”就完全没法算,只能靠猜。2.2 核心表结构:一张预约主表加三张辅助表,别过度设计访客管理系统最怕做成“千人千面”的大平台。我一般会控制表的数量,核心就是四张表:访客表、来访预约表、进出记录表、黑名单表。如果需要审批流,审批意见直接冗余在预约表里,用一个字段存最后审批结果,不需要单独建审批记录表,除非你有多级审批硬需求。访客表字段建议用手机号作为业务唯一键。很多人问要不要给访客建账号、密码?不要。访客不是系统用户,不需要登录,最多短信验证码核验身份。预约表要冗余访客姓名、手机号、证件号,因为同一访客可能多次来访,冗余后可以避免每次查询都去关联访客主表,统计效率更高。黑名单表要区分“拉黑类型”:临时拉黑和永久拉黑,还要有拉黑原因和操作人。这里有个容易被忽略的点:黑名单不能只按身份证号匹配,因为很多人不填身份证,必须支持手机号+姓名模糊匹配,否则黑名单形同虚设。进出记录表是流水表,只做插入,不做更新,配合预约状态来判定是否离场。下面给出一个简化但可落地的建表脚本片段,使用 MySQL 方言:-- 访客主表:手机号作为业务唯一键 CREATE TABLE visitor ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, phone VARCHAR(20) NOT NULL, id_card VARCHAR(32), created_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_phone_name(phone, name) ); -- 来访预约表:核心状态机载体 CREATE TABLE visit_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, visitor_id BIGINT NOT NULL, employee_id BIGINT NOT NULL COMMENT '被访人ID', visit_date DATE NOT NULL, start_time TIME NOT NULL, end_time TIME NOT NULL, purpose VARCHAR(255), status TINYINT NOT NULL DEFAULT 0 COMMENT '0待审批 1已通过 2已拒绝 3已签到 4已完成 5已取消 6已过期', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, audit_time DATETIME NULL, audit_user BIGINT NULL, INDEX idx_employee_date(employee_id, visit_date) ); -- 进出记录表:只追加,不更新 CREATE TABLE visit_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, type TINYINT NOT NULL COMMENT '1入场 2离场', device_no VARCHAR(64) COMMENT '门禁设备编号', record_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_order_time(order_id, record_time) );字段注释写清楚状态枚举,后面写状态机就不会乱。员工表、部门表、黑名单表这里不单独展开,结构都很直白。要注意的是时间字段全部用 DATETIME,不要用字符串,否则后期做统计区间查询时索引直接失效,这在生产环境是实打实的翻车现场。2.3 状态机怎么落地:用常量类而不是魔法数上面表里 status 用了 TINYINT,但代码里不能到处写数字。Spring Boot 项目里我习惯建一个枚举类或常量类,把状态流转规则收敛到一个地方。状态机不一定要用状态机框架,对大部分来访系统来说,一个带校验的服务方法就够了,但状态流转的合法性必须校验,否则会出现“已拒绝的预约单又被签到”这种逻辑漏洞。代码示例:public enum VisitStatus { PENDING(0, "待审批"), APPROVED(1, "已通过"), REJECTED(2, "已拒绝"), CHECKED_IN(3, "已签到"), COMPLETED(4, "已完成"), CANCELED(5, "已取消"), EXPIRED(6, "已过期"); private final int code; private final String desc; VisitStatus(int code, String desc) { this.code = code; this.desc = desc; } }@Service public class VisitOrderService { // 允许的状态流转 private static final Map<Integer, List<Integer>> STATE_MACHINE = new HashMap<>(); static { STATE_MACHINE.put(VisitStatus.PENDING.getCode(), List.of(VisitStatus.APPROVED.getCode(), VisitStatus.REJECTED.getCode(), VisitStatus.CANCELED.getCode())); STATE_MACHINE.put(VisitStatus.APPROVED.getCode(), List.of(VisitStatus.CHECKED_IN.getCode(), VisitStatus.CANCELED.getCode(), VisitStatus.EXPIRED.getCode())); STATE_MACHINE.put(VisitStatus.CHECKED_IN.getCode(), List.of(VisitStatus.COMPLETED.getCode())); } public void changeStatus(Long orderId, int nextStatus) { VisitOrder order = visitOrderMapper.selectById(orderId); List<Integer> allowed = STATE_MACHINE.get(order.getStatus()); if (allowed == null || !allowed.contains(nextStatus)) { throw new BusinessException("非法状态流转: " + order.getStatus() + " -> " + nextStatus); } // 更新状态 } }逻辑说明:这是把状态流转规则从业务方法里抽出来,做成一个 Map,每次状态变更都先查合法路径。参数说明:PENDING 作为初始态,允许被审批、被取消;APPROVED 之后允许签到、取消,或在过期任务里把状态刷为过期;CHECKED_IN 只能走向 COMPLETED。如果有人想从 REJECTED 直接改成 CHECKED_IN,这里直接抛出异常。实际项目里还可以配合定时任务,把过期预约单批量刷成 EXPIRED,这个后面细说。3. 项目搭建与最小可运行闭环:从空目录到能跑通的 REST 接口3.1 依赖选型和目录结构:用最熟悉的组合,别在选型上创造需求Spring Boot 项目搭建本身没有悬念。依赖选择上我习惯只引入必要的内容:spring-boot-starter-web、mybatis-plus(或 MyBatis,看团队熟悉程度)、mysql-connector-j、lombok、validation、redis 可选(做二维码和会话用)。Redis 在早期不是必需的,黑名单和预约信息直接查数据库完全够用,等到有性能瓶颈再引入缓存也来得及。目录结构按职责分包,不要按三层架构把 controller、service、mapper 三个包铺满全部业务类,而是按业务模块分包:visitor、order、record、blacklist、user。这样后期找一个功能,进对应的包就能看到完整的 controller、service、mapper,比满屏的类堆在一个 service 包里好维护得多。最小可运行闭环是这样的:用 Spring Initializr 生成项目,写一个访客登记接口,存储到数据库,再写一个查询接口,把数据读出来。这一步跑通之后,剩下的功能都是在这个骨架上的重复扩展。3.2 配置文件里的参数陷阱:连接池、时区、驼峰映射一次说清很多新手在配置文件上栽跟头,不是依赖不好使,而是前后端字段对不上,或者数据库时间差 8 小时。这里直接给一份我常用的 application.yml 配置模板,参数注释已经标明用途:server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/visit_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto spring.jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai参数说明:serverTimezone 必须设置,否则高版本 JDBC 会报错或者时间偏移;HikariCP 的 maximum-pool-size 并不一定越大越好,对单机小系统 20 足够,太大会拖垮数据库连接数;map-underscore-to-camel-case 必须打开,否则数据库里的 visit_date 映射不到 visitDate 字段;jackson 时间格式统一,否则前端拿到的时间串不友好。3.3 从接口到数据库:写一个完整的访客登记闭环用一个小接口把整套链路串起来。访客登记的逻辑是:先按手机号查访客表,存在就直接用,不存在就新增;然后创建预约单,状态为待审批。这个接口是后面所有功能的地基。@RestController @RequestMapping("/api/visitor") public class VisitorController { @Resource private VisitorService visitorService; @Resource private VisitOrderService visitOrderService; @PostMapping("/register") public Result register(@RequestBody @Valid VisitorRegisterRequest request) { // 1. 查找或创建访客主档 Visitor visitor = visitorService.findOrCreate(request.getPhone(), request.getName()); // 2. 创建待审批预约单 VisitOrder order = new VisitOrder(); order.setVisitorId(visitor.getId()); order.setEmployeeId(request.getEmployeeId()); order.setVisitDate(request.getVisitDate()); order.setStartTime(request.getStartTime()); order.setEndTime(request.getEndTime()); order.setStatus(VisitStatus.PENDING.getCode()); visitOrderService.create(order); return Result.success(order); } }逻辑说明:第一步先做访客幂等处理,避免同一个人预约两次产生两条重复主档。第二步组装预约单,状态固定为待审批,因为预约必须经过被访人确认,不能直接进入已通过状态,除非你做成免审批场景。参数说明:ditRequest 里的 employeeId 必须校验存在且在职,可以在 Service 里做;visitDate 和 startTime/endTime 要做时间校验,结束时间不能早于开始时间,预约日期不能是过去日期,否则后面统计会全是脏数据。这个接口跑通后,用 Postman 或 curl 发送一条 JSON,能看到数据库里多一条记录,整个最小闭环就算完成。后面要做的审批、签到、离场、黑名单,本质上都是对这条预约记录做状态流转。4. 把核心业务写实:审批、签到、黑名单和统计报表4.1 预约审批:被访人确认,但审批权限要可控审批流程分两种:被访人本人确认,和部门管理员代审。在小系统里通常做成一件事:预约单生成后,被访人收到通知,点“同意”或“拒绝”,后台记录操作人和时间。如果被访人长时间未处理,需要一个超时提醒,一般是定时任务扫“待审批且预约日期超过 N 天”的单子,发提醒短信或做列表标红。审批接口的代码逻辑很直接,关键在权限判断。这里给出一个常用的做法:@PostMapping("/{orderId}/audit") public Result audit(@PathVariable Long orderId, @RequestBody AuditRequest request) { VisitOrder order = visitOrderMapper.selectById(orderId); if (!order.getEmployeeId().equals(CurrentUser.getId())) throw new BusinessException("只能审批发给自己的预约"); order.setStatus("approve".equals(request.getAction()) ? VisitStatus.APPROVED.getCode() : VisitStatus.REJECTED.getCode()); order.setAuditUser(CurrentUser.getId()); order.setAuditTime(new Date()); visitOrderMapper.updateById(order); return Result.success(); }参数说明:action 字段传 “approve” 或 “reject”,不传数字,避免接口层直接暴露枚举值。权限校验放在 Service 或 Controller 都行,但必须做,否则任意员工都能审批别人的单子。这里 CurrentUser 是假想上下文,实际项目用 Token 解析后塞进 ThreadLocal。4.2 现场签到与离场:二维码只是入口,真正的校验在服务端访客到现场,前台看预约单号或访客手机号,确认后做签到。常见做法是预约通过后生成二维码,门卫扫码自动签到。但二维码不能只存一个预约单号,必须有签名防伪造,否则别人截图就能进来。我一般用服务端生成一个临时签名串,包含 orderId、过期时间、随机数,用 HMAC-SHA256 签名,二维码里只放签名串。服务端扫码后先验签、再查单、再状态流转。这个设计不复杂,但能挡掉绝大多数低成本的伪造攻击。签到接口核心代码:@PostMapping("/checkin") public Result checkin(@RequestBody CheckinRequest request) { String sign = request.getSign(); // 1. 验签 String expected = SignUtil.sign(request.getOrderId(), request.getExpireTime(), secretKey); if (!expected.equals(sign) || request.getExpireTime() < System.currentTimeMillis()) { throw new BusinessException("二维码无效或已过期"); } VisitOrder order = visitOrderMapper.selectById(request.getOrderId()); if (order == null || !VisitStatus.APPROVED.equals(order.getStatus())) { throw new BusinessException("预约单不在可签到状态"); } // 2. 创建入场记录,状态改为已签到 order.setStatus(VisitStatus.CHECKED_IN.getCode()); visitOrderMapper.updateById(order); visitRecordMapper.insert(new VisitRecord(order.getId(), 1)); return Result.success(); }逻辑说明:先验签再查单,顺序不能反,避免拿到非法订单号去查库做无谓开销。参数说明:expireTime 建议设置 5 分钟,二维码随用随失效;secretKey 放在服务端配置文件,不要下发到前端。离场接口逻辑相同,只是把状态从 CHECKED_IN 改成 COMPLETED,并插入一条 type=2 的进出记录。到这里,一次完整来访的闭环就通了:预约 -> 审批 -> 签到 -> 离场。4.3 黑名单拦截:别只查一张表,要做多条件匹配黑名单在这个系统里的地位被很多人低估。实际运营中,黑名单的拦截点至少有三个:预约时、签到时、审批时。预约时拦截,能减少审批人无效工作量;签到时拦截,是防止预约后拉黑的情况;审批时拦截,是兜底。黑名单匹配的坑在于,访客不一定会填身份证号,但一定会填手机号,手机号才是最高频的匹配键。匹配逻辑建议这样写:public void checkBlacklist(String phone, String name) { LambdaQueryWrapper<Blacklist> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Blacklist::getPhone, phone) .or(w -> w.eq(Blacklist::getName, name).and(q -> q.isNull(Blacklist::getPhone))); Blacklist black = blacklistMapper.selectOne(wrapper); if (black != null) { throw new BusinessException("访客已被拉黑,原因:" + black.getReason()); } }这里的 or 条件要谨慎,括号必须分组,否则逻辑会变成“同名的人全部被拦”。参数说明:我这里的 or 子查询表达的是“手机号命中,或者姓名命中且没有手机号”的情况。实际项目里,如果确认来访人证件号可靠,也可以加入身份证匹配,但优先级要把手机号放最前面。4.4 统计报表:基于状态机做口径,避免数字对不上来访管理系统上线后,管理层最常问的就是“今天来了多少人”“上月来了几批”。报表做不好,系统会被质疑数据不准。这里必须定义一个规则:入口记录数 = 已签到数量,离场记录数 = 已完成数量,在园人数 = 已签到 - 已完成。千万不要一个个状态去数,那样数字一定对不上。用 SQL 聚合,按照状态分组统计,保证口径一致:SELECT visit_date, SUM(status = 3) AS checkin_count, SUM(status = 4) AS complete_count, SUM(status = 3) - SUM(status = 4) AS staying_count FROM visit_order WHERE visit_date BETWEEN #{start} AND #{end} GROUP BY visit_date ORDER BY visit_date;这是一个典型的按日统计查询。SUM(status = 3) 语法在 MySQL 里是合法的,返回的是满足条件的行数。参数说明:start/end 用日期字符串,前端传 "2024-05-01" 这种格式,MyBatis 会直接映射给 DATE 类型字段,不需要额外转换。如果后续要按部门和被访人维度拆,只需要把 employee_id 放到 GROUP BY 里,并关联员工表取部门名。5. 避坑指南:来访系统最常见的 5 个翻车现场5.1 时间字段的时区问题:数据库时间比本地慢了 8 小时现象:本地插入一条记录,查库发现 create_time 比当前时间少 8 小时;或者反过来,页面显示时间比实际多 8 小时。原因:JDBC 连接串没有指定 serverTimezone,或者指定的是 UTC,而服务器和数据库不在一个时区。解决:连接串统一加 serverTimezone=Asia/Shanghai,同时在 Spring 配置文件里设置 jackson time-zone=Asia/Shanghai。这两个地方少一个都会出问题。5.2 手机号查重导致同一访客有多条主档现象:同一个访客用手机号登记两次,生成了两条 visitor 记录,预约单分别挂在两条记录下,统计去重人数翻倍。原因:访客登记逻辑没有做“存在即复用”,或者唯一键只建在 id 上。解决:建表时把 phone 和 name 做成联合唯一键,代码里先按 phone 查,查到就复用,查不到才创建。还要注意身份证号非必填,不能拿它做唯一键。5.3 状态流转没有校验,已拒绝的预约单也能签到现象:测试人员把一条已拒绝的预约单改状态为已签到,系统没有拦截,最终报表数据出现负数或者逻辑矛盾。原因:服务层没有状态机校验,接口层直接用了更新方法。解决:把 2.3 节的状态流转校验加到所有变更入口,不只在审批接口,签到、取消、过期都要走同一个校验方法。5.4 黑名单查询把同名的人都拦截了现象:黑名单里有一个叫“张三”的人,结果所有叫“张三”的都预约失败。原因:查询黑名单时用了 name 字段做 or 条件,没有给手机号与非空条件分组。解决:参照 4.3 节的写法,手机号精确命中优先,姓名命中必须附加手机号为空的兜底条件才能生效,或者干脆不用姓名匹配,只按手机号匹配。5.5 二维码可被截图重复使用现象:访客截图二维码让同事也扫,结果两个人都签到入场。原因:二维码里只有 orderId,没有过期时间和签名,服务端也没有校验当前单是否已经签到。解决:二维码内容换成签名串,服务端验签+有效期校验+状态校验三重兜底,签到成功后状态变成 CHECKED_IN,再次扫码直接拒绝。这条要写进测试用例,不能只靠人为自觉。6. 进阶技巧:把项目从“能用”做到“好用”的校验与部署细节到这里,系统的主流程已经完整。但作为交付给业务方使用的产物,必须补上几件事:定时任务过期单处理、接口幂等、以及部署时的环境差异问题。先说过期单。预约通过的访客可能没来,预约单就一直停留在已通过状态,数据报表里“在园人数”会出现永远不减少的情况。定时任务每天凌晨扫描一次,把 visit_date 小于当天且状态为已通过的预约单批量刷成已过期:0 0 2 * * ? * # cron 表达式:每天凌晨 2 点执行对应的任务方法里做一个 UPDATE,把状态从 1 改成 6,顺便把未执行的通行码作废。这里要注意,批量更新时必须带状态条件,用UPDATE visit_order SET status=6 WHERE status=1 AND visit_date < CURDATE(),不然会把已取消的单也翻一遍。然后说接口幂等。访客登记接口如果网络抖动,前端重试,可能插入两条预约单。常见做法是前端生成一个请求唯一流水号(requestId),服务端拿它做去重。最简单的是在预约表加一个 request_no 字段并加唯一索引,重复插入直接报错,异常翻译成“请勿重复提交”。这个改造量很小,但对用户体验的改善非常明显。关于部署,本地开发用 H2 或 MySQL 没争议,但生产环境要注意两个细节。第一个是数据库密码不要写在 application.yml 里,用环境变量或配置中心管理;第二个是前端静态资源如果打包进 Spring Boot 的 jar 里,要记得配置静态资源路径,否则部署后页面白屏。常规做法是前端单独部署到 Nginx,后端只提供 REST 接口,这样前后端各自扩容,互不干扰。最后说一个我自己的习惯:每次上线前,我会手动过一遍五条核心链路——访客预约、审批通过、扫码签到、离场完成、黑名单拦截,并打印每个环节的状态值和耗时。这套系统的复杂度不高,业务逻辑一旦稳定,真正让我们掉头发的永远是数据口径和状态一致性。把状态机焊死、把时间格式统一、把二维码签名加上,这三件事做完,后续迭代的压力会小很多。希望我的这些踩坑经验能让你在这个方向上少走弯路,也希望你做完之后,能在这个基础上加入人脸识别或访客车辆管理,把这套骨架真正长成适合你业务的模样。本文还有配套的精品资源,点击获取