邹裕
2 天以前 0120a42fc52d9e55bc22a450e35a24b2d844b8ad
修复ucanaccess查询中文排序mdb数据库导致索引失效查询缓慢问题
已添加1个文件
已修改3个文件
474 ■■■■■ 文件已修改
README.md 150 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pom.xml 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/hwtd/mes/collect/service/impl/DataCollectionServiceImpl.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/hwtd/mes/collect/util/JackcessTableReader.java 286 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
README.md
@@ -1,47 +1,145 @@
## data-acquisition
亨旺特导MES数据采集器
亨旺特导 MES æ•°æ®é‡‡é›†å™¨ï¼ˆSpring Boot 2.6.3 / Java 8)
### ä¸²å£é‡‡é›†ï¼ˆæ‰‹åŠ¨ç›‘å¬ï¼‰
- é‡‡é›†æ–¹å¼ï¼šä¸²å£ï¼ˆæ‰‹åŠ¨ç›‘å¬ï¼‰ã€Access æ•°æ®åº“、PostgreSQL
- æœåŠ¡ç«¯å£ï¼š9527(`server.port`)
- æ—¥å¿—目录:`logging.file-location`(默认 `D:\mes-acquistion\logs`)
- ç»Ÿä¸€è¿”回格式:`{ "code": 0, "msg": null, "data": ... }`,`code=0` æˆåŠŸï¼Œ`code=1` å¤±è´¥ï¼ˆåŽŸå› åœ¨ `msg` é‡Œï¼‰
串口不再随项目启动自动监听,改为手动监听:调用采集接口时开启监听,采集结束后调用关闭接口。
### ä¸€ã€ä¸²å£é‡‡é›†ï¼ˆæ‰‹åŠ¨ç›‘å¬ï¼‰
串口不随项目启动自动监听:调用开启接口打开串口并取数,采集结束后调用关闭接口释放串口。
| æŽ¥å£ | è¯´æ˜Ž |
| --- | --- |
| `GET /lims/getFile?fileExtension=.serialPort` | é‡‡é›†æŽ¥å£ï¼šè°ƒç”¨æ—¶è‡ªåŠ¨å¼€å¯ç›‘å¬ï¼Œå¹¶è¿”å›žæœ¬æ¬¡é‡‡é›†åˆ°çš„æ•°æ®ï¼›æ²¡æœ‰æ•°æ®æ—¶è¿”å›žç©ºé›†åˆï¼Œç”±å‰ç«¯è½®è¯¢å–æ•° |
| `GET /lims/openSerialPort` | æ‰‹åŠ¨å¼€å¯ç›‘å¬ï¼Œå¹¶è¿”å›žå½“å‰æŽ¥æ”¶åˆ°çš„æ•°æ®åˆ—è¡¨ï¼ˆå¹‚ç­‰ï¼Œå‚æ•°ä¸€è‡´æ—¶å¤ç”¨å·²æ‰“å¼€çš„ä¸²å£ï¼‰ |
| `GET /lims/closeSerialPort` | æ‰‹åŠ¨å…³é—­ç›‘å¬ï¼ˆå¹‚ç­‰ï¼Œé‡‡é›†ç»“æŸåŽè°ƒç”¨ï¼Œé¿å…ä¸²å£ä¸€ç›´è¢«å ç”¨ï¼‰ |
| `GET /lims/serialPortStatus` | æŸ¥è¯¢ç›‘听状态:`listenName` / `listening` / `portOpen` / `dataSize` |
| `GET /collection/openSerialPort` | å¼€å¯ä¸²å£ç›‘听并返回当前接收到的数据列表(没有数据时返回空集合,由前端轮询取数)。幂等:参数一致时复用已打开的串口,参数变化时按新参数重新打开,多线程并发调用也只会打开一次 |
| `GET /collection/closeSerialPort` | å…³é—­ä¸²å£ç›‘听(幂等,未开启时调用也返回成功) |
| `GET /collection/serialPortStatus` | æŸ¥è¯¢ç›‘听状态,返回 `listenName` / `listening` / `portOpen` / `dataSize` |
串口参数全部由接口传入,`/lims/getFile`(`fileExtension=.serialPort` æ—¶ï¼‰å’Œ `/lims/openSerialPort` éƒ½æ”¯æŒï¼Œ
不传的字段使用默认值(`listenName` å¿…传)。application.yml ä¸­çš„ `serialPort` é…ç½®å·²ä¸å†ç”Ÿæ•ˆï¼Œå¯ä»¥åˆ é™¤ã€‚
#### ä¸²å£å‚数(全部由接口传入,不传的字段用默认值)
| å‚æ•° | é»˜è®¤å€¼ | è¯´æ˜Ž |
| --- | --- | --- |
| `listenName` | å¿…ä¼  | ä¸²å£åç§°ï¼Œå¦‚ `COM12`;不传会返回“串口名称不能为空” |
| `serialPortName` | å¿…ä¼  | ä¸²å£åç§°ï¼Œå¦‚ `COM12`;不传会返回“串口名称不能为空” |
| `baudRate` | `9600` | æ³¢ç‰¹çއ |
| `dataBits` | `8` | æ•°æ®ä½ï¼Œ5~8 |
| `stopBits` | `1` | åœæ­¢ä½ï¼Œ1、2、3(1.5 ä½) |
| `parity` | `NONE` | æ ¡éªŒä½ï¼š`NONE`/`ODD`/`EVEN`/`MARK`/`SPACE`,也兼容 0~4 |
| `flowControl` | `NONE` | æµæŽ§ï¼š`NONE`/`RTS_CTS`/`XON_XOFF`,也兼容数字常量 |
| `parity` | `NONE` | æ ¡éªŒä½ï¼š`NONE`/`ODD`/`EVEN`/`MARK`/`SPACE`(不区分大小写),也兼容 0~4 |
| `flowControl` | `NONE` | æµæŽ§ï¼š`NONE`/`RTS_CTS`/`XON_XOFF`(不区分大小写),也兼容数字常量 |
| `readTimeout` | `1000` | è¯»å–超时(毫秒) |
| `endMark` | `*` | ä¸€å¸§æ•°æ®çš„结束标志,特殊字符用 URL ç¼–码,如换行 `%0A` |
| `maxCount` | `6` | æ•°æ®ç¼“存队列长度:队列满时插入最新一条、移除最旧一条 |
| `endMark` | `*` | ä¸€å¸§æ•°æ®çš„结束标志,特殊字符用 URL ç¼–码传入,如换行 `%0A` |
| `maxCount` | `1` | æ•°æ®ç¼“存队列长度:队列满时插入最新一条、移除最旧一条;默认 `1` å³åªè¿”回设备最新的一条 |
| `frameIdleMillis` | `200` | å¸§ç©ºé—²è¡¥å¸§æ—¶é—´ï¼ˆæ¯«ç§’):超过该时间没有新数据,就把缓冲区剩余数据当成一帧取出;`0` è¡¨ç¤ºå…³é—­ |
示例:`/lims/getFile?fileExtension=.serialPort&listenName=COM12&baudRate=19200&parity=EVEN&endMark=%0A&maxCount=6`
调用示例:
参数与当前生效参数一致时复用已经打开的串口(不会重复打开);参数变化时会自动按新参数重新打开串口;
参数非法时接口直接返回具体原因(如 `串口参数 dataBits éžæ³•:9,取值范围 5~8`)。
```
# å¼€å¯ç›‘听并取数(9600 8N1、结束标志 *,默认只取最新一条)
http://127.0.0.1:9527/collection/openSerialPort?serialPortName=COM12
采集到的数据按队列缓存:插入最新的一条,超过 `maxCount` æ¡ï¼ˆé»˜è®¤ 6 æ¡ï¼‰æ—¶ç§»é™¤æœ€æ—§çš„一条,
因此接口每次最多返回 `maxCount` æ¡æœ€æ–°çš„æ•°æ®ï¼ˆæŒ‰æŽ¥æ”¶å…ˆåŽé¡ºåºï¼‰ï¼›æ•°æ®è¢«å–走后队列清空,重新开始缓存。
# 19200 å¶æ ¡éªŒã€ä»¥æ¢è¡Œç»“尾、最多取 6 æ¡
http://127.0.0.1:9527/collection/openSerialPort?serialPortName=COM12&baudRate=19200&parity=EVEN&endMark=%0A&maxCount=6
数据帧按 `endMark` åˆ‡åˆ†ã€‚如果设备是「每帧以某个字符开头」而不是结尾(例如发 `*0 1 0`、`*0 2 0`,
`*` ç”¨äºŽåˆ†éš”前后两帧),那么最后一帧后面没有新的 `*` æ¥è§¦å‘切分,只靠结束标志会漏掉最后一条;
此时由 `frameIdleMillis`(默认 200ms)兜底:串口空闲超过该时间后,把接收缓冲区里剩下的数据当作一帧取出。
如果设备每帧都以结束标志结尾,缓冲区每次切分后都是空的,空闲补帧不会做任何事。
# æŸ¥çœ‹ç›‘听状态 / å…³é—­ä¸²å£
http://127.0.0.1:9527/collection/serialPortStatus
http://127.0.0.1:9527/collection/closeSerialPort
```
并发说明:开启 / å…³é—­ä¸²å£ç”±åŒä¸€æŠŠé”ä¿æŠ¤ï¼Œå¤šçº¿ç¨‹å¹¶å‘调用采集接口也只会打开一次串口,不会重复注册监听器;
采集结果存放在同步集合中,多线程同时取数时同一条数据只会被一个线程取走,不会重复也不会丢失。
调用关闭接口后再次调用采集接口会重新开启监听,并清掉上一轮残留的半包数据和未取走的数据,保证每次采集的数据只属于本轮。
#### å–数与切帧规则
- æ•°æ®æŒ‰ `endMark` åˆ‡æˆä¸€å¸§ä¸€å¸§ï¼›
- åˆ‡å‡ºæ¥çš„æ•°æ®æ”¾è¿›é˜Ÿåˆ—缓存:插入最新的一条,超过 `maxCount` æ¡æ—¶ç§»é™¤æœ€æ—§çš„一条,
  å› æ­¤æŽ¥å£æ¯æ¬¡æœ€å¤šè¿”回 `maxCount` æ¡æœ€æ–°çš„æ•°æ®ï¼ˆæŒ‰æŽ¥æ”¶å…ˆåŽé¡ºåºï¼‰ï¼›æ•°æ®è¢«å–走后队列清空,重新开始缓存;
- è®¾å¤‡æœ€åŽä¸€å¸§å¦‚果不带结束标志(例如每帧以 `*` å¼€å¤´ã€é ä¸‹ä¸€ä¸ª `*` åˆ†éš”前后两帧),只靠结束标志会漏掉最后一条,
  æ­¤æ—¶ç”± `frameIdleMillis` å…œåº•:串口空闲超过该时间就把接收缓冲区里剩下的数据当作一帧取出;
  å¦‚果设备每帧都以结束标志结尾,缓冲区每次切分后都是空的,空闲补帧不会做任何事;
- å‚数与当前生效参数一致时复用已经打开的串口(不会重复打开);参数变化时按新参数重新打开;
- å†æ¬¡å¼€å¯ç›‘听时会清掉上一轮残留的半包数据和未取走的数据,保证每次采集的数据只属于本轮;
- å‚数非法时接口直接返回具体原因(如 `串口参数 dataBits éžæ³•:9,取值范围 5~8`)。
#### å¹¶å‘说明
- å¼€å¯ / å…³é—­ä¸²å£ç”±åŒä¸€æŠŠé”ä¿æŠ¤ï¼Œé‡å¤è°ƒç”¨åªä¼šçœŸæ­£æ‰“开一次串口,不会重复注册监听器;
- é‡‡é›†ç»“果存放在同步集合中,多线程同时取数时同一条数据只会被一个线程取走,不会重复也不会丢失;
- ç›‘听状态使用 volatile æ ‡è®°ï¼Œå…³é—­ä¸²å£åŽå›žè°ƒçº¿ç¨‹ä¸å†å¤„理数据。
### äºŒã€æ•°æ®åº“采集
| æŽ¥å£ | è¯´æ˜Ž |
| --- | --- |
| `GET /collection/getAccessData` | é‡‡é›† Access æ•°æ®åº“(.mdb/.accdb)表数据,返回 `List<Map<String,Object>>` |
| `GET /collection/getPostgreSqlData` | é‡‡é›† PostgreSQL è¡¨æ•°æ®ï¼Œè¿”回 `List<Map<String,Object>>` |
参数(`DatabaseDTO`):
| å‚æ•° | è¯´æ˜Ž |
| --- | --- |
| `filePath` | Access æ•°æ®åº“文件路径(仅 `getAccessData`) |
| `ipAddress` / `serverPort` / `databaseName` | PostgreSQL è¿žæŽ¥ä¿¡æ¯ï¼ˆä»… `getPostgreSqlData`) |
| `userName` / `password` | æ•°æ®åº“用户名、密码;Access çš„æ–‡ä»¶æ‰“开密码放在 `password`(`userName` å¯¹ Access æ–‡ä»¶åŠ å¯†æ— ä½œç”¨ï¼‰ |
| `tableName` | æ•°æ®è¡¨å |
| `pointColumns` | è¦é‡‡é›†çš„字段,多个用逗号分隔 |
| `mainColumn` + `batchCode` | æŒ‰æ‰¹æ¬¡è¿‡æ»¤ï¼š`mainColumn = batchCode` |
| `orderColumn` / `orderRule` | æŽ’序字段与规则(`ASC`/`DESC`,不传默认 `ASC`) |
#### Access è¯»å–方式(`mes.access.read-mode`)
- `jackcess`(默认):jackcess åªè¯»ç›´è¯»ï¼ˆ`DatabaseBuilder.setReadOnly(true).setAutoSync(false)`),打开文件后顺序扫描单表、只取需要的列,不构建 HSQLDB é•œåƒï¼Œåªå–某张表几列时最快;
- `ucanaccess`:走 UCanAccess çš„ SQL è·¯å¾„,需要 Access SQL、链接表 `remap` ç­‰åœºæ™¯æ—¶ä½¿ç”¨ã€‚
jackcess æ¨¡å¼çš„差异:
- æŒ‰ `mainColumn` è¿‡æ»¤æ˜¯æ•´è¡¨æ‰«æï¼šæœ¬åº“文本索引的排序规则是中文 2052,jackcess ä¸æ”¯æŒï¼ˆç´¢å¼•只读、索引游标直接抛异常),没有可用的索引查询路径;`orderColumn` æŽ’序在 Java ä¾§å®Œæˆï¼›
- `OLE` å­—段与原来一样返回 `null`,且不会读取它的长值;
- æ—¶é—´å­—段统一返回 `java.sql.Timestamp`(JSON é‡Œä»æ˜¯ `yyyy-MM-dd HH:mm:ss.SSS`);
- è®¾ç½®äº†æ‰“开密码的库会自动用 `jackcess-encrypt` æ‰“开(密码取 `password` å‚æ•°ï¼›Access çš„æ–‡ä»¶åŠ å¯†åªæœ‰â€œæ‰“å¼€å¯†ç â€ä¸€ä¸ªæ¦‚å¿µï¼Œ`userName` ä¸å‚与,.mdw å·¥ä½œç»„级安全性 jackcess ä¸æ”¯æŒï¼‰ï¼›`ucanaccess` æ¨¡å¼ä¸‹æœ‰ `jackcess-encrypt` åœ¨ classpath ä¸Šï¼Œ`password` å‚数同样对加密库生效;
- é“¾æŽ¥è¡¨çš„行为与 UCanAccess çš„ `remap` ä¸åŒï¼Œé‡åˆ°é—®é¢˜åˆ‡å›ž `ucanaccess` æ¨¡å¼å³å¯ã€‚
### ä¸‰ã€æ‰˜ç›˜å›¾æ ‡
程序启动后任务栏右下角会出现托盘图标(`SystemTrayInitializer`),右键菜单:
| èœå•项 | è¯´æ˜Ž |
| --- | --- |
| æŸ¥çœ‹ä¸²å£çŠ¶æ€ | å¼¹å‡ºçŠ¶æ€çª—å£ï¼šä¸²å£åç§° / ç›‘听状态 / ä¸²å£è¿žæŽ¥ / ç¼“存条数,每秒自动刷新 |
| å…³é—­ä¸²å£ | åªå…³é—­ä¸²å£ç›‘听(不退出程序),方便现场把串口释放给别的程序用;结果用托盘气泡提示 |
| æ‰“开日志目录 | æ‰“å¼€ `logging.file-location` é…ç½®çš„æ—¥å¿—目录 |
| é€€å‡º | å…ˆå…³é—­ Spring å®¹å™¨ï¼ˆè§¦å‘ `@PreDestroy`:关闭串口、停止补帧线程),再结束进程 |
- åŒå‡»æ‰˜ç›˜å›¾æ ‡ = æŸ¥çœ‹ä¸²å£çŠ¶æ€ï¼›
- èœå•弹出在光标上方、右边缘对齐,点菜单外面 / æŒ‰ ESC / å†å³é”®ä¸€æ¬¡éƒ½ä¼šæ”¶èµ·ï¼›
- å›¾æ ‡ä¼˜å…ˆç”¨ `src/main/resources/tray.png`(放一张 32x32 çš„ png å³å¯æ¢æˆè‡ªå·±çš„图标),没有就用代码画的默认图标;
- æ‰˜ç›˜ä¾èµ– AWT,启动类里已经 `setHeadless(false)`;以 Windows æœåŠ¡æ–¹å¼è¿è¡Œï¼ˆæ²¡æœ‰æ¡Œé¢ä¼šè¯ï¼‰æ—¶æ‰˜ç›˜ä¸æ˜¾ç¤ºï¼ŒæŽ¥å£ä¸å—å½±å“ã€‚
### å››ã€æž„建与运行
```bash
# æ‰“包(产物 target/data-acquisition.jar,可直接 java -jar è¿è¡Œï¼‰
mvn clean package -DskipTests
# è¿è¡Œï¼ˆéœ€è¦ Java 8)
java -jar target/data-acquisition.jar
```
- **双击 jar è¿è¡Œ**:需要把 `.jar` çš„默认打开方式指向 **Java 8 çš„ javaw.exe**
  ï¼ˆå³é”® jar â†’ æ‰“开方式 â†’ é€‰æ‹©å…¶ä»–应用 â†’ æ›´å¤šåº”用 â†’ åœ¨è¿™å°ç”µè„‘上查找其他应用 â†’ `<jdk8>\bin\javaw.exe` â†’ å‹¾é€‰â€œå§‹ç»ˆä½¿ç”¨æ­¤åº”用打开 .jar æ–‡ä»¶â€ï¼‰ã€‚
  æ³¨æ„ä¸è¦æŒ‡å‘高版本 JDK(本项目是 Spring Boot 2.6.3,只支持 Java 8~17),也不要用压缩软件打开;
- ç”¨ javaw å¯åŠ¨æ²¡æœ‰æŽ§åˆ¶å°ï¼Œ**启动失败会弹窗提示**(例如端口被占用、日志目录不可写),详细错误看 `logging.file-location` ç›®å½•下的日志;
- é€€å‡ºè¯·ç”¨æ‰˜ç›˜èœå•的「退出」(会正常关闭串口);直接结束进程不会走这套清理流程。
### äº”、配置(application.yml)
```yaml
server:
  port: 9527                              # æœåŠ¡ç«¯å£
logging:
  file-location: D:\mes-acquistion\logs   # æ—¥å¿—目录(托盘“打开日志目录”也用它)
#mes:
#  access:
#    read-mode: jackcess                   # Access è¯»å–方式:jackcess(默认,直读单表)/ ucanaccess(SQL æ¨¡å¼ï¼‰
```
> ä¸²å£å‚数(串口名、波特率、数据位、校验位、结束标志等)全部由接口传入,配置文件里不需要串口相关配置。
pom.xml
@@ -103,6 +103,26 @@
            <groupId>net.sf.ucanaccess</groupId>
            <artifactId>ucanaccess</artifactId>
            <version>5.0.1</version>
            <exclusions>
                <exclusion>
                    <groupId>com.healthmarketscience.jackcess</groupId>
                    <artifactId>jackcess</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <!-- Source: https://mvnrepository.com/artifact/com.healthmarketscience.jackcess/jackcess -->
        <dependency>
            <groupId>com.healthmarketscience.jackcess</groupId>
            <artifactId>jackcess</artifactId>
            <version>4.0.10</version>
            <scope>compile</scope>
        </dependency>
        <!-- æ‰“开设置了密码的 Access æ–‡ä»¶ï¼ˆ.mdb/.accdb)需要它 -->
        <dependency>
            <groupId>com.healthmarketscience.jackcess</groupId>
            <artifactId>jackcess-encrypt</artifactId>
            <version>4.0.3</version>
            <scope>compile</scope>
        </dependency>
        <dependency>
src/main/java/com/hwtd/mes/collect/service/impl/DataCollectionServiceImpl.java
@@ -2,10 +2,12 @@
import com.hwtd.mes.collect.dto.DatabaseDTO;
import com.hwtd.mes.collect.service.DataCollectionService;
import com.hwtd.mes.collect.util.JackcessTableReader;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.sql.*;
@@ -20,8 +22,24 @@
     */
    private final static List<String> MDB_EXCLUDE_TYPES = Arrays.asList("java.sql.Blob");
    /**
     * Access è¯»å–方式:
     * jackcess(默认)—— ç”¨ jackcess åªè¯»ç›´è¯»å•表、按需取列,不构建 HSQLDB é•œåƒï¼Œåªè¦è¡¨é‡Œå‡ åˆ—时最快;
     * ucanaccess â€”— èµ°åŽŸæ¥çš„ SQL è·¯å¾„(需要 Access SQL、链接表 remap ç­‰åœºæ™¯æ—¶ä½¿ç”¨ï¼‰ã€‚
     */
    @Value("${mes.access.read-mode:jackcess}")
    private String accessReadMode = "jackcess";
    @Override
    public List<Map<String, Object>> getAccessData(DatabaseDTO databaseDTO) {
        if (databaseDTO == null || StringUtils.isBlank(databaseDTO.getFilePath())) {
            throw new RuntimeException("Access数据库文件路径不能为空");
        }
        // é»˜è®¤ jackcess ç›´è¯»ï¼šåªå–某张表的几列不需要 SQL å¼•擎,也不用等整个库镜像进 HSQLDB
        if (!"ucanaccess".equalsIgnoreCase(StringUtils.trimToEmpty(accessReadMode))) {
            return JackcessTableReader.read(databaseDTO);
        }
        List<Map<String,Object>> list = new ArrayList<>();
        try{
            Properties prop = new Properties();
src/main/java/com/hwtd/mes/collect/util/JackcessTableReader.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,286 @@
package com.hwtd.mes.collect.util;
import com.healthmarketscience.jackcess.Column;
import com.healthmarketscience.jackcess.Cursor;
import com.healthmarketscience.jackcess.CursorBuilder;
import com.healthmarketscience.jackcess.DataType;
import com.healthmarketscience.jackcess.Database;
import com.healthmarketscience.jackcess.DatabaseBuilder;
import com.healthmarketscience.jackcess.Row;
import com.healthmarketscience.jackcess.Table;
import com.healthmarketscience.jackcess.crypt.CryptCodecProvider;
import com.healthmarketscience.jackcess.crypt.InvalidCredentialsException;
import com.healthmarketscience.jackcess.crypt.InvalidCryptoConfigurationException;
import com.hwtd.mes.collect.dto.DatabaseDTO;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import java.io.File;
import java.io.IOException;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
 * ç”¨ jackcess ç›´è¯» .mdb/.accdb è¡¨æ•°æ®ï¼ˆåªè¯»ï¼‰ï¼Œç”¨äºŽâ€œåªå–某张表的几列”这种简单场景。
 * <p>
 * ä¸Ž UCanAccess çš„区别:UCanAccess æ‰“开连接时要把整个库镜像进 HSQLDB(表多时极慢),
 * è¿™é‡Œåªæ‰“开文件、顺序扫描一张表、按需取列,完全不经过 SQL å¼•擎。
 * <p>
 * ä»£ä»·ä¸Žé™åˆ¶ï¼š
 * <ul>
 * <li>没有 SQL:按字段过滤、按字段排序都在 Java ä¾§å®Œæˆï¼Œè¿‡æ»¤å¿…然是整表扫描——本库文本索引的排序规则是
 * ä¸­æ–‡ 2052,jackcess ä¸æ”¯æŒï¼ˆç´¢å¼•只读),索引游标会直接抛异常,所以没有可用的索引查询路径;</li>
 * <li>结果全部装在内存里,未加过滤条件的超大表要留意内存占用;</li>
 * <li>设置了打开密码的库会自动用 jackcess-encrypt æ‰“开,密码取请求参数 {@code password}
 * ï¼ˆAccess çš„æ–‡ä»¶åŠ å¯†åªæœ‰â€œæ‰“å¼€å¯†ç â€ï¼Œ{@code userName} ä¸å‚与;.mdw å·¥ä½œç»„级安全性 jackcess ä¸æ”¯æŒï¼‰ï¼›</li>
 * <li>Access ä¸­æŒ‡å‘外部库的“链接表”行为与 UCanAccess çš„ remap å‚数不同,遇到问题可切回
 * {@code mes.access.read-mode=ucanaccess}。</li>
 * </ul>
 */
@Slf4j
public final class JackcessTableReader {
    private JackcessTableReader() {
    }
    public static List<Map<String, Object>> read(DatabaseDTO databaseDTO) {
        File file = new File(StringUtils.trimToEmpty(databaseDTO.getFilePath()));
        if (!file.isFile()) {
            throw new RuntimeException("Access数据库文件不存在:" + databaseDTO.getFilePath());
        }
        if (StringUtils.isBlank(databaseDTO.getTableName())) {
            throw new RuntimeException("Access数据库表名不能为空");
        }
        // setReadOnly(true):只读打开文件,不产生锁文件,不会干扰 MES ç­‰å…¶ä»–程序写入
        // setAutoSync(false):刷盘策略只在写库时生效,只读场景保持关闭
        DatabaseBuilder builder = new DatabaseBuilder(file)
                .setReadOnly(true)
                .setAutoSync(false);
        // æœ‰å¯†ç å°±ç”¨ jackcess-encrypt æ‰“开:Access çš„æ–‡ä»¶åŠ å¯†åªæœ‰â€œæ‰“å¼€å¯†ç â€ï¼Œç”¨æˆ·åä¸å‚ä¸Ž
        String password = StringUtils.trimToNull(databaseDTO.getPassword());
        if (password != null) {
            builder.setCodecProvider(new CryptCodecProvider(password));
        }
        try (Database database = builder.open()) {
            Table table = findTable(database, databaseDTO.getTableName());
            List<Column> columns = resolveColumns(table, databaseDTO.getPointColumns());
            // è¿‡æ»¤å­—段:盘号
            Column filterColumn = null;
            String batchCode = null;
            if (StringUtils.isNotBlank(databaseDTO.getMainColumn()) && StringUtils.isNotBlank(databaseDTO.getBatchCode())) {
                filterColumn = requireColumn(table, databaseDTO.getMainColumn(), "字段");
                batchCode = databaseDTO.getBatchCode().trim();
            }
            // æŽ’序字段
            Column orderColumn = null;
            if (StringUtils.isNotBlank(databaseDTO.getOrderColumn())) {
                orderColumn = requireColumn(table, databaseDTO.getOrderColumn(), "排序字段");
            }
            // æŠ•影:只把需要的列读出来(未请求的列、尤其是 OLE/MEMO è¿™ç±»é•¿å€¼åˆ—不会被读取)
            Set<String> projection = new LinkedHashSet<>();
            for (Column column : columns) {
                if (!isOle(column)) {
                    projection.add(column.getName());
                }
            }
            if (filterColumn != null) {
                projection.add(filterColumn.getName());
            }
            if (orderColumn != null) {
                projection.add(orderColumn.getName());
            }
            long start = System.currentTimeMillis();
            int scanned = 0;
            List<RowData> rows = new ArrayList<>();
            // CursorBuilder.createCursor(Table) æ˜¯â€œæ— ç´¢å¼•”的顺序扫描游标,不依赖任何索引
            Cursor cursor = CursorBuilder.createCursor(table);
            while (cursor.moveToNextRow()) {
                scanned++;
                Row row = cursor.getCurrentRow(projection);
                if (filterColumn != null && !valueMatches(row.get(filterColumn.getName()), batchCode)) {
                    continue;
                }
                Map<String, Object> data = new HashMap<>();
                for (Column column : columns) {
                    // OLE å­—段与原来一样返回 null,且不读长值
                    data.put(column.getName(), isOle(column) ? null : normalize(row.get(column.getName())));
                }
                rows.add(new RowData(data, (orderColumn == null) ? null : row.get(orderColumn.getName())));
            }
            // æŽ’序(Access å‡åºæ—¶ null åœ¨å‰ï¼Œé™åºæ—¶æŒ‰ç›¸åé¡ºåºï¼‰
            if (orderColumn != null) {
                boolean desc = "DESC".equalsIgnoreCase(databaseDTO.getOrderRule());
                Comparator<RowData> comparator = (o1, o2) -> compareValues(o1.sortKey, o2.sortKey);
                rows.sort(desc ? comparator.reversed() : comparator);
            }
            List<Map<String, Object>> list = new ArrayList<>(rows.size());
            for (RowData row : rows) {
                list.add(row.data);
            }
            log.info("jackcess ç›´è¯»å®Œæˆï¼šè¡¨ {},扫描 {} è¡Œã€è¿”回 {} è¡Œï¼Œè€—æ—¶ {} ms",
                    table.getName(), scanned, list.size(), System.currentTimeMillis() - start);
            return list;
        } catch (InvalidCredentialsException e) {
            // jackcess-encrypt æ ¡éªŒæ‰“开密码失败时抛这个(IllegalStateException å­ç±»ï¼‰
            throw new RuntimeException("Access æ–‡ä»¶å¯†ç ä¸æ­£ç¡®ï¼š" + file.getName(), e);
        } catch (InvalidCryptoConfigurationException e) {
            throw new RuntimeException("Access æ–‡ä»¶çš„加密方式不受支持:" + e.getMessage(), e);
        } catch (IOException e) {
            // .mdb(Jet åŠ å¯†ï¼‰æ²¡æœ‰å¯†ç æ ¡éªŒä½ï¼Œå¯†ç ä¸å¯¹é€šå¸¸è¡¨çŽ°ä¸ºé¡µé¢/记录读取异常,这里补一句提示
            String hint = (password == null) ? "" : "(该文件设置了打开密码,请确认密码是否正确)";
            throw new RuntimeException("Access数据库读取异常:" + e.getMessage() + hint, e);
        }
    }
    /**
     * è¡¨ååŒ¹é…ï¼šå…ˆæŒ‰åå­—取,取不到再做一次忽略大小写的匹配(Access çš„表名不区分大小写)
     */
    private static Table findTable(Database database, String tableName) throws IOException {
        String name = cleanName(tableName);
        Table table = database.getTable(name);
        if (table == null) {
            for (String candidate : database.getTableNames()) {
                if (candidate.equalsIgnoreCase(name)) {
                    table = database.getTable(candidate);
                    break;
                }
            }
        }
        if (table == null) {
            throw new RuntimeException("Access数据库中没有找到表:" + tableName);
        }
        return table;
    }
    /**
     * è§£æžé‡‡é›†ç‚¹ä½å­—段,支持逗号分隔、*、以及 [字段名] / `字段名` è¿™ç§å¸¦è½¬ä¹‰çš„写法
     */
    private static List<Column> resolveColumns(Table table, String pointColumns) {
        List<Column> columns = new ArrayList<>();
        if ("*".equals(StringUtils.trimToEmpty(pointColumns))) {
            columns.addAll(table.getColumns());
            return columns;
        }
        if (StringUtils.isBlank(pointColumns)) {
            throw new RuntimeException("采集点位字段不能为空");
        }
        for (String item : pointColumns.split(",")) {
            String name = cleanName(item);
            if (name.isEmpty()) {
                continue;
            }
            columns.add(requireColumn(table, name, "字段"));
        }
        if (columns.isEmpty()) {
            throw new RuntimeException("采集点位字段不能为空");
        }
        return columns;
    }
    private static Column requireColumn(Table table, String columnName, String what) {
        String name = cleanName(columnName);
        for (Column column : table.getColumns()) {
            if (column.getName().equalsIgnoreCase(name)) {
                return column;
            }
        }
        throw new RuntimeException("表 " + table.getName() + " ä¸­æ²¡æœ‰æ‰¾åˆ°" + what + ":" + columnName);
    }
    private static String cleanName(String name) {
        String result = StringUtils.trimToEmpty(name);
        if (result.length() > 1
                && ((result.charAt(0) == '[' && result.endsWith("]"))
                || (result.charAt(0) == '`' && result.endsWith("`")))) {
            result = result.substring(1, result.length() - 1).trim();
        }
        return result;
    }
    private static boolean isOle(Column column) {
        return column.getType() == DataType.OLE;
    }
    /**
     * å€¼æ¯”较:文本按“去空格 + å¿½ç•¥å¤§å°å†™â€ï¼Œä¸ŽåŽŸæ¥ UCanAccess è¿žæŽ¥ï¼ˆTRIM(字段) = '值'、ignoreCase é»˜è®¤ true)保持一致
     */
    private static boolean valueMatches(Object columnValue, String expected) {
        if (columnValue == null || expected == null) {
            return false;
        }
        if (columnValue instanceof CharSequence) {
            return columnValue.toString().trim().equalsIgnoreCase(expected);
        }
        return String.valueOf(columnValue).trim().equals(expected);
    }
    /**
     * ç±»åž‹å½’一:jackcess é»˜è®¤è¿”回 java.util.Date,这里统一转成 java.sql.Timestamp,
     * ä¿æŒä¸ŽåŽŸæ¥ JDBC è·¯å¾„返回的时间类型一致(JSON é‡ŒåŒæ ·æ˜¯ "yyyy-MM-dd HH:mm:ss.SSS")
     */
    private static Object normalize(Object value) {
        if (value instanceof Date) {
            return new Timestamp(((Date) value).getTime());
        }
        if (value instanceof LocalDateTime) {
            return Timestamp.valueOf((LocalDateTime) value);
        }
        return value;
    }
    @SuppressWarnings({"unchecked", "rawtypes"})
    private static int compareValues(Object value1, Object value2) {
        if (value1 == null && value2 == null) {
            return 0;
        }
        if (value1 == null) {
            return -1;
        }
        if (value2 == null) {
            return 1;
        }
        if (value1 instanceof Number && value2 instanceof Number) {
            if (value1 instanceof Double || value2 instanceof Double
                    || value1 instanceof Float || value2 instanceof Float) {
                return Double.compare(((Number) value1).doubleValue(), ((Number) value2).doubleValue());
            }
            return Long.compare(((Number) value1).longValue(), ((Number) value2).longValue());
        }
        if (value1 instanceof Date && value2 instanceof Date) {
            return Long.compare(((Date) value1).getTime(), ((Date) value2).getTime());
        }
        if (value1.getClass() == value2.getClass() && value1 instanceof Comparable) {
            return ((Comparable) value1).compareTo(value2);
        }
        return String.valueOf(value1).compareTo(String.valueOf(value2));
    }
    /**
     * ä¸€è¡Œæ•°æ® + æŽ’序键(排序字段可能不在返回结果里,所以单独存一份)
     */
    private static final class RowData {
        private final Map<String, Object> data;
        private final Object sortKey;
        private RowData(Map<String, Object> data, Object sortKey) {
            this.data = data;
            this.sortKey = sortKey;
        }
    }
}