从 mock 页到真表:访客设备列表的两表设计、9+1 造数与外部 review 的四个 P2

导读

一个纯 mock 的演示页面要接成真实接口。动手前先做数据普查——确认全系统真的没有访客设备表;然后设计”实体 + 事件”两张新表,用 9+1 的造数结构让”在线时长”不需要定时任务就能自然增长;最后外部 review 抓出四个 P2,其中一个 OGNL Date 坑在老代码里还埋着同款雷。

前言

访客管理下的”访客设备列表”原本是东环模拟数据批次里的纯 mock 页——前端写死数据,后端什么都没有。9 月 9 日把它接成真实接口:「同步时间」改「上线时间」、新增「设备在线时长」、新增「操作-查询」弹窗分页展示上下线记录,外加上司口径的演示数据:6 台设备、每台 10 条记录、每段在线 3~5 个月。

一、动手前先普查:确认”全系统真没有这张表”

给演示数据找家之前,先把线上和本地的全表清单核对了一遍,结论是全系统没有访客设备表,也没有任何设备的上下线历史表。四张”看起来像”的候选表逐一判定:

判定
pass_access_device 门禁 真表:1561 条全是门口机/梯控,被厂家平台持续同步——不能塞演示数据,污染真页面还可能被同步碰到
pass_gate_device 道闸 5 条手工维护数据、从未同步,代码里对番禺用户放行全量——本身就是演示专用表,可照抄模式
through_door_device_info 2023-06 停更的死数据,代码零引用
through_passageway_devices_info 出入口↔设备关联表,无在线状态

普查的价值在于否定:如果想当然地把演示数据塞进 pass_access_device,就是给真页面投毒。确认没有之后,反而放开手脚——新造两张表,照门禁/道闸的既有模式走

二、设计:实体 + 事件,时长不落库

两张表 1:N:设备是实体,上下线是事件

两张新表(要点列)
through_visitor_device                 -- 实体:当前状态
  device_code / device_name / device_type(1人证 2自助)
  online_status / online_time          -- 当前会话的开始时间

through_visitor_device_online_record – 事件:历史轨迹(只读,seed 灌数)
device_id / online_time / offline_time – offline_time NULL = 当前在线
INDEX (device_id, online_time)

两个设计细节最值得说:

时长不落库,现算。「设备在线时长」= now − 上线时间,由 formatDuration 在返回时格式化(满月 "x个月x天"、不足月 "x天x小时")。落库就意味着要有人更新它,现算就永远没有一致性问题。

造数结构 9+1,不需要定时任务。 每台设备 9 条完整记录(段长 3.24.3 个月、段间隔 25 天,下线时间落库)+ 1 条当前在线(上线时间设在 3~4 个月前、offline_time 为 NULL)。这样主列表的”在线时长”随真实时间自然增长——演示环境放两个月后再打开,数字照样合理,**不用配任何定时任务去演”设备还活着”**。

9+1 造数结构:九段完整历史加一条正在生长的当前段
图 1:每台设备 9 段完整记录 + 1 条 NULL 下线的当前段,时长随时间自然生长

后端实现照抄 pass_gate_device 四件套:GenericRestController 白捡 CRUD/分页,listByPage 园区过滤含 PYJD 特例,只新写一个 POST visitorDevice/onlineRecord/page。seed 不进 Flyway,放 db/visitor_device_seed_20260909.sql,头带 DELETE 清理段,幂等可重跑。

三、外部 review 的四个 P2

代码过了外部 agent review,抓出四个 P2,个个实在:

P2-1:通用 mapper 的 OGNL Date 坑(实测复现)。 XML 里的经典写法 <if test="date != null and '' != date">,传入非空 Date 直接抛 invalid comparison: java.util.Date and java.lang.String——OGNL 不允许 Date 和字符串比等。**Date 字段条件只判 != null**。更值得注意的是:老表 pass_gate_device 的 XML 里同款雷还埋着三处(synTime/createTime/updateTime),本次只修新代码,老表未动、记录在案。

OGNL Date 坑:错误写法 vs 正确写法
<!-- ❌ 对 Date 参数判空串:invalid comparison: java.util.Date and java.lang.String -->
<if test="onlineTime != null and '' != onlineTime">

<!– ✅ Date 条件只判 null –>
<if test=”onlineTime != null”>

P2-2:满月边界。 Period.between(LocalDate, LocalDate) 按日截断——差 22 小时不到完整月也会被算成满月。改 ChronoUnit.MONTHS.between(LocalDateTime, LocalDateTime) 用完整日期时间判定整月,再用 DAYS.between(from.plusMonths(m), to) 算零头,新用例 30天2小时 钉死边界。

P2-3:非确定性测试。 时长计算依赖”现在”,测试跑在不同日期结果不同。给 fillOnlineDuration/fillRecordDuration(list, Date now) 重载,测试固定 NOW——依赖时钟的逻辑,测试必须能注入时钟

P2-4:弹窗竞态。 快速连点两台设备的”查询”,慢响应后到会覆盖先到的快响应。修法:打开弹窗先清空数据,再用 recordReqSeq 自增序号只收最新响应。

review 的裁剪决策同样值得记录:记录 Mapper 的写语句全删(表是只读的,不留没有调用方的 SQL);设备 Mapper 全套 CRUD 保留(父 Controller 端点可达,删了就是砍功能)。

外部 review 抓出的四个 P2
图 2:四个 P2——OGNL Date、满月边界、非确定性测试、弹窗竞态

四、E2E 两条技巧

token 是网关生成的,不是 admin。 直连 admin:18200 登录,响应里没有 token——网关的 ModifyAuthRespFilter 只对 loginRelaApis 配置里的路径改写响应注入 token。所以 E2E 必须走网关 10001 + ?devDebug=127.0.0.1,直连服务只能拿到”系统繁忙”。

临时改密 E2E 法。 不知道测试账号明文密码时,用项目自己的 jssha 算法算哈希(SHA-512 两轮、先 update(btoa(userName))update(明文)),UPDATE 进 sys_user.password,登完立刻还原原 hash。账号 5 次错密锁号,所以哈希必须一次算对——“一击必中”是硬约束。

五、验证与提交

  • mvn -pl service-provider/through-service -am test:55/55 全绿(VisitorDeviceServiceImplTest 9/9);
  • 本地建表灌数后 SQL 抽查:6 台 × 各 10 条、各恰 1 条在线、段长全部 ≥3.2 个月;
  • 重启后走网关 E2E:主列表 6 台倒序、时长正确(3个月6天 ~ 4个月3天)、类型/名称筛选、记录弹窗翻第 2 页、OGNL 鉴别用例——全过;
  • 提交:后端 dc115e8e8(15 文件 +1549)、前端 5c928d18 / dce0acd2,均只 commit 未 push;遗留:线上发布时 seed 需在 building_through 手工执行一次。

经验总结

  • 给演示数据找家之前先普查:哪些表是活的、被谁写、同步会不会碰——否定结论也是结论;
  • 实体 + 事件分表,NULL 下线时间表示”当前在线”,是状态轨迹表的通用范式;
  • “会随时间增长的演示数据”用 9+1 结构造,比定时任务维护便宜一个数量级;
  • MyBatis 的 Date 条件只判 != null'' != date 是会炸的 OGNL 误用;
  • 依赖时钟的逻辑必须能注入时钟,否则测试永远非确定;
  • 外部 review 值得过:四个 P2 里有两个(OGNL、满月)是自己大概率漏掉的。

结语

mock 页接真实接口,工作量不在”接”,而在把 mock 背后不存在的表设计出来、把演示数据的生命周期想清楚。9+1 的造数结构让演示环境自己会长,四个 P2 让代码比写完那天更干净——这一天结束时,接的已经不只是一个页面了。