From c6913eb2944cb6cbfcfb438170764affb8b8e384 Mon Sep 17 00:00:00 2001 From: "zangch@mesnac.com" Date: Thu, 27 Nov 2025 13:45:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(rfid):=20=E6=94=AF=E6=8C=81=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E8=AE=B0=E5=BD=95=E6=8C=89=E6=97=A5=E6=9C=9F=E5=88=86?= =?UTF-8?q?=E8=A1=A8=E6=9F=A5=E8=AF=A2=E5=92=8C=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在Service接口中新增queryDate参数,用于按日期定位具体分表查询 - 列表查询根据beginRecordTime和endRecordTime自动路由多个分表 - 新增、修改操作根据recordTime自动确定目标分表 - 删除接口增加queryDate参数支持精确分表定位并校验有效性 - 文档详细说明分表设计、前后端调用链与业务逻辑使用方式 - 前端实现分页查询、选中行修改删除传递queryDate支持分表访问 - 添加分表工具类封装表名映射、表存在检测及缓存机制 --- RFID.md | 184 ++- ruoyi-modules/hw-rfid/ShardingQuery.md | 1143 +++++++++++++++++ .../rfid/service/IRfidReadRecordService.java | 25 +- 3 files changed, 1346 insertions(+), 6 deletions(-) create mode 100644 ruoyi-modules/hw-rfid/ShardingQuery.md diff --git a/RFID.md b/RFID.md index fcec185..5259e62 100644 --- a/RFID.md +++ b/RFID.md @@ -55,4 +55,186 @@ 19. **特别注意:rfid_read_record分表** - rfid_read_record按照日期分表,例如rfid_read_record_20251126,可参考rfid_read_record_20251126.sql文件,数据库类型是MySQL - 前后端代码可以参考ShardingQuery.md文件(其他项目TiDb数据库的总结文档) - \ No newline at end of file + + +# 业务逻辑 +## 设备记录成功率逻辑 +- +- 成功率统计接口:`GET /rfid/dashboard/successRate`,按小时(0–23 点)返回折线图数据,用于前端折线图展示. +- 统计维度:后端自动以当前系统日期作为“今日”,**同时统计昨日同一小时的成功率**,前端无需传任何查询参数. +- 分表与时间范围:分别通过 `RfidReadRecordTableHelper.getTableName(今日)` 与 `RfidReadRecordTableHelper.getTableName(昨日)` 生成当日与昨日分表(如 `rfid_read_record_20251126`),各自只统计 `00:00:00` 至 `23:59:59` 之间的读取记录. +- 成功率聚合 SQL:在分表上按 `DATE_FORMAT(record_time, '%H:00')` 分组,统计每小时的总记录数 `totalCount` 与成功记录数 `successCount`(`read_status = '1'`)。 +- 成功率计算公式:`successRate = ROUND(successCount * 100.0 / totalCount, 2)`,即“该小时成功条数 ÷ 总条数 × 100”,保留两位小数. +- Mapper 至少返回 `timePoint`、`successRate` 字段,Service 层分别将“今日”与“昨日”的统计结果转为 `Map`,再按 0–23 小时顺序构造 24 条数据: + - `successRate`:今日该小时成功率; + - `yesterdaySuccessRate`:昨日同一小时成功率; + - 对于没有统计结果的小时,对应字段返回 `null`,保证时间轴完整. +- 异常与容错:当某日分表不存在或查询出错时,Service 捕获异常记录告警日志并返回空列表或空 Map,不影响整个看板接口的可用性. + +## 看板模块 + +### 接口1:实时统计(顶部概览 + 告警列表) + +> 请求方式:`GET /rfid/dashboard/realtime` +> 前端方法:`getRealtimeStats(alarmLimit?: number)` +> 后端链路:`DashboardController#getRealtimeStats` → `IDashboardService#getRealtimeStats` + +**请求参数(Query)** + +- `alarmLimit?: number` + 告警列表限制数量,可选。 + - **不传:返回全部告警记录**,供前端滚动显示。 + +**返回实体与字段** + +- 后端返回类型:`DashboardVO.RealtimeStats` +- 前端 TS 类型:`RealtimeStats` + +字段说明: + +1. `overview: StatisticsOverview` —— 顶部统计概览 + - `deviceTotal: number`:设备总数(仅统计 `is_marked = 1` 的设备)。 + - `onlineCount: number`:在线设备数量(`online_status = 1`)。 + - `offlineCount: number`:离线设备数量(`online_status = 0`)。 + - `alarmCount: number`:告警设备数量(`alarm_status = 1`)。 + +2. `alarmStats: AlarmStatVO[]` —— 告警统计列表 + - `alarmTime: string`:告警时间(`MM-dd HH:mm` 格式)。 + - `deviceName: string`:设备名称。 + - `location: string`:所在位置名称。 + - `alarmLevel: string`:告警级别。 + - `alarmAction: string`:告警行为/建议动作。 + +--- + +### 接口2:位置树(含设备信息) + +> 请求方式:`GET /rfid/dashboard/deviceStatus` +> 前端方法:`getLocationTree()` +> 后端链路:`DashboardController#getLocationTree` → `IDashboardService#getLocationTree` + +**请求参数(Query)** + +- 无参数,直接调用即返回完整位置树。 + +**返回实体与字段** + +- 后端返回类型:`List` +- 前端 TS 类型:`LocationTreeNode[]` + +字段说明: + +1. 位置基础信息 + - `id: number`:位置 ID。 + - `locationCode: string`:位置编号。 + - `locationAlias: string`:位置别名。 + - `locationType: string`:位置类型(`1-车间;2-工序;3-工位/设备`)。 + - `parentId: number`:父级位置 ID(`0` 或 `null` 表示顶级)。 + +2. 设备信息(仅当 `locationType = 3` 时有值) + - `deviceId: number`:设备 ID,**前端用于匹配 WebSocket 数据**。 + - `deviceCode: string`:设备编号。 + - `deviceName: string`:设备名称。 + - `onlineStatus: string`:在线状态(`1-在线;0-离线`)。 + - `alarmStatus: string`:告警状态(`0-正常;1-告警`)。 + +3. 子节点 + - `children: LocationTreeNode[]`:子位置/设备列表。 + +**WebSocket 数据匹配说明** + +C# 服务会通过 WebSocket 实时推送设备读取记录,格式示例: +```json +{ + "objid": 1993656942031147008, + "deviceId": 1, + "readStatus": "1", + "epcStr": "SW004", + "alarmFlag": "0", + "alarmLevel": "", + "alarmType": "", + "alarmAction": "", + "recordTime": "2025-11-26T20:23:49.695356+08:00" +} +``` +前端通过 `deviceId` 字段匹配位置树中的设备节点,实现实时数据展示。 + +--- + +### 接口3:设备记录的成功率(按小时统计) + +> 请求方式:`GET /rfid/dashboard/successRate` +> 前端方法:`getSuccessRateTrends(type?: string)` +> 后端链路:`DashboardController#getSuccessRateTrends` → `DashboardServiceImpl#getSuccessRateTrends` + +**请求参数(Query)** + +- `type?: string` + - 前端不传参,后端直接默认当天,返回 + +**返回实体与字段** + +- 后端返回类型:`List` +- 前端 TS 类型:`SuccessRateTrend[]` + +字段说明: + +1. `timePoint: string` + - 时间点,格式为 `"HH:00"`,例如 `"09:00"`,从 `"00:00"` 到 `"23:00"` 共 24 个。 + +2. `successRate: number | null` + - 该小时的读取成功率(百分比,如 `98.5`)。 + - 如果某个小时没有任何读取记录,则为 `null`。 + +**后端处理要点(概要版)** + +1. **查询维度** + - 入参 `type`:`today`(默认)、`yesterday`。 + - 根据 `type` 计算目标日期 `targetDate`: + - `today` 或其它值 → `LocalDate.now()`(今日); + - `yesterday` → `LocalDate.now().minusDays(1)`(昨日)。 + +2. **分表与时间范围** + - 使用 `RfidReadRecordTableHelper.getTableName(targetDate)` 生成当日分表名,例如 `rfid_read_record_20251126`。 + - 计算当日时间范围字符串: + - 起始时间 `startTime = targetDate.atStartOfDay()`(`yyyy-MM-dd 00:00:00`); + - 结束时间 `endTime = targetDate.atTime(23, 59, 59)`(`yyyy-MM-dd 23:59:59`)。 + +3. **按小时统计成功率(Mapper 层)** + - 调用 `readRecordMapper.selectSuccessRateByHour(tableName, startTime, endTime)` 进行聚合统计。 + - SQL 约定返回字段: + - `timePoint`:小时点,格式为 `"HH:00"`,如 `"08:00"`; + - `successRate`:该小时的读取成功率(类型可能为 `Double` / `BigDecimal` 等)。 + - 成功率的具体计算逻辑由 Mapper SQL 维护(如成功条数 / 总条数),Service 层只消费结果,不参与公式计算。 + +4. **补全 0–23 小时完整时间轴** + - 将 Mapper 返回结果转换为 `Map`; + - 循环 `hour = 0..23` 构建 24 个时间点; + - 对没有统计结果的小时,`successRate` 返回为 `null`。 + +5. **前端展示约定** + - 折线图 X 轴:直接使用 `timePoint`(`"HH:00"`)。 + - 折线图 Y 轴:使用 `successRate` 数值。 + - 当前端拿到 `successRate = null` 的点时,可根据实际需求选择: + - 展示为空值(不连线 / 断点);或 + - 按 0 处理(展示为 0%)。 + - 由于后端已保证 24 个小时点完整返回,前端无需再做补全或排序逻辑。 + +--- + +### 原有接口(聚合 / 辅助) + +1. `GET /rfid/dashboard/data` + - 前端方法:`getDashboardData(locationId?: number)` / `getDashboardStats(locationId?: number)`(兼容老名称)。 + - 参数:`locationId?: number`,位置 ID,可选。 + - 返回:`DashboardVO`(统计概览 + 设备状态列表 + 成功率趋势 + 告警统计)。 + +2. `GET /rfid/dashboard/overview` + - 前端方法:`getOverview()`。 + - 参数:无。 + - 返回:`StatisticsOverview`,字段含义同上。 + +3. `GET /rfid/dashboard/alarmStats` + - 前端方法:`getAlarmStats(limit?: number)`。 + - 参数:`limit?: number`,限制返回条数,默认 10。 + - 返回:`AlarmStatVO[]`,字段含义同实时统计中的 `alarmStats`. \ No newline at end of file diff --git a/ruoyi-modules/hw-rfid/ShardingQuery.md b/ruoyi-modules/hw-rfid/ShardingQuery.md new file mode 100644 index 0000000..c89abbf --- /dev/null +++ b/ruoyi-modules/hw-rfid/ShardingQuery.md @@ -0,0 +1,1143 @@ + # 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 读取记录分表查询的完整实现链路,后续如需扩展到其他按天分表的业务,可以复用本模式并适当抽象公共能力。 + +分页 \ No newline at end of file diff --git a/ruoyi-modules/hw-rfid/src/main/java/org/dromara/rfid/service/IRfidReadRecordService.java b/ruoyi-modules/hw-rfid/src/main/java/org/dromara/rfid/service/IRfidReadRecordService.java index ace2a80..e102a2d 100644 --- a/ruoyi-modules/hw-rfid/src/main/java/org/dromara/rfid/service/IRfidReadRecordService.java +++ b/ruoyi-modules/hw-rfid/src/main/java/org/dromara/rfid/service/IRfidReadRecordService.java @@ -6,10 +6,14 @@ import org.dromara.common.mybatis.core.page.TableDataInfo; import org.dromara.common.mybatis.core.page.PageQuery; import java.util.Collection; +import java.util.Date; import java.util.List; /** * 读取记录Service接口 + *

+ * 支持按日期分表,表名格式:rfid_read_record_yyyyMMdd + *

* * @author zch * @date 2025-11-25 @@ -19,13 +23,17 @@ public interface IRfidReadRecordService { /** * 查询读取记录 * - * @param id 主键 + * @param id 主键 + * @param queryDate 查询日期(用于定位分表) * @return 读取记录 */ - RfidReadRecordVo queryById(Long id); + RfidReadRecordVo queryById(Long id, Date queryDate); /** * 分页查询读取记录列表 + *

+ * 根据 bo 中的 beginRecordTime 和 endRecordTime 确定查询的分表范围 + *

* * @param bo 查询条件 * @param pageQuery 分页参数 @@ -43,6 +51,9 @@ public interface IRfidReadRecordService { /** * 新增读取记录 + *

+ * 根据 recordTime 自动路由到对应日期的分表 + *

* * @param bo 读取记录 * @return 是否新增成功 @@ -51,6 +62,9 @@ public interface IRfidReadRecordService { /** * 修改读取记录 + *

+ * 根据 recordTime 定位分表进行更新 + *

* * @param bo 读取记录 * @return 是否修改成功 @@ -60,9 +74,10 @@ public interface IRfidReadRecordService { /** * 校验并批量删除读取记录信息 * - * @param ids 待删除的主键集合 - * @param isValid 是否进行有效性校验 + * @param ids 待删除的主键集合 + * @param queryDate 查询日期(用于定位分表) + * @param isValid 是否进行有效性校验 * @return 是否删除成功 */ - Boolean deleteWithValidByIds(Collection ids, Boolean isValid); + Boolean deleteWithValidByIds(Collection ids, Date queryDate, Boolean isValid); }