从 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:设备是实体,上下线是事件。
两个设计细节最值得说:
时长不落库,现算。「设备在线时长」= now − 上线时间,由 formatDuration 在返回时格式化(满月 "x个月x天"、不足月 "x天x小时")。落库就意味着要有人更新它,现算就永远没有一致性问题。
造数结构 9+1,不需要定时任务。 每台设备 9 条完整记录(段长 3.24.3 个月、段间隔 25 天,下线时间落库)+ 1 条当前在线(上线时间设在 3~4 个月前、offline_time 为 NULL)。这样主列表的”在线时长”随真实时间自然增长——演示环境放两个月后再打开,数字照样合理,**不用配任何定时任务去演”设备还活着”**。

图 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),本次只修新代码,老表未动、记录在案。
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 端点可达,删了就是砍功能)。

图 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 全绿(VisitorDeviceServiceImplTest9/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 让代码比写完那天更干净——这一天结束时,接的已经不只是一个页面了。