# RFID 读取记录分表查询设计说明 > 说明:本文件聚焦 “读取记录(`rfid_read_record`)按日期分表 + 分页/列表查询/采样查询” 的完整实现链路。 > > - 前端:`Cambodia-TBR-RFID-ui/src/views/rfid/rfidReadRecord/index.vue` > - 后端工具类:`RfidReadRecordTableHelper` > - Service:`RfidReadRecordServiceImpl` > - Mapper:`RfidReadRecordMapper.xml` 中与分表/分页/采样直接相关的 SQL > > 其余与 UI 展示或通用 CRUD 相关但不影响分表逻辑的代码在原文件中可查看,这里只保留与“分表查询”强相关的关键代码,并在代码中补充注释。 --- ## 1. 分表设计与整体调用链 ### 1.1 分表策略 - **按天分表**: - 物理表名格式:`rfid_read_record_yyyyMMdd`,例如:`rfid_read_record_20251126`。 - 基础表名常量:`RfidReadRecordTableHelper.BASE_TABLE_NAME = "rfid_read_record"`。 - **路由规则**: - 写入(新增/修改):根据记录的 `recordTime` 计算出表名。 - 读取/删除: - 列表/分页:根据 `beginRecordTime` ~ `endRecordTime` 算出涉及到的日期区间,再映射为多个分表名,并过滤掉数据库中不存在的分表。 - 按主键查询/删除:前端必须携带一个 **查询日期 `queryDate`**(由前端从 `recordTime` 截取 `yyyy-MM-dd` 得到),后端据此定位具体分表。 - **表存在校验与缓存**: - 通过 `information_schema.tables` 检查表是否存在,并在内存中做 5 分钟缓存,避免频繁访问元数据。 ### 1.2 前后端调用链总览 1. **前端页面** `rfidReadRecord/index.vue` - 用户在查询表单中选择:设备、读取状态、条码、时间范围(`dateRange`)、告警标志等条件。 - 点击“搜索”时,前端把 `dateRange` 拆成 `beginRecordTime` / `endRecordTime` 放入 `queryParams`,调用 `listRfidReadRecord` 接口获取分页数据。 - 选中行时,前端会把被选中记录的 `recordTime`(取日期部分)缓存到 `selectedRecordTimes`,用于后续 **修改/删除** 操作时传给后端作为 `queryDate` 参数,精确定位分表。 2. **Service 层** `RfidReadRecordServiceImpl` - 列表/分页查询: - 使用 `RfidReadRecordTableHelper.getExistingTableNames(beginRecordTime, endRecordTime)` 得到实际存在的分表列表。 - 若只涉及一张分表,则走单表查询 SQL;若涉及多张分表,则走多表 UNION ALL 聚合查询 SQL。 - 单条查询/删除: - 根据前端传入的 `queryDate` 计算表名,再调用 Mapper 对应的单表 SQL。 - 新增/修改: - 根据实体中的 `recordTime` 计算表名,路由到对应分表执行 insert/update。 3. **分表工具类** `RfidReadRecordTableHelper` - 对日期与表名之间的映射做统一封装: - `getTableName(Date)` / `getTableName(LocalDate)` / `getTodayTableName()` - `getTableNames(beginDate, endDate)`:生成日期区间内所有候选表名。 - `checkTableExists(tableName)` / `getExistingTableNames(beginDate, endDate)`:通过 information_schema + 本地缓存过滤出 **实际存在** 的分表。 4. **Mapper XML** `RfidReadRecordMapper.xml` - 所有 SQL 都通过 `${tableName}` 或 `${tbl}` 动态引用具体分表名,避免硬编码。 - 针对单表 / 多表 / 采样查询提供不同的 SQL: - 单表:`selectCustomRfidReadRecordVoPage` / `selectCustomRfidReadRecordVoList` / `insertRfidReadRecord` / `updateRfidReadRecordById` / `deleteCustomRfidReadRecordByIds` … - 多表 UNION:`selectCustomRfidReadRecordVoPageMultiTable` / `selectCustomRfidReadRecordVoListMultiTable` / `countCustomRfidReadRecordMultiTable` … - 采样查询:`selectWithSampling` / `selectWithSamplingMultiTable`,通过时间槽 `time_slot` 减少大数据量查询的记录数。 --- ## 2. 前端实现:`rfidReadRecord/index.vue` 本节只贴出与 **分表查询** 强相关的前端代码: - 查询条件中关于“记录时间”的部分(`dateRange`)。 - 脚本中对 `dateRange` 的处理(拆分成 `beginRecordTime` / `endRecordTime`)。 - 列表多选时记录每行的 `recordTime`,并存入 `selectedRecordTimes`。 - 修改 / 删除时,将选中行的日期部分作为 `queryDate` 传给后端。 ### 2.1 模板中与时间/选择相关的片段 ```vue ``` ### 2.2 脚本逻辑:时间范围拆分、分表日期传递 ```ts // file: Cambodia-TBR-RFID-ui/src/views/rfid/rfidReadRecord/index.vue ``` --- ## 3. 分表工具类:`RfidReadRecordTableHelper` 该工具类封装了 **日期 ↔ 分表表名** 的转换逻辑,以及基于 `information_schema` 的表存在检查与本地缓存,供 Service 在查询/写入前使用。 下面只摘取与分表查询强相关的核心方法,并在代码中加上注释。 ```java // file: rfid-middleware/ruoyi-modules/hw-rfid/src/main/java/org/dromara/rfid/helper/RfidReadRecordTableHelper.java package org.dromara.rfid.helper; import cn.hutool.core.date.DateUtil; import cn.hutool.core.util.StrUtil; import lombok.extern.slf4j.Slf4j; import org.dromara.common.core.utils.SpringUtils; import javax.sql.DataSource; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; /** * RFID 读取记录分表工具类 * * 按日期分表,表名格式:rfid_read_record_yyyyMMdd */ @Slf4j public class RfidReadRecordTableHelper { /** 基础表名,不带日期后缀 */ public static final String BASE_TABLE_NAME = "rfid_read_record"; /** 日期格式:yyyyMMdd,用于拼接表名后缀 */ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); /** * 表存在缓存:key = 表名,value = 是否存在。 * * 只缓存“存在”的表,避免后续每次都查 information_schema; * 不缓存“不存在”的表,以便新建分表后能够尽快被识别到。 */ private static final Map TABLE_EXISTS_CACHE = new ConcurrentHashMap<>(64); /** 上次刷新缓存的时间戳 */ private static volatile long lastCacheRefreshTime = System.currentTimeMillis(); /** 缓存有效期:5 分钟 */ private static final long CACHE_EXPIRE_MS = 5 * 60 * 1000L; private RfidReadRecordTableHelper() { } /** 手动清理缓存(例如新建分表后可调用) */ public static void clearTableExistsCache() { TABLE_EXISTS_CACHE.clear(); lastCacheRefreshTime = System.currentTimeMillis(); log.debug("分表缓存已清除"); } /** * 根据 java.util.Date 获取表名 * * @param date 记录时间 * @return 物理表名,如 rfid_read_record_20251126 */ public static String getTableName(Date date) { if (date == null) { date = new Date(); // 为空时默认当天 } String dateSuffix = DateUtil.format(date, "yyyyMMdd"); return BASE_TABLE_NAME + "_" + dateSuffix; } /** * 根据 LocalDate 获取表名 */ public static String getTableName(LocalDate localDate) { if (localDate == null) { localDate = LocalDate.now(); } return BASE_TABLE_NAME + "_" + localDate.format(DATE_FORMATTER); } /** 获取当天对应的分表表名 */ public static String getTodayTableName() { return getTableName(LocalDate.now()); } /** * 根据日期范围获取所有候选表名列表(不做存在性校验) * * 场景:分页/列表查询,先根据 beginRecordTime / endRecordTime 确定日期区间, * 然后再交给 getExistingTableNames 过滤出真实存在的分表。 */ public static List getTableNames(Date beginDate, Date endDate) { List tableNames = new ArrayList<>(); if (beginDate == null && endDate == null) { // 如果前端未传时间,默认只查当天分表 tableNames.add(getTodayTableName()); return tableNames; } // 处理单边日期为空的情况:缺失一端则默认为当天 LocalDate start = beginDate != null ? DateUtil.toLocalDateTime(beginDate).toLocalDate() : LocalDate.now(); LocalDate end = endDate != null ? DateUtil.toLocalDateTime(endDate).toLocalDate() : LocalDate.now(); // 确保 start <= end if (start.isAfter(end)) { LocalDate temp = start; start = end; end = temp; } // 按天递增遍历,依次拼接表名 LocalDate current = start; while (!current.isAfter(end)) { tableNames.add(getTableName(current)); current = current.plusDays(1); } return tableNames; } /** * 从表名中解析日期后缀(例如 rfid_read_record_20251126 → 20251126) */ public static String parseDateSuffix(String tableName) { if (StrUtil.isBlank(tableName) || !tableName.startsWith(BASE_TABLE_NAME + "_")) { return null; } return tableName.substring((BASE_TABLE_NAME + "_").length()); } /** * 检查表是否存在(带缓存) * * 1. 先判断缓存是否过期,过期则清空缓存; * 2. 若缓存中已有该表名,则直接返回; * 3. 否则访问 information_schema 做一次真实校验,并仅缓存“存在”的表。 */ public static boolean checkTableExists(String tableName) { long now = System.currentTimeMillis(); if (now - lastCacheRefreshTime > CACHE_EXPIRE_MS) { // 缓存过期,清除后重新统计 TABLE_EXISTS_CACHE.clear(); lastCacheRefreshTime = now; } // 先从缓存中读取 Boolean cached = TABLE_EXISTS_CACHE.get(tableName); if (cached != null) { return cached; } // 执行真实检查 boolean exists = doCheckTableExists(tableName); // 只缓存“存在”的表 if (exists) { TABLE_EXISTS_CACHE.put(tableName, Boolean.TRUE); } return exists; } /** * 通过 information_schema 查询表是否存在 */ private static boolean doCheckTableExists(String tableName) { try { DataSource dataSource = SpringUtils.getBean(DataSource.class); try (Connection conn = dataSource.getConnection()) { String sql = "SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ? LIMIT 1"; try (PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setString(1, tableName); try (ResultSet rs = stmt.executeQuery()) { return rs.next(); } } } } catch (Exception e) { log.warn("检查表 {} 是否存在时出错: {}", tableName, e.getMessage()); return false; } } /** * 根据日期范围获取所有实际存在的表名列表 * * Service 层在分页/列表查询前调用本方法,避免访问不存在的分表导致 SQL 报错。 */ public static List getExistingTableNames(Date beginDate, Date endDate) { List allTableNames = getTableNames(beginDate, endDate); return allTableNames.stream() .filter(RfidReadRecordTableHelper::checkTableExists) .collect(Collectors.toList()); } } ``` --- ## 4. Service 层:`RfidReadRecordServiceImpl` `RfidReadRecordServiceImpl` 是业务入口,负责: - 通过 `RfidReadRecordTableHelper` 决定要访问的分表; - 调用 Mapper 中的单表/多表/采样 SQL; - 在新增/修改/删除时根据 `recordTime` 或 `queryDate` 精确定位物理表。 ```java // file: rfid-middleware/ruoyi-modules/hw-rfid/src/main/java/org/dromara/rfid/service/impl/RfidReadRecordServiceImpl.java package org.dromara.rfid.service.impl; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.IdUtil; import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.utils.MapstructUtils; import org.dromara.common.core.utils.StringUtils; import org.dromara.common.mybatis.core.page.TableDataInfo; import org.dromara.common.mybatis.core.page.PageQuery; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.dromara.rfid.helper.RfidReadRecordTableHelper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.dromara.rfid.domain.bo.RfidReadRecordBo; import org.dromara.rfid.domain.vo.RfidReadRecordVo; import org.dromara.rfid.domain.RfidReadRecord; import org.dromara.rfid.mapper.RfidReadRecordMapper; import org.dromara.rfid.service.IRfidReadRecordService; import java.util.Date; import java.util.List; import java.util.Collection; /** * 读取记录 Service 实现 * * 支持按日期分表,表名格式:rfid_read_record_yyyyMMdd */ @Slf4j @RequiredArgsConstructor @Service public class RfidReadRecordServiceImpl implements IRfidReadRecordService { private final RfidReadRecordMapper baseMapper; /** * 根据主键 + 查询日期查询单条记录 * * @param id 主键 * @param queryDate 查询日期(yyyy-MM-dd),由前端从 recordTime 截取而来 */ @Override public RfidReadRecordVo queryById(Long id, Date queryDate) { // 通过查询日期计算分表表名 String tableName = RfidReadRecordTableHelper.getTableName(queryDate); return baseMapper.selectCustomRfidReadRecordVoById(tableName, id); } /** * 分页查询读取记录列表 * * 根据 beginRecordTime / endRecordTime 计算涉及到的分表列表, * 无表则直接返回空分页;单表使用单表分页 SQL,多表则使用 UNION ALL 聚合 SQL。 */ @Override public TableDataInfo queryPageList(RfidReadRecordBo bo, PageQuery pageQuery) { // 1. 根据时间范围计算实际存在的分表列表 List tableNames = RfidReadRecordTableHelper.getExistingTableNames( bo.getBeginRecordTime(), bo.getEndRecordTime()); // 2. 没有任何分表存在,直接返回空分页 if (CollUtil.isEmpty(tableNames)) { return TableDataInfo.build(new Page<>()); } // 3. 构建 MyBatis-Plus 查询条件(仅负责 WHERE,不负责 ORDER BY) LambdaQueryWrapper lqw = buildQueryWrapper(bo); // 4. 根据分表数量选择单表 or 多表分页 SQL Page result = tableNames.size() == 1 ? baseMapper.selectCustomRfidReadRecordVoPage(tableNames.get(0), pageQuery.build(), lqw) : baseMapper.selectCustomRfidReadRecordVoPageMultiTable(tableNames, pageQuery.build(), lqw); return TableDataInfo.build(result); } /** * 查询符合条件的读取记录列表(非分页) * * 支持采样查询:当 samplingInterval > 1 时,按 N 分钟一个时间槽进行抽样, * 降低大数据量查询时的返回记录数。 */ @Override public List queryList(RfidReadRecordBo bo) { // 1. 获取时间范围内实际存在的分表 List tableNames = RfidReadRecordTableHelper.getExistingTableNames( bo.getBeginRecordTime(), bo.getEndRecordTime()); if (CollUtil.isEmpty(tableNames)) { return List.of(); } LambdaQueryWrapper lqw = buildQueryWrapper(bo); // 2. 判断是否使用采样查询 Integer samplingInterval = bo.getSamplingInterval(); if (samplingInterval != null && samplingInterval > 1) { // 采样查询:每 N 分钟取一条最新记录作为代表 return tableNames.size() == 1 ? baseMapper.selectWithSampling(tableNames.get(0), samplingInterval, lqw) : baseMapper.selectWithSamplingMultiTable(tableNames, samplingInterval, lqw); } // 3. 普通查询:按单表 / 多表分别调用不同 SQL return tableNames.size() == 1 ? baseMapper.selectCustomRfidReadRecordVoList(tableNames.get(0), lqw) : baseMapper.selectCustomRfidReadRecordVoListMultiTable(tableNames, lqw); } /** * 构建查询条件 Wrapper * * 说明: * - 索引字段优先:device_id, record_time; * - 其他条件按需追加,Mapper XML 中的 ${ew.customSqlSegment} 会拼接到 WHERE 之后。 */ private LambdaQueryWrapper buildQueryWrapper(RfidReadRecordBo bo) { LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); // 索引字段:设备 ID lqw.eq(bo.getDeviceId() != null, RfidReadRecord::getDeviceId, bo.getDeviceId()); // 索引字段:记录时间范围 if (bo.getBeginRecordTime() != null && bo.getEndRecordTime() != null) { lqw.between(RfidReadRecord::getRecordTime, bo.getBeginRecordTime(), bo.getEndRecordTime()); } else { lqw.ge(bo.getBeginRecordTime() != null, RfidReadRecord::getRecordTime, bo.getBeginRecordTime()); lqw.le(bo.getEndRecordTime() != null, RfidReadRecord::getRecordTime, bo.getEndRecordTime()); } // 其他非必选条件 lqw.eq(StringUtils.isNotBlank(bo.getReadStatus()), RfidReadRecord::getReadStatus, bo.getReadStatus()); lqw.eq(StringUtils.isNotBlank(bo.getBarcode()), RfidReadRecord::getBarcode, bo.getBarcode()); lqw.eq(StringUtils.isNotBlank(bo.getAlarmFlag()), RfidReadRecord::getAlarmFlag, bo.getAlarmFlag()); lqw.eq(StringUtils.isNotBlank(bo.getAlarmLevel()), RfidReadRecord::getAlarmLevel, bo.getAlarmLevel()); lqw.eq(StringUtils.isNotBlank(bo.getAlarmType()), RfidReadRecord::getAlarmType, bo.getAlarmType()); lqw.eq(StringUtils.isNotBlank(bo.getAlarmAction()), RfidReadRecord::getAlarmAction, bo.getAlarmAction()); return lqw; } /** * 新增读取记录:根据 recordTime 路由到对应分表 */ @Override @Transactional(rollbackFor = Exception.class) public Boolean insertByBo(RfidReadRecordBo bo) { RfidReadRecord add = MapstructUtils.convert(bo, RfidReadRecord.class); validEntityBeforeSave(add); // 自定义 SQL 不会自动生成 ID,需要手动生成雪花 ID if (add.getId() == null) { add.setId(IdUtil.getSnowflakeNextId()); } // 根据记录时间路由到具体分表 String tableName = RfidReadRecordTableHelper.getTableName(add.getRecordTime()); boolean flag = baseMapper.insertRfidReadRecord(tableName, add) > 0; if (flag) { bo.setId(add.getId()); } return flag; } /** * 修改读取记录:同样根据 recordTime 路由到对应分表 */ @Override @Transactional(rollbackFor = Exception.class) public Boolean updateByBo(RfidReadRecordBo bo) { RfidReadRecord update = MapstructUtils.convert(bo, RfidReadRecord.class); validEntityBeforeSave(update); String tableName = RfidReadRecordTableHelper.getTableName(update.getRecordTime()); return baseMapper.updateRfidReadRecordById(tableName, update) > 0; } /** * 基础校验:空对象校验 + 条码非空校验 */ private void validEntityBeforeSave(RfidReadRecord entity) { if (entity == null) { throw new ServiceException("读取记录不能为空"); } if (entity.getBarcode() != null) { entity.setBarcode(entity.getBarcode().trim()); } // 读取成功(readStatus = 1)时条码必须有值 if ("1".equals(entity.getReadStatus()) && StringUtils.isBlank(entity.getBarcode())) { throw new ServiceException("读取成功时条码信息不能为空"); } } /** * 批量删除:根据 queryDate 决定分表 */ @Override @Transactional(rollbackFor = Exception.class) public Boolean deleteWithValidByIds(Collection ids, Date queryDate, Boolean isValid) { if (CollUtil.isEmpty(ids)) { return Boolean.TRUE; } String tableName = RfidReadRecordTableHelper.getTableName(queryDate); return baseMapper.deleteCustomRfidReadRecordByIds(tableName, ids) > 0; } } ``` --- ## 5. Mapper XML:`RfidReadRecordMapper.xml`(分表相关 SQL) 本节仅保留与分表查询和分页/采样直接相关的 SQL,其他看板/统计类 SQL 因与分表模式相同,读取时可类推。 ### 5.1 单表查询/分页 ```xml insert into ${tableName}( id, device_id, read_status, barcode, record_time, alarm_flag, alarm_level, alarm_type, alarm_action ) values ( #{entity.id}, #{entity.deviceId}, #{entity.readStatus}, #{entity.barcode}, #{entity.recordTime}, #{entity.alarmFlag}, #{entity.alarmLevel}, #{entity.alarmType}, #{entity.alarmAction} ) update ${tableName} device_id = #{entity.deviceId}, read_status = #{entity.readStatus}, barcode = #{entity.barcode}, record_time = #{entity.recordTime}, alarm_flag = #{entity.alarmFlag}, alarm_level = #{entity.alarmLevel}, alarm_type = #{entity.alarmType}, alarm_action = #{entity.alarmAction}, where id = #{entity.id} delete from ${tableName} where id in #{id} ``` ### 5.2 多表 UNION 查询/分页 ```xml ``` ### 5.3 采样查询(大数据量优化) ```xml ``` --- ## 6. 典型分表查询流程串联示例 下面以“**分页查询 + 查看详情 + 删除**”为例,串起前后端的完整调用链,便于快速理解: 1. **用户在前端选择时间范围并点击搜索** - `dateRange = ['2025-11-25 00:00:00', '2025-11-26 23:59:59']` - `getList()` 中将其转换为: - `queryParams.beginRecordTime = '2025-11-25 00:00:00'` - `queryParams.endRecordTime = '2025-11-26 23:59:59'` - 调用 `listRfidReadRecord(queryParams)`。 2. **后端 Service 收到请求,构建分表列表** - `RfidReadRecordServiceImpl.queryPageList(bo, pageQuery)`: - 调用 `RfidReadRecordTableHelper.getExistingTableNames(bo.beginRecordTime, bo.endRecordTime)`,得到如: - `[rfid_read_record_20251125, rfid_read_record_20251126]`; - 根据分表数量调用: - `selectCustomRfidReadRecordVoPageMultiTable(tableNames, pageQuery, lqw)`。 3. **Mapper 生成最终 SQL** - 使用 `` 依次展开每个分表: - `select ... from rfid_read_record_20251125 t ...` - `UNION ALL` - `select ... from rfid_read_record_20251126 t ...` - 最后统一 `left join rfid_device`,按 `record_time desc` 排序,实现跨表分页查询。 4. **用户在前端勾选一条记录并点击“修改”或“删除”** - `handleSelectionChange` 将选中行的 `recordTime` 截断为 `yyyy-MM-dd`,写入 `selectedRecordTimes`。 - 修改: - `handleUpdate(row)` 计算 `queryDate = row.recordTime.substring(0, 10)`; - 调用 `getRfidReadRecord(id, queryDate)`。 - 删除: - `handleDelete(row)` 计算相同的 `queryDate`; - 调用 `delRfidReadRecord(ids, queryDate)`。 5. **后端根据 queryDate 精确路由单条操作** - `queryById(id, queryDate)`:通过 `RfidReadRecordTableHelper.getTableName(queryDate)` 得到唯一表名,调用单表查询 SQL。 - `deleteWithValidByIds(ids, queryDate)`:同样通过 queryDate 得到表名,只在该分表中执行删除。 --- ## 7. 小结与扩展建议 - **优点**: - 按天分表 + 动态路由,兼顾了写入简单性与查询性能; - 通过 `getExistingTableNames` 屏蔽了不存在分表带来的 SQL 错误; - 采样查询在大数据场景下可以显著减少返回记录数,适合看板类页面。 - **注意事项**: - 前端在做“单条操作(详情/修改/删除)”时,务必传递 `queryDate`; - 新建分表后,若需要立刻生效,可调用 `RfidReadRecordTableHelper.clearTableExistsCache()` 刷新缓存; - 大范围跨天查询可能涉及多张分表,需注意分页总量与响应时间,可结合采样查询或限制最大时间跨度。 本说明文档覆盖了 RFID 读取记录分表查询的完整实现链路,后续如需扩展到其他按天分表的业务,可以复用本模式并适当抽象公共能力。 分页