Kaynağa Gözat

Merge branch 'master' of https://git.steerinfo.com/JQGT-JLXT/jg_wzjl_api

Redeem 2 gün önce
ebeveyn
işleme
8e086e93cf

+ 160 - 0
db/sql/meter_work_monitor.sql

@@ -0,0 +1,160 @@
+CREATE TABLE "LIMS"."METER_WORK_MONITOR" 
+   (	"POINT_NO" VARCHAR2(20) NOT NULL ENABLE, 
+	"POINT_NAME" VARCHAR2(40), 
+	"VALID_WGT" VARCHAR2(1) DEFAULT 1, 
+	"VALID_CARNO" VARCHAR2(1) DEFAULT 1, 
+	"VALID_TARE_TIME" VARCHAR2(1) DEFAULT 1, 
+	"VALID_TIME_INTERVAL" VARCHAR2(1) DEFAULT 1, 
+	"VALID_PARK_STATUS" VARCHAR2(1) DEFAULT 1, 
+	"VALID_EXCEED_WGT" VARCHAR2(1) DEFAULT 1, 
+	"VALID_PREDICTION_DIFF" VARCHAR2(1) DEFAULT 1, 
+	"VALID_FLAG" VARCHAR2(1) DEFAULT 1, 
+	"VALID_MAT_TIME" VARCHAR2(1) DEFAULT 1, 
+	"CAR_MONITOR" VARCHAR2(1) DEFAULT 0, 
+	"CAR_MONITOR_LVL" NUMBER DEFAULT 0, 
+	"IS_HELP" VARCHAR2(1) DEFAULT 0, 
+	"IS_TAKE_OVER" VARCHAR2(1) DEFAULT 0, 
+	"LED_WRITER" VARCHAR2(200), 
+	"PRINT_NUM" NUMBER(4,0) DEFAULT 0, 
+	"COLLECTION_CODE" VARCHAR2(20), 
+	"IS_OVER_WGT" VARCHAR2(40) DEFAULT 0, 
+	"USER_NO" VARCHAR2(20), 
+	"MONITOR_USER_NO" VARCHAR2(20), 
+	"SET_CAR" VARCHAR2(20), 
+	"LED_KEEP" VARCHAR2(1) DEFAULT 0, 
+	"RFID_CAR" VARCHAR2(20), 
+	"PHOTO_CAR" VARCHAR2(20), 
+	"SCAN_CAR" VARCHAR2(20), 
+	"EDIT_CAR" VARCHAR2(20), 
+	"CAR_ERR" VARCHAR2(1) DEFAULT 0, 
+	"WGT_ERR" VARCHAR2(1) DEFAULT 0, 
+	"LAST_TIME" DATE, 
+	"STOP_OVER_TIME" VARCHAR2(1), 
+	"MSG_INFO" VARCHAR2(400), 
+	"WGT" NUMBER(10,0) DEFAULT 0, 
+	"IS_REMOTE" VARCHAR2(1), 
+	"WEIGH_INSTRUMENT_MODEL" VARCHAR2(60), 
+	"WEIGH_INSTRUMENT_PARAM" VARCHAR2(100), 
+	"MOXA_IP" VARCHAR2(20), 
+	"MOXA_PORT" VARCHAR2(10), 
+	"VALID_LIANDA" VARCHAR2(1) DEFAULT 1, 
+	"IS_OPEN_SCANNING" VARCHAR2(1) DEFAULT 0, 
+	"VALID_IS_PRE" VARCHAR2(1) DEFAULT 0, 
+	"USER_IP" VARCHAR2(30), 
+	"POWER_STATE" VARCHAR2(1) DEFAULT 0, 
+	"DVR_STATE" VARCHAR2(1) DEFAULT 0, 
+	"MOXA_STATE" VARCHAR2(1) DEFAULT 0, 
+	"PLC_STATE" VARCHAR2(1) DEFAULT 0, 
+	"LEFT_INFRARED_RAY_STATE" VARCHAR2(1) DEFAULT 0, 
+	"RIGHT_INFRARED_RAY_STATE" VARCHAR2(1) DEFAULT 0, 
+	"AMPLIFIER_STATE" VARCHAR2(1) DEFAULT 0, 
+	"LAMP_STATE" VARCHAR2(1) DEFAULT 0, 
+	"SPOT_TYPE_NO" VARCHAR2(20) DEFAULT 0, 
+	"RED_GREEN_LIGHT_STATE" VARCHAR2(1) DEFAULT 0, 
+	"FRONT_INFRARED_RAY_STATE" VARCHAR2(1) DEFAULT 0, 
+	"REAR_INFRARED_RAY_STATE" VARCHAR2(1) DEFAULT 0, 
+	"RFID_STATE" VARCHAR2(1) DEFAULT 0, 
+	"VALID_AREA" VARCHAR2(1) DEFAULT 1, 
+	"WEIGHT_STATUS" VARCHAR2(2) DEFAULT 0, 
+	"FERROALLOY_CONFIRM" VARCHAR2(1) DEFAULT 0, 
+	"FERROALLOY_RESULT" VARCHAR2(200), 
+	"ZERO_STATE" VARCHAR2(1) DEFAULT 0, 
+	"ZERO_WEIGHT" NUMBER, 
+	"IS_ZERO_STATE" VARCHAR2(1) DEFAULT 1, 
+	"IS_VALID_SCAN" VARCHAR2(1) DEFAULT 1, 
+	"IS_ENABLE_SOUND" VARCHAR2(1) DEFAULT 1, 
+	"PHOTO_CAR2" VARCHAR2(20), 
+	 CONSTRAINT "MONITOR_PK" PRIMARY KEY ("POINT_NO")
+  USING INDEX PCTFREE 10 INITRANS 2 MAXTRANS 255 COMPUTE STATISTICS 
+  STORAGE(INITIAL 65536 NEXT 1048576 MINEXTENTS 1 MAXEXTENTS 2147483645
+  PCTINCREASE 0 FREELISTS 1 FREELIST GROUPS 1
+  BUFFER_POOL DEFAULT FLASH_CACHE DEFAULT CELL_FLASH_CACHE DEFAULT)
+  TABLESPACE "IMS"  ENABLE, 
+	 CONSTRAINT "SYS_C009706" CHECK ("POINT_NO" IS NOT NULL) ENABLE
+   ) SEGMENT CREATION IMMEDIATE 
+  PCTFREE 10 PCTUSED 40 INITRANS 1 MAXTRANS 255 
+ NOCOMPRESS LOGGING
+  STORAGE(INITIAL 65536 NEXT 1048576 MINEXTENTS 1 MAXEXTENTS 2147483645
+  PCTINCREASE 0 FREELISTS 1 FREELIST GROUPS 1
+  BUFFER_POOL DEFAULT FLASH_CACHE DEFAULT CELL_FLASH_CACHE DEFAULT)
+  TABLESPACE "IMS"   NO INMEMORY ;
+  CREATE UNIQUE INDEX "LIMS"."MONITOR_PK" ON "LIMS"."METER_WORK_MONITOR" ("POINT_NO") 
+  PCTFREE 10 INITRANS 2 MAXTRANS 255 COMPUTE STATISTICS 
+  STORAGE(INITIAL 65536 NEXT 1048576 MINEXTENTS 1 MAXEXTENTS 2147483645
+  PCTINCREASE 0 FREELISTS 1 FREELIST GROUPS 1
+  BUFFER_POOL DEFAULT FLASH_CACHE DEFAULT CELL_FLASH_CACHE DEFAULT)
+  TABLESPACE "IMS" ;
+  ALTER TABLE "LIMS"."METER_WORK_MONITOR" MODIFY ("POINT_NO" NOT NULL ENABLE);
+  ALTER TABLE "LIMS"."METER_WORK_MONITOR" ADD CONSTRAINT "MONITOR_PK" PRIMARY KEY ("POINT_NO")
+  USING INDEX PCTFREE 10 INITRANS 2 MAXTRANS 255 COMPUTE STATISTICS 
+  STORAGE(INITIAL 65536 NEXT 1048576 MINEXTENTS 1 MAXEXTENTS 2147483645
+  PCTINCREASE 0 FREELISTS 1 FREELIST GROUPS 1
+  BUFFER_POOL DEFAULT FLASH_CACHE DEFAULT CELL_FLASH_CACHE DEFAULT)
+  TABLESPACE "IMS"  ENABLE;
+  ALTER TABLE "LIMS"."METER_WORK_MONITOR" ADD CONSTRAINT "SYS_C009706" CHECK ("POINT_NO" IS NOT NULL) ENABLE;
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."POINT_NO" IS '计量点编号';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."POINT_NAME" IS '计量点';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."VALID_WGT" IS '验证重量  0不验证  1验证';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."VALID_CARNO" IS '验证车号  0不验证  1验证';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."VALID_TARE_TIME" IS '验证回皮时间  0不验证  1验证';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."VALID_TIME_INTERVAL" IS '验证时间间隔  0不验证  1验证';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."VALID_PARK_STATUS" IS '验证对射(实时数据库停车位置)  0不验证  1验证';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."VALID_EXCEED_WGT" IS '验证危化品超重  0不验证  1验证';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."VALID_PREDICTION_DIFF" IS '验证毛皮超差  0不验证  1验证';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."VALID_FLAG" IS '是否有效 1有效 0无效';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."VALID_MAT_TIME" IS '验证物料可计量时间段 0不验证  1验证';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."CAR_MONITOR" IS '是否进行车号修正 0不修正 1修正';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."CAR_MONITOR_LVL" IS '车号修正级别1-3(1=修正1位;2=修正连续的2位;3=修正连续的3位)';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."IS_HELP" IS '0未呼叫,1呼叫待接听,2呼叫已接听';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."IS_TAKE_OVER" IS '0智能终端,1远程计量呼叫(派位阶段),2远程接管';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."LED_WRITER" IS '写入LED的值';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."PRINT_NUM" IS '打印纸张';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."COLLECTION_CODE" IS '与实时库对应的采集点';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."IS_OVER_WGT" IS '有值时为计量完成';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."USER_NO" IS '接管终端用户';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."MONITOR_USER_NO" IS '监控用户';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."SET_CAR" IS '重置车号';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."LED_KEEP" IS '0不读取LED_WRITER的值,1读取';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."RFID_CAR" IS 'rfid车号';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."PHOTO_CAR" IS '抓拍车号';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."SCAN_CAR" IS '扫描车号';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."EDIT_CAR" IS '修正车号';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."CAR_ERR" IS '车号异常 0正常 1异常';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."WGT_ERR" IS '重量异常 0正常 1异常';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."LAST_TIME" IS '最后在线时间,由于时间不一定准,所以使用旧数据时间与当前时间比如果连续30次相同则认为掉线';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."STOP_OVER_TIME" IS '0正常 1停留超时';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."MSG_INFO" IS '现场终端提示信息';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."WGT" IS '现场显示的重量';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."IS_REMOTE" IS '是否是监控界面点击的远程 0否 1是';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."WEIGH_INSTRUMENT_MODEL" IS '称重仪表型号';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."WEIGH_INSTRUMENT_PARAM" IS '称重仪表参数';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."MOXA_IP" IS 'MOXA卡IP';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."MOXA_PORT" IS 'MOXA卡端口';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."VALID_LIANDA" IS '验证联达  0不验证  1验证';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."IS_OPEN_SCANNING" IS '现场扫码器是否正常连接(0=连接异常,1=连接正常)';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."VALID_IS_PRE" IS '委托异常  0正常  1异常';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."USER_IP" IS '接管用户IP';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."POWER_STATE" IS '衡器电源状态(0正常 1异常)';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."DVR_STATE" IS '硬盘录像机状态(0正常 1异常)';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."MOXA_STATE" IS 'MOXA状态(0正常 1异常)';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."PLC_STATE" IS 'PLC状态(0正常 1异常)';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."LEFT_INFRARED_RAY_STATE" IS '红外对射状态(0正常 1异常)';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."RIGHT_INFRARED_RAY_STATE" IS '红外对射状态(0正常 1异常)';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."AMPLIFIER_STATE" IS '功放状态(0正常 1异常)';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."LAMP_STATE" IS '照明灯状态(0关闭的 1打开的)';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."SPOT_TYPE_NO" IS '站点类型';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."RED_GREEN_LIGHT_STATE" IS '红绿灯状态(0绿灯 1红灯)';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."FRONT_INFRARED_RAY_STATE" IS '前红外光栅状态(0正常 1异常)';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."REAR_INFRARED_RAY_STATE" IS '后红外光栅状态(0正常 1异常)';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."RFID_STATE" IS 'rfid状态(0正常 1异常)';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."VALID_AREA" IS '允许异地结净     0不验证  1验证';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."WEIGHT_STATUS" IS '重量是否稳定     0不稳定    1稳定';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."FERROALLOY_CONFIRM" IS '铁合金确认       0未确认  1已确认';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."FERROALLOY_RESULT" IS '铁合金确认结果 0保存失败 1保存成功';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."ZERO_STATE" IS '零点报警         0不报警  1报警';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."ZERO_WEIGHT" IS '零点重量';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."IS_ZERO_STATE" IS '验证零点报警    0:不验证   1:验证';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."IS_VALID_SCAN" IS '允许扫码计量(0:允许;1:不允许,数据库默认为1)';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."IS_ENABLE_SOUND" IS '是否播放声音(0:不播放;1:播放)';
+   COMMENT ON COLUMN "LIMS"."METER_WORK_MONITOR"."PHOTO_CAR2" IS '抓拍车号2';
+   COMMENT ON TABLE "LIMS"."METER_WORK_MONITOR"  IS '计量监控表';

+ 70 - 0
docs/spec/monitor/01-tarantool-schema.md

@@ -0,0 +1,70 @@
+# Tarantool Space:METER_WORK_MONITOR
+
+## 引擎与主键
+
+- Engine:`memtx`
+- Space 名:`METER_WORK_MONITOR`
+- PK:`POINT_NO`(format 第 1 字段,string)
+- 字段顺序与 Oracle DDL [`db/sql/meter_work_monitor.sql`](../../../db/sql/meter_work_monitor.sql) **严格一致**(共 66 列业务字段)
+
+## 类型映射
+
+| Oracle | Tarantool | Java |
+|--------|-----------|------|
+| VARCHAR2 | string | String |
+| NUMBER / NUMBER(n) | number | Short / Long |
+| DATE (`LAST_TIME`) | number(毫秒 epoch) | Date |
+
+## 辅助字段(不落 Oracle)
+
+| 字段 | 位置 | 类型 | 说明 |
+|------|------|------|------|
+| `_dirty` | 67 | boolean | Job 增量同步标记 |
+| `_updated_at` | 68 | number | 最近写入毫秒时间戳 |
+
+## 字段序号(1-based,供 update ops)
+
+| # | 列名 | # | 列名 |
+|---|------|---|------|
+| 1 | POINT_NO | 34 | IS_REMOTE |
+| 2 | POINT_NAME | 35 | WEIGH_INSTRUMENT_MODEL |
+| 3 | VALID_WGT | 36 | WEIGH_INSTRUMENT_PARAM |
+| 4 | VALID_CARNO | 37 | MOXA_IP |
+| 5 | VALID_TARE_TIME | 38 | MOXA_PORT |
+| 6 | VALID_TIME_INTERVAL | 39 | VALID_LIANDA |
+| 7 | VALID_PARK_STATUS | 40 | IS_OPEN_SCANNING |
+| 8 | VALID_EXCEED_WGT | 41 | VALID_IS_PRE |
+| 9 | VALID_PREDICTION_DIFF | 42 | USER_IP |
+| 10 | VALID_FLAG | 43 | POWER_STATE |
+| 11 | VALID_MAT_TIME | 44 | DVR_STATE |
+| 12 | CAR_MONITOR | 45 | MOXA_STATE |
+| 13 | CAR_MONITOR_LVL | 46 | PLC_STATE |
+| 14 | IS_HELP | 47 | LEFT_INFRARED_RAY_STATE |
+| 15 | IS_TAKE_OVER | 48 | RIGHT_INFRARED_RAY_STATE |
+| 16 | LED_WRITER | 49 | AMPLIFIER_STATE |
+| 17 | PRINT_NUM | 50 | LAMP_STATE |
+| 18 | COLLECTION_CODE | 51 | SPOT_TYPE_NO |
+| 19 | IS_OVER_WGT | 52 | RED_GREEN_LIGHT_STATE |
+| 20 | USER_NO | 53 | FRONT_INFRARED_RAY_STATE |
+| 21 | MONITOR_USER_NO | 54 | REAR_INFRARED_RAY_STATE |
+| 22 | SET_CAR | 55 | RFID_STATE |
+| 23 | LED_KEEP | 56 | VALID_AREA |
+| 24 | RFID_CAR | 57 | WEIGHT_STATUS |
+| 25 | PHOTO_CAR | 58 | FERROALLOY_CONFIRM |
+| 26 | SCAN_CAR | 59 | FERROALLOY_RESULT |
+| 27 | EDIT_CAR | 60 | ZERO_STATE |
+| 28 | CAR_ERR | 61 | ZERO_WEIGHT |
+| 29 | WGT_ERR | 62 | IS_ZERO_STATE |
+| 30 | LAST_TIME | 63 | IS_VALID_SCAN |
+| 31 | STOP_OVER_TIME | 64 | IS_ENABLE_SOUND |
+| 32 | MSG_INFO | 65 | PHOTO_CAR2 |
+| 33 | WGT | 66 | (业务字段结束) |
+| 67 | \_dirty | 68 | \_updated_at |
+
+## 冷启动
+
+1. 在 Tarantool 执行 [`ddl/meter_work_monitor.lua`](ddl/meter_work_monitor.lua)。
+2. 应用配置 `rtdb.monitor.bootstrap-enabled=true`,启动时从 Oracle `selectByParameters` 全量 `replace` 灌入,业务字段完整,`_dirty=false`。
+3. 灌数完成后建议关闭 bootstrap,避免每次重启覆盖内存中未回写数据(生产应先确认 Job 已刷盘)。
+
+脚本:见 `ddl/meter_work_monitor.lua`。

+ 52 - 0
docs/spec/monitor/02-rtdb-connection-pool.md

@@ -0,0 +1,52 @@
+# RTDB 连接池(基于 icore-rtdb)
+
+## 现状
+
+`icore-rtdb`(`com.steerinfo.rtdb`)**无内置连接池**:
+
+- `ClientImpl`:单 TCP socket + 请求多路复用
+- 现网 [`TestClient2`](../../../src/main/java/com/steerinfo/util/TestClient2.java) 使用单例 `DbConstants.client`
+
+高并发写入场景下,单连接仍可能成为瓶颈或故障单点,因此自建 **多 Client 池**。
+
+## 设计
+
+类包:`com.steerinfo.meterwork.rtdb`
+
+| 类 | 职责 |
+|----|------|
+| `RtdbPoolProperties` | 配置绑定 |
+| `RtdbClientPool` | 固定大小 `ArrayBlockingQueue<Client>`,borrow / release,ping 失败重建 |
+| `RtdbClientConfiguration` | Spring Bean,`destroyMethod` 关闭全部 Client |
+
+### 借用 / 归还
+
+```
+borrow(timeout) → Client
+  try: 业务 call / replace
+  catch: invalidate(client)  // close + 新建放回
+  finally: release(client)   // 正常归还
+```
+
+### API 用法(与现网一致)
+
+```java
+client.syncOps().call("db.space.METER_WORK_MONITOR:replace", tuple);
+client.syncOps().call("db.space.METER_WORK_MONITOR:get", pointNo);
+client.syncOps().call("db.space.METER_WORK_MONITOR:update", key, ops);
+client.syncOps().call("db.space.METER_WORK_MONITOR:select"); // 全表 / dirty 扫描
+```
+
+## 配置项
+
+| Key | 默认 | 说明 |
+|-----|------|------|
+| `rtdb.host` | `172.22.42.3` | Tarantool 主机 |
+| `rtdb.port` | `2101` | 端口 |
+| `rtdb.username` | `guest` | 用户 |
+| `rtdb.password` | 空 | 密码 |
+| `rtdb.pool.size` | `8` | 池内 Client 数 |
+| `rtdb.pool.borrow-timeout-ms` | `3000` | 借用超时 |
+| `rtdb.monitor.enabled` | `true` | 是否启用 Monitor RTDB 写入 |
+
+Monitor 写入走本池;`DbConstants.client` 仍供其它读路径使用。

+ 44 - 0
docs/spec/monitor/03-write-path.md

@@ -0,0 +1,44 @@
+# 写入路径:doUpdateWf / updateLastTime
+
+## 改写范围
+
+| HTTP | Controller 方法 | Service | 行为 |
+|------|-----------------|---------|------|
+| `POST .../doUpdateWf` | `update` | `doUpdateWf` | 选择性字段合并后 `replace`,标记 dirty |
+| `POST .../doUpdateWfBySpotNo` | `updateLastTime` | `updateLastTime` | 更新 `LAST_TIME` + dirty,**不查 Oracle** |
+
+`PUT /{id}` → `modify` **不改**。
+
+## doUpdateWf
+
+1. 校验 `pointNo`。
+2. `get` Tarantool 现有行;若不存在且开启 fallback,可从 Oracle 加载后写入。
+3. 将请求中非空字段合并到现有模型。
+4. `replace` 全行(含 `_dirty=true`, `_updated_at=now`)。
+5. 成功返回 `pointNo`;失败返回 `""`(避免 Controller `isEmpty()` NPE)。
+
+## updateLastTime
+
+1. 校验 `pointNo`。
+2. 优先 `update` 字段 30=`LAST_TIME`(epoch ms)、67=`_dirty`、68=`_updated_at`。
+3. 若行不存在:尝试 Oracle 读入灌一条再 update,或 fallback Oracle。
+4. 成功返回 `pointNo`;失败返回 `""`。
+
+## 编解码
+
+- 由 `MeterWorkMonitorTupleCodec` 负责 `MeterWorkMonitor` ↔ `List<?>`(68 元组)。
+- `LAST_TIME`:`Date.getTime()` ↔ Long。
+- null 字符串在 tuple 中写空串或 box.NULL 策略:业务 string 字段用 `""` / number 用 `0` / date 用 `null`(box.NULL)。
+
+## 失败策略
+
+| 配置 | 行为 |
+|------|------|
+| `rtdb.monitor.fallback-oracle=false`(默认) | 仅写 Tarantool;失败打日志并返回 `""` |
+| `rtdb.monitor.fallback-oracle=true` | Tarantool 失败时回退 `mapper.updateByPrimaryKeySelective` |
+
+实现类:
+
+- `com.steerinfo.meterwork.meterworkmonitor.rtdb.MeterWorkMonitorTupleCodec`
+- `com.steerinfo.meterwork.meterworkmonitor.rtdb.MeterWorkMonitorRtdbRepository`
+- `MeterWorkMonitorServiceImpl`(注入 Repository)

+ 31 - 0
docs/spec/monitor/04-oracle-sync-job.md

@@ -0,0 +1,31 @@
+# Tarantool → Oracle 同步 Job
+
+## 开关与 Cron
+
+| Key | 默认 | 说明 |
+|-----|------|------|
+| `meter.monitor.sync.enabled` | `false` | 显式开启才调度 |
+| `meter.monitor.sync.cron` | `0 */1 * * * ?` | 每分钟 |
+
+对齐 [`StatusSyncScheduler`](../../../src/main/java/com/steerinfo/abnormalHandler/scheduler/StatusSyncScheduler.java) 模式。
+
+## 流程
+
+1. 单飞锁(`AtomicBoolean`),重叠则跳过。
+2. 从 Tarantool 扫描 `_dirty == true` 的行(`select` 后过滤或专用 index)。
+3. 解码为 `List<MeterWorkMonitor>`。
+4. 逐条调用 `meterWorkMonitorMapper.updateByPrimaryKeySelective`。
+5. 成功行:`update` 清 `_dirty=false`。
+6. 记录成功/失败条数日志。
+
+## 类
+
+| 类 | 包 |
+|----|-----|
+| `MeterWorkMonitorSyncScheduler` | `...meterworkmonitor.scheduler` |
+| `MeterWorkMonitorSyncService` | `...meterworkmonitor.service`(或同包 service 子包) |
+
+## 监控建议
+
+- 每次同步耗时、dirty 数量、Oracle 失败条数。
+- dirty 长期堆积时检查 Oracle 连通性或 batch SQL。

+ 57 - 0
docs/spec/monitor/README.md

@@ -0,0 +1,57 @@
+# METER_WORK_MONITOR Tarantool 内存加速方案
+
+## 背景
+
+`METER_WORK_MONITOR` 为计量监控热点表。`POST /doUpdateWf` 与 `POST /doUpdateWfBySpotNo`(`updateLastTime`)写入频率高,直接打 Oracle 易形成锁竞争与连接压力。
+
+## 目标
+
+1. 在 Tarantool(memtx)建立与 Oracle 同结构的内存表 `METER_WORK_MONITOR`。
+2. 基于 `icore-rtdb` 自建多 `Client` 连接池,支撑高并发写入。
+3. 上述两个接口只写 Tarantool,立即返回。
+4. 定时 Job 将脏数据回写 Oracle。
+
+## 架构
+
+```
+秤点/前端 → MeterWorkMonitorController
+         → MeterWorkMonitorServiceImpl
+         → MeterWorkMonitorRtdbRepository
+         → RtdbClientPool → Tarantool METER_WORK_MONITOR
+
+MeterWorkMonitorSyncScheduler → SyncService
+  → 读 Tarantool dirty 行 → batchUpdateSelective → Oracle
+  → 清 dirty
+```
+
+## 文档索引
+
+| 文档 | 说明 |
+|------|------|
+| [01-tarantool-schema.md](01-tarantool-schema.md) | Space 字段、类型、冷启动 |
+| [02-rtdb-connection-pool.md](02-rtdb-connection-pool.md) | 连接池设计与配置 |
+| [03-write-path.md](03-write-path.md) | 写入路径与失败策略 |
+| [04-oracle-sync-job.md](04-oracle-sync-job.md) | 回写 Job |
+| [ddl/meter_work_monitor.lua](ddl/meter_work_monitor.lua) | Tarantool 建表脚本 |
+
+## 一致性与降级
+
+- **一致性**:写路径最终一致;Oracle 落后于 Tarantool 最多一个 Job 周期(默认 1 分钟)。
+- **降级**:`rtdb.monitor.fallback-oracle=true` 时,Tarantool 写失败回退原 Oracle `updateByPrimaryKeySelective`。
+- **范围外**:`PUT /{id}`、`MeterWorkCarActualFirst*` 等其它 Oracle 直写路径不变;`TestClient2` / `DbConstants.client` 暂保留。
+
+## 配置开关(摘要)
+
+```properties
+rtdb.host=172.22.42.3
+rtdb.port=2101
+rtdb.username=guest
+rtdb.password=
+rtdb.pool.size=8
+rtdb.pool.borrow-timeout-ms=3000
+rtdb.monitor.enabled=true
+rtdb.monitor.fallback-oracle=false
+rtdb.monitor.bootstrap-enabled=false
+meter.monitor.sync.enabled=false
+meter.monitor.sync.cron=0 */1 * * * ?
+```

+ 99 - 0
docs/spec/monitor/ddl/meter_work_monitor.lua

@@ -0,0 +1,99 @@
+-- METER_WORK_MONITOR memtx space
+-- Field order matches Oracle LIMS.METER_WORK_MONITOR (66 cols) + _dirty + _updated_at
+-- Run in Tarantool console: dofile('meter_work_monitor.lua')
+
+local SPACE = 'METER_WORK_MONITOR'
+
+if box.space[SPACE] ~= nil then
+    box.space[SPACE]:drop()
+end
+
+local s = box.schema.space.create(SPACE, {
+    engine = 'memtx',
+    if_not_exists = true,
+    format = {
+        { name = 'POINT_NO', type = 'string' },
+        { name = 'POINT_NAME', type = 'string', is_nullable = true },
+        { name = 'VALID_WGT', type = 'string', is_nullable = true },
+        { name = 'VALID_CARNO', type = 'string', is_nullable = true },
+        { name = 'VALID_TARE_TIME', type = 'string', is_nullable = true },
+        { name = 'VALID_TIME_INTERVAL', type = 'string', is_nullable = true },
+        { name = 'VALID_PARK_STATUS', type = 'string', is_nullable = true },
+        { name = 'VALID_EXCEED_WGT', type = 'string', is_nullable = true },
+        { name = 'VALID_PREDICTION_DIFF', type = 'string', is_nullable = true },
+        { name = 'VALID_FLAG', type = 'string', is_nullable = true },
+        { name = 'VALID_MAT_TIME', type = 'string', is_nullable = true },
+        { name = 'CAR_MONITOR', type = 'string', is_nullable = true },
+        { name = 'CAR_MONITOR_LVL', type = 'number', is_nullable = true },
+        { name = 'IS_HELP', type = 'string', is_nullable = true },
+        { name = 'IS_TAKE_OVER', type = 'string', is_nullable = true },
+        { name = 'LED_WRITER', type = 'string', is_nullable = true },
+        { name = 'PRINT_NUM', type = 'number', is_nullable = true },
+        { name = 'COLLECTION_CODE', type = 'string', is_nullable = true },
+        { name = 'IS_OVER_WGT', type = 'string', is_nullable = true },
+        { name = 'USER_NO', type = 'string', is_nullable = true },
+        { name = 'MONITOR_USER_NO', type = 'string', is_nullable = true },
+        { name = 'SET_CAR', type = 'string', is_nullable = true },
+        { name = 'LED_KEEP', type = 'string', is_nullable = true },
+        { name = 'RFID_CAR', type = 'string', is_nullable = true },
+        { name = 'PHOTO_CAR', type = 'string', is_nullable = true },
+        { name = 'SCAN_CAR', type = 'string', is_nullable = true },
+        { name = 'EDIT_CAR', type = 'string', is_nullable = true },
+        { name = 'CAR_ERR', type = 'string', is_nullable = true },
+        { name = 'WGT_ERR', type = 'string', is_nullable = true },
+        { name = 'LAST_TIME', type = 'number', is_nullable = true },
+        { name = 'STOP_OVER_TIME', type = 'string', is_nullable = true },
+        { name = 'MSG_INFO', type = 'string', is_nullable = true },
+        { name = 'WGT', type = 'number', is_nullable = true },
+        { name = 'IS_REMOTE', type = 'string', is_nullable = true },
+        { name = 'WEIGH_INSTRUMENT_MODEL', type = 'string', is_nullable = true },
+        { name = 'WEIGH_INSTRUMENT_PARAM', type = 'string', is_nullable = true },
+        { name = 'MOXA_IP', type = 'string', is_nullable = true },
+        { name = 'MOXA_PORT', type = 'string', is_nullable = true },
+        { name = 'VALID_LIANDA', type = 'string', is_nullable = true },
+        { name = 'IS_OPEN_SCANNING', type = 'string', is_nullable = true },
+        { name = 'VALID_IS_PRE', type = 'string', is_nullable = true },
+        { name = 'USER_IP', type = 'string', is_nullable = true },
+        { name = 'POWER_STATE', type = 'string', is_nullable = true },
+        { name = 'DVR_STATE', type = 'string', is_nullable = true },
+        { name = 'MOXA_STATE', type = 'string', is_nullable = true },
+        { name = 'PLC_STATE', type = 'string', is_nullable = true },
+        { name = 'LEFT_INFRARED_RAY_STATE', type = 'string', is_nullable = true },
+        { name = 'RIGHT_INFRARED_RAY_STATE', type = 'string', is_nullable = true },
+        { name = 'AMPLIFIER_STATE', type = 'string', is_nullable = true },
+        { name = 'LAMP_STATE', type = 'string', is_nullable = true },
+        { name = 'SPOT_TYPE_NO', type = 'string', is_nullable = true },
+        { name = 'RED_GREEN_LIGHT_STATE', type = 'string', is_nullable = true },
+        { name = 'FRONT_INFRARED_RAY_STATE', type = 'string', is_nullable = true },
+        { name = 'REAR_INFRARED_RAY_STATE', type = 'string', is_nullable = true },
+        { name = 'RFID_STATE', type = 'string', is_nullable = true },
+        { name = 'VALID_AREA', type = 'string', is_nullable = true },
+        { name = 'WEIGHT_STATUS', type = 'string', is_nullable = true },
+        { name = 'FERROALLOY_CONFIRM', type = 'string', is_nullable = true },
+        { name = 'FERROALLOY_RESULT', type = 'string', is_nullable = true },
+        { name = 'ZERO_STATE', type = 'string', is_nullable = true },
+        { name = 'ZERO_WEIGHT', type = 'number', is_nullable = true },
+        { name = 'IS_ZERO_STATE', type = 'string', is_nullable = true },
+        { name = 'IS_VALID_SCAN', type = 'string', is_nullable = true },
+        { name = 'IS_ENABLE_SOUND', type = 'string', is_nullable = true },
+        { name = 'PHOTO_CAR2', type = 'string', is_nullable = true },
+        { name = '_dirty', type = 'boolean', is_nullable = true },
+        { name = '_updated_at', type = 'number', is_nullable = true },
+    }
+})
+
+s:create_index('primary', {
+    type = 'hash',
+    parts = { 1, 'string' },
+    if_not_exists = true,
+})
+
+-- optional secondary index for dirty scan
+s:create_index('dirty', {
+    type = 'tree',
+    unique = false,
+    parts = { 67, 'boolean' },
+    if_not_exists = true,
+})
+
+return s

+ 69 - 0
src/main/java/com/steerinfo/meterwork/meterworkmonitor/rtdb/MeterWorkMonitorRtdbBootstrap.java

@@ -0,0 +1,69 @@
+package com.steerinfo.meterwork.meterworkmonitor.rtdb;
+
+import com.steerinfo.meterwork.meterworkmonitor.mapper.MeterWorkMonitorMapper;
+import com.steerinfo.meterwork.meterworkmonitor.model.MeterWorkMonitor;
+import com.steerinfo.meterwork.rtdb.RtdbPoolProperties;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.List;
+
+/**
+ * 冷启动:Oracle → Tarantool 全量灌数(_dirty=false)。
+ */
+@Component
+@Order(100)
+@ConditionalOnBean(MeterWorkMonitorRtdbRepository.class)
+@ConditionalOnProperty(name = "rtdb.monitor.bootstrap-enabled", havingValue = "true", matchIfMissing = false)
+public class MeterWorkMonitorRtdbBootstrap implements ApplicationRunner {
+
+    private static final Logger log = LoggerFactory.getLogger(MeterWorkMonitorRtdbBootstrap.class);
+
+    @Resource
+    private MeterWorkMonitorMapper meterWorkMonitorMapper;
+
+    @Resource
+    private MeterWorkMonitorRtdbRepository meterWorkMonitorRtdbRepository;
+
+    @Resource
+    private RtdbPoolProperties rtdbPoolProperties;
+
+    @Override
+    public void run(ApplicationArguments args) {
+        if (rtdbPoolProperties == null || !rtdbPoolProperties.getMonitor().isBootstrapEnabled()) {
+            return;
+        }
+        long start = System.currentTimeMillis();
+        int ok = 0;
+        int fail = 0;
+        try {
+            List<MeterWorkMonitor> rows = meterWorkMonitorMapper.selectByParameters(new HashMap<String, Object>());
+            log.info("METER_WORK_MONITOR bootstrap start, oracleRows={}", rows == null ? 0 : rows.size());
+            if (rows == null) {
+                return;
+            }
+            for (MeterWorkMonitor row : rows) {
+                try {
+                    meterWorkMonitorRtdbRepository.replace(row, false);
+                    ok++;
+                } catch (Exception e) {
+                    fail++;
+                    log.error("bootstrap replace failed, pointNo={}", row.getPointNo(), e);
+                }
+            }
+        } catch (Exception e) {
+            log.error("METER_WORK_MONITOR bootstrap failed", e);
+        } finally {
+            log.info("METER_WORK_MONITOR bootstrap done, ok={}, fail={}, costMs={}",
+                    ok, fail, System.currentTimeMillis() - start);
+        }
+    }
+}

+ 195 - 0
src/main/java/com/steerinfo/meterwork/meterworkmonitor/rtdb/MeterWorkMonitorRtdbRepository.java

@@ -0,0 +1,195 @@
+package com.steerinfo.meterwork.meterworkmonitor.rtdb;
+
+import com.steerinfo.meterwork.meterworkmonitor.model.MeterWorkMonitor;
+import com.steerinfo.meterwork.rtdb.RtdbClientPool;
+import com.steerinfo.rtdb.Client;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.stereotype.Repository;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * METER_WORK_MONITOR Tarantool 访问封装。
+ */
+@Repository
+@ConditionalOnBean(RtdbClientPool.class)
+public class MeterWorkMonitorRtdbRepository {
+
+    private static final Logger log = LoggerFactory.getLogger(MeterWorkMonitorRtdbRepository.class);
+
+    private static final String SPACE_GET = "db.space.METER_WORK_MONITOR:get";
+    private static final String SPACE_REPLACE = "db.space.METER_WORK_MONITOR:replace";
+    private static final String SPACE_UPDATE = "db.space.METER_WORK_MONITOR:update";
+    private static final String SPACE_SELECT = "db.space.METER_WORK_MONITOR:select";
+    private static final String DIRTY_SELECT = "db.space.METER_WORK_MONITOR.index.dirty:select";
+
+    private final RtdbClientPool pool;
+
+    public MeterWorkMonitorRtdbRepository(RtdbClientPool pool) {
+        this.pool = pool;
+    }
+
+    public MeterWorkMonitor getByPointNo(String pointNo) {
+        return pool.execute(client -> {
+            List<?> raw = callResult(client, SPACE_GET, pointNo);
+            List<?> tuple = unwrapTuple(raw);
+            return MeterWorkMonitorTupleCodec.fromTuple(tuple);
+        });
+    }
+
+    public void replace(MeterWorkMonitor model, boolean dirty) {
+        List<Object> tuple = MeterWorkMonitorTupleCodec.toTuple(model, dirty);
+        pool.execute(client -> {
+            client.syncOps().call(SPACE_REPLACE, tuple);
+            return null;
+        });
+    }
+
+    public void updateLastTime(String pointNo, long lastTimeMs) {
+        List<List<?>> ops = Arrays.asList(
+                Arrays.asList("=", MeterWorkMonitorTupleCodec.IDX_LAST_TIME, lastTimeMs),
+                Arrays.asList("=", MeterWorkMonitorTupleCodec.IDX_DIRTY, true),
+                Arrays.asList("=", MeterWorkMonitorTupleCodec.IDX_UPDATED_AT, System.currentTimeMillis())
+        );
+        pool.execute(client -> {
+            client.syncOps().call(SPACE_UPDATE, pointNo, ops);
+            return null;
+        });
+    }
+
+    public void clearDirty(String pointNo) {
+        List<List<?>> ops = Arrays.asList(
+                Arrays.asList("=", MeterWorkMonitorTupleCodec.IDX_DIRTY, false),
+                Arrays.asList("=", MeterWorkMonitorTupleCodec.IDX_UPDATED_AT, System.currentTimeMillis())
+        );
+        pool.execute(client -> {
+            client.syncOps().call(SPACE_UPDATE, pointNo, ops);
+            return null;
+        });
+    }
+
+    public List<MeterWorkMonitor> selectDirty() {
+        return pool.execute(client -> {
+            List<?> raw;
+            try {
+                raw = callResult(client, DIRTY_SELECT, true);
+            } catch (Exception e) {
+                log.warn("dirty index select failed, fallback full select: {}", e.getMessage());
+                raw = callResult(client, SPACE_SELECT);
+            }
+            List<List<?>> tuples = unwrapTupleList(raw);
+            List<MeterWorkMonitor> result = new ArrayList<>();
+            for (List<?> tuple : tuples) {
+                if (MeterWorkMonitorTupleCodec.isDirty(tuple)) {
+                    MeterWorkMonitor m = MeterWorkMonitorTupleCodec.fromTuple(tuple);
+                    if (m != null && m.getPointNo() != null) {
+                        result.add(m);
+                    }
+                }
+            }
+            return result;
+        });
+    }
+
+    public List<MeterWorkMonitor> selectAll() {
+        return pool.execute(client -> {
+            List<?> raw = callResult(client, SPACE_SELECT);
+            List<List<?>> tuples = unwrapTupleList(raw);
+            List<MeterWorkMonitor> result = new ArrayList<>();
+            for (List<?> tuple : tuples) {
+                MeterWorkMonitor m = MeterWorkMonitorTupleCodec.fromTuple(tuple);
+                if (m != null && m.getPointNo() != null) {
+                    result.add(m);
+                }
+            }
+            return result;
+        });
+    }
+
+    @SuppressWarnings("unchecked")
+    private static List<?> callResult(Client client, String function, Object... args) {
+        Object res = client.syncOps().call(function, args);
+        if (res instanceof List) {
+            return (List<?>) res;
+        }
+        return Collections.emptyList();
+    }
+
+    /**
+     * call 返回通常为 [tuple] 或 [ [tuples...] ],兼容多种包装。
+     */
+    @SuppressWarnings("unchecked")
+    private static List<?> unwrapTuple(List<?> raw) {
+        if (raw == null || raw.isEmpty()) {
+            return null;
+        }
+        Object first = raw.get(0);
+        if (first == null) {
+            return null;
+        }
+        if (first instanceof List) {
+            List<?> inner = (List<?>) first;
+            // get 返回单行:可能是字段 list,也可能再包一层
+            if (!inner.isEmpty() && !(inner.get(0) instanceof List) && inner.size() >= 1) {
+                // 可能是 tuple 本身(首字段为 POINT_NO string)
+                if (inner.get(0) instanceof String || inner.get(0) instanceof Number || inner.get(0) instanceof Boolean) {
+                    return inner;
+                }
+            }
+            if (!inner.isEmpty() && inner.get(0) instanceof List) {
+                return (List<?>) inner.get(0);
+            }
+            return inner;
+        }
+        return null;
+    }
+
+    @SuppressWarnings("unchecked")
+    private static List<List<?>> unwrapTupleList(List<?> raw) {
+        List<List<?>> out = new ArrayList<>();
+        if (raw == null || raw.isEmpty()) {
+            return out;
+        }
+        Object first = raw.get(0);
+        if (!(first instanceof List)) {
+            return out;
+        }
+        List<?> level1 = (List<?>) first;
+        if (level1.isEmpty()) {
+            return out;
+        }
+        // select 返回 [[tuple1],[tuple2],...] 或 [tuple1, tuple2, ...]
+        if (level1.get(0) instanceof List) {
+            Object maybeField = ((List<?>) level1.get(0)).isEmpty() ? null : ((List<?>) level1.get(0)).get(0);
+            // 若内层首元素仍是 List,则 level1 是 tuple 列表
+            if (maybeField instanceof List) {
+                for (Object o : level1) {
+                    if (o instanceof List) {
+                        out.add((List<?>) o);
+                    }
+                }
+                return out;
+            }
+            // level1.get(0) 是 tuple(字段为标量)→ level1 即多行
+            for (Object o : level1) {
+                if (o instanceof List) {
+                    List<?> row = (List<?>) o;
+                    if (!row.isEmpty() && !(row.get(0) instanceof List)) {
+                        out.add(row);
+                    } else if (!row.isEmpty() && row.get(0) instanceof List) {
+                        out.add((List<?>) row.get(0));
+                    }
+                }
+            }
+            return out;
+        }
+        // 单行字段 list
+        out.add(level1);
+        return out;
+    }
+}

+ 443 - 0
src/main/java/com/steerinfo/meterwork/meterworkmonitor/rtdb/MeterWorkMonitorTupleCodec.java

@@ -0,0 +1,443 @@
+package com.steerinfo.meterwork.meterworkmonitor.rtdb;
+
+import com.steerinfo.meterwork.meterworkmonitor.model.MeterWorkMonitor;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * MeterWorkMonitor ↔ Tarantool tuple(66 业务字段 + _dirty + _updated_at)。
+ */
+public final class MeterWorkMonitorTupleCodec {
+
+    public static final int FIELD_COUNT = 68;
+    public static final int IDX_POINT_NO = 1;
+    public static final int IDX_LAST_TIME = 30;
+    public static final int IDX_DIRTY = 67;
+    public static final int IDX_UPDATED_AT = 68;
+
+    private MeterWorkMonitorTupleCodec() {
+    }
+
+    public static List<Object> toTuple(MeterWorkMonitor m, boolean dirty) {
+        List<Object> t = new ArrayList<>(FIELD_COUNT);
+        t.add(nullToEmpty(m.getPointNo()));
+        t.add(nullToEmpty(m.getPointName()));
+        t.add(nullToEmpty(m.getValidWgt()));
+        t.add(nullToEmpty(m.getValidCarno()));
+        t.add(nullToEmpty(m.getValidTareTime()));
+        t.add(nullToEmpty(m.getValidTimeInterval()));
+        t.add(nullToEmpty(m.getValidParkStatus()));
+        t.add(nullToEmpty(m.getValidExceedWgt()));
+        t.add(nullToEmpty(m.getValidPredictionDiff()));
+        t.add(nullToEmpty(m.getValidFlag()));
+        t.add(nullToEmpty(m.getValidMatTime()));
+        t.add(nullToEmpty(m.getCarMonitor()));
+        t.add(numOrZero(m.getCarMonitorLvl()));
+        t.add(nullToEmpty(m.getIsHelp()));
+        t.add(nullToEmpty(m.getIsTakeOver()));
+        t.add(nullToEmpty(m.getLedWriter()));
+        t.add(numOrZero(m.getPrintNum()));
+        t.add(nullToEmpty(m.getCollectionCode()));
+        t.add(nullToEmpty(m.getIsOverWgt()));
+        t.add(nullToEmpty(m.getUserNo()));
+        t.add(nullToEmpty(m.getMonitorUserNo()));
+        t.add(nullToEmpty(m.getSetCar()));
+        t.add(nullToEmpty(m.getLedKeep()));
+        t.add(nullToEmpty(m.getRfidCar()));
+        t.add(nullToEmpty(m.getPhotoCar()));
+        t.add(nullToEmpty(m.getScanCar()));
+        t.add(nullToEmpty(m.getEditCar()));
+        t.add(nullToEmpty(m.getCarErr()));
+        t.add(nullToEmpty(m.getWgtErr()));
+        t.add(dateToEpoch(m.getLastTime()));
+        t.add(nullToEmpty(m.getStopOverTime()));
+        t.add(nullToEmpty(m.getMsgInfo()));
+        t.add(m.getWgt() == null ? 0L : m.getWgt());
+        t.add(nullToEmpty(m.getIsRemote()));
+        t.add(nullToEmpty(m.getWeighInstrumentModel()));
+        t.add(nullToEmpty(m.getWeighInstrumentParam()));
+        t.add(nullToEmpty(m.getMoxaIp()));
+        t.add(nullToEmpty(m.getMoxaPort()));
+        t.add(nullToEmpty(m.getValidLianda()));
+        t.add(nullToEmpty(m.getIsOpenScanning()));
+        t.add(nullToEmpty(m.getValidIsPre()));
+        t.add(nullToEmpty(m.getUserIp()));
+        t.add(nullToEmpty(m.getPowerState()));
+        t.add(nullToEmpty(m.getDvrState()));
+        t.add(nullToEmpty(m.getMoxaState()));
+        t.add(nullToEmpty(m.getPlcState()));
+        t.add(nullToEmpty(m.getLeftInfraredRayState()));
+        t.add(nullToEmpty(m.getRightInfraredRayState()));
+        t.add(nullToEmpty(m.getAmplifierState()));
+        t.add(nullToEmpty(m.getLampState()));
+        t.add(nullToEmpty(m.getSpotTypeNo()));
+        t.add(nullToEmpty(m.getRedGreenLightState()));
+        t.add(nullToEmpty(m.getFrontInfraredRayState()));
+        t.add(nullToEmpty(m.getRearInfraredRayState()));
+        t.add(nullToEmpty(m.getRfidState()));
+        t.add(nullToEmpty(m.getValidArea()));
+        t.add(nullToEmpty(m.getWeightStatus()));
+        t.add(nullToEmpty(m.getFerroalloyConfirm()));
+        t.add(nullToEmpty(m.getFerroalloyResult()));
+        t.add(nullToEmpty(m.getZeroState()));
+        t.add(numOrZero(m.getZeroWeight()));
+        t.add(nullToEmpty(m.getIsZeroState()));
+        t.add(nullToEmpty(m.getIsValidScan()));
+        t.add(nullToEmpty(m.getIsEnableSound()));
+        t.add(nullToEmpty(m.getPhotoCar2()));
+        t.add(dirty);
+        t.add(System.currentTimeMillis());
+        return t;
+    }
+
+    public static MeterWorkMonitor fromTuple(List<?> t) {
+        if (t == null || t.isEmpty()) {
+            return null;
+        }
+        MeterWorkMonitor m = new MeterWorkMonitor();
+        m.setPointNo(asString(t, 0));
+        m.setPointName(asString(t, 1));
+        m.setValidWgt(asString(t, 2));
+        m.setValidCarno(asString(t, 3));
+        m.setValidTareTime(asString(t, 4));
+        m.setValidTimeInterval(asString(t, 5));
+        m.setValidParkStatus(asString(t, 6));
+        m.setValidExceedWgt(asString(t, 7));
+        m.setValidPredictionDiff(asString(t, 8));
+        m.setValidFlag(asString(t, 9));
+        m.setValidMatTime(asString(t, 10));
+        m.setCarMonitor(asString(t, 11));
+        m.setCarMonitorLvl(asShort(t, 12));
+        m.setIsHelp(asString(t, 13));
+        m.setIsTakeOver(asString(t, 14));
+        m.setLedWriter(asString(t, 15));
+        m.setPrintNum(asShort(t, 16));
+        m.setCollectionCode(asString(t, 17));
+        m.setIsOverWgt(asString(t, 18));
+        m.setUserNo(asString(t, 19));
+        m.setMonitorUserNo(asString(t, 20));
+        m.setSetCar(asString(t, 21));
+        m.setLedKeep(asString(t, 22));
+        m.setRfidCar(asString(t, 23));
+        m.setPhotoCar(asString(t, 24));
+        m.setScanCar(asString(t, 25));
+        m.setEditCar(asString(t, 26));
+        m.setCarErr(asString(t, 27));
+        m.setWgtErr(asString(t, 28));
+        m.setLastTime(asDate(t, 29));
+        m.setStopOverTime(asString(t, 30));
+        m.setMsgInfo(asString(t, 31));
+        m.setWgt(asLong(t, 32));
+        m.setIsRemote(asString(t, 33));
+        m.setWeighInstrumentModel(asString(t, 34));
+        m.setWeighInstrumentParam(asString(t, 35));
+        m.setMoxaIp(asString(t, 36));
+        m.setMoxaPort(asString(t, 37));
+        m.setValidLianda(asString(t, 38));
+        m.setIsOpenScanning(asString(t, 39));
+        m.setValidIsPre(asString(t, 40));
+        m.setUserIp(asString(t, 41));
+        m.setPowerState(asString(t, 42));
+        m.setDvrState(asString(t, 43));
+        m.setMoxaState(asString(t, 44));
+        m.setPlcState(asString(t, 45));
+        m.setLeftInfraredRayState(asString(t, 46));
+        m.setRightInfraredRayState(asString(t, 47));
+        m.setAmplifierState(asString(t, 48));
+        m.setLampState(asString(t, 49));
+        m.setSpotTypeNo(asString(t, 50));
+        m.setRedGreenLightState(asString(t, 51));
+        m.setFrontInfraredRayState(asString(t, 52));
+        m.setRearInfraredRayState(asString(t, 53));
+        m.setRfidState(asString(t, 54));
+        m.setValidArea(asString(t, 55));
+        m.setWeightStatus(asString(t, 56));
+        m.setFerroalloyConfirm(asString(t, 57));
+        m.setFerroalloyResult(asString(t, 58));
+        m.setZeroState(asString(t, 59));
+        m.setZeroWeight(asShort(t, 60));
+        m.setIsZeroState(asString(t, 61));
+        m.setIsValidScan(asString(t, 62));
+        m.setIsEnableSound(asString(t, 63));
+        m.setPhotoCar2(asString(t, 64));
+        return m;
+    }
+
+    public static boolean isDirty(List<?> t) {
+        if (t == null || t.size() < IDX_DIRTY) {
+            return false;
+        }
+        Object v = t.get(IDX_DIRTY - 1);
+        return Boolean.TRUE.equals(v) || "true".equalsIgnoreCase(String.valueOf(v));
+    }
+
+    /**
+     * 将 patch 中非空字段合并到 base(selective update 语义)。
+     */
+    public static void mergeNonNull(MeterWorkMonitor base, MeterWorkMonitor patch) {
+        if (patch.getPointName() != null) {
+            base.setPointName(patch.getPointName());
+        }
+        if (patch.getValidWgt() != null) {
+            base.setValidWgt(patch.getValidWgt());
+        }
+        if (patch.getValidCarno() != null) {
+            base.setValidCarno(patch.getValidCarno());
+        }
+        if (patch.getValidTareTime() != null) {
+            base.setValidTareTime(patch.getValidTareTime());
+        }
+        if (patch.getValidTimeInterval() != null) {
+            base.setValidTimeInterval(patch.getValidTimeInterval());
+        }
+        if (patch.getValidParkStatus() != null) {
+            base.setValidParkStatus(patch.getValidParkStatus());
+        }
+        if (patch.getValidExceedWgt() != null) {
+            base.setValidExceedWgt(patch.getValidExceedWgt());
+        }
+        if (patch.getValidPredictionDiff() != null) {
+            base.setValidPredictionDiff(patch.getValidPredictionDiff());
+        }
+        if (patch.getValidFlag() != null) {
+            base.setValidFlag(patch.getValidFlag());
+        }
+        if (patch.getValidMatTime() != null) {
+            base.setValidMatTime(patch.getValidMatTime());
+        }
+        if (patch.getCarMonitor() != null) {
+            base.setCarMonitor(patch.getCarMonitor());
+        }
+        if (patch.getCarMonitorLvl() != null) {
+            base.setCarMonitorLvl(patch.getCarMonitorLvl());
+        }
+        if (patch.getIsHelp() != null) {
+            base.setIsHelp(patch.getIsHelp());
+        }
+        if (patch.getIsTakeOver() != null) {
+            base.setIsTakeOver(patch.getIsTakeOver());
+        }
+        if (patch.getLedWriter() != null) {
+            base.setLedWriter(patch.getLedWriter());
+        }
+        if (patch.getPrintNum() != null) {
+            base.setPrintNum(patch.getPrintNum());
+        }
+        if (patch.getCollectionCode() != null) {
+            base.setCollectionCode(patch.getCollectionCode());
+        }
+        if (patch.getIsOverWgt() != null) {
+            base.setIsOverWgt(patch.getIsOverWgt());
+        }
+        if (patch.getUserNo() != null) {
+            base.setUserNo(patch.getUserNo());
+        }
+        if (patch.getMonitorUserNo() != null) {
+            base.setMonitorUserNo(patch.getMonitorUserNo());
+        }
+        if (patch.getSetCar() != null) {
+            base.setSetCar(patch.getSetCar());
+        }
+        if (patch.getLedKeep() != null) {
+            base.setLedKeep(patch.getLedKeep());
+        }
+        if (patch.getRfidCar() != null) {
+            base.setRfidCar(patch.getRfidCar());
+        }
+        if (patch.getPhotoCar() != null) {
+            base.setPhotoCar(patch.getPhotoCar());
+        }
+        if (patch.getScanCar() != null) {
+            base.setScanCar(patch.getScanCar());
+        }
+        if (patch.getEditCar() != null) {
+            base.setEditCar(patch.getEditCar());
+        }
+        if (patch.getCarErr() != null) {
+            base.setCarErr(patch.getCarErr());
+        }
+        if (patch.getWgtErr() != null) {
+            base.setWgtErr(patch.getWgtErr());
+        }
+        if (patch.getLastTime() != null) {
+            base.setLastTime(patch.getLastTime());
+        }
+        if (patch.getStopOverTime() != null) {
+            base.setStopOverTime(patch.getStopOverTime());
+        }
+        if (patch.getMsgInfo() != null) {
+            base.setMsgInfo(patch.getMsgInfo());
+        }
+        if (patch.getWgt() != null) {
+            base.setWgt(patch.getWgt());
+        }
+        if (patch.getIsRemote() != null) {
+            base.setIsRemote(patch.getIsRemote());
+        }
+        if (patch.getWeighInstrumentModel() != null) {
+            base.setWeighInstrumentModel(patch.getWeighInstrumentModel());
+        }
+        if (patch.getWeighInstrumentParam() != null) {
+            base.setWeighInstrumentParam(patch.getWeighInstrumentParam());
+        }
+        if (patch.getMoxaIp() != null) {
+            base.setMoxaIp(patch.getMoxaIp());
+        }
+        if (patch.getMoxaPort() != null) {
+            base.setMoxaPort(patch.getMoxaPort());
+        }
+        if (patch.getValidLianda() != null) {
+            base.setValidLianda(patch.getValidLianda());
+        }
+        if (patch.getIsOpenScanning() != null) {
+            base.setIsOpenScanning(patch.getIsOpenScanning());
+        }
+        if (patch.getValidIsPre() != null) {
+            base.setValidIsPre(patch.getValidIsPre());
+        }
+        if (patch.getUserIp() != null) {
+            base.setUserIp(patch.getUserIp());
+        }
+        if (patch.getPowerState() != null) {
+            base.setPowerState(patch.getPowerState());
+        }
+        if (patch.getDvrState() != null) {
+            base.setDvrState(patch.getDvrState());
+        }
+        if (patch.getMoxaState() != null) {
+            base.setMoxaState(patch.getMoxaState());
+        }
+        if (patch.getPlcState() != null) {
+            base.setPlcState(patch.getPlcState());
+        }
+        if (patch.getLeftInfraredRayState() != null) {
+            base.setLeftInfraredRayState(patch.getLeftInfraredRayState());
+        }
+        if (patch.getRightInfraredRayState() != null) {
+            base.setRightInfraredRayState(patch.getRightInfraredRayState());
+        }
+        if (patch.getAmplifierState() != null) {
+            base.setAmplifierState(patch.getAmplifierState());
+        }
+        if (patch.getLampState() != null) {
+            base.setLampState(patch.getLampState());
+        }
+        if (patch.getSpotTypeNo() != null) {
+            base.setSpotTypeNo(patch.getSpotTypeNo());
+        }
+        if (patch.getRedGreenLightState() != null) {
+            base.setRedGreenLightState(patch.getRedGreenLightState());
+        }
+        if (patch.getFrontInfraredRayState() != null) {
+            base.setFrontInfraredRayState(patch.getFrontInfraredRayState());
+        }
+        if (patch.getRearInfraredRayState() != null) {
+            base.setRearInfraredRayState(patch.getRearInfraredRayState());
+        }
+        if (patch.getRfidState() != null) {
+            base.setRfidState(patch.getRfidState());
+        }
+        if (patch.getValidArea() != null) {
+            base.setValidArea(patch.getValidArea());
+        }
+        if (patch.getWeightStatus() != null) {
+            base.setWeightStatus(patch.getWeightStatus());
+        }
+        if (patch.getFerroalloyConfirm() != null) {
+            base.setFerroalloyConfirm(patch.getFerroalloyConfirm());
+        }
+        if (patch.getFerroalloyResult() != null) {
+            base.setFerroalloyResult(patch.getFerroalloyResult());
+        }
+        if (patch.getZeroState() != null) {
+            base.setZeroState(patch.getZeroState());
+        }
+        if (patch.getZeroWeight() != null) {
+            base.setZeroWeight(patch.getZeroWeight());
+        }
+        if (patch.getIsZeroState() != null) {
+            base.setIsZeroState(patch.getIsZeroState());
+        }
+        if (patch.getIsValidScan() != null) {
+            base.setIsValidScan(patch.getIsValidScan());
+        }
+        if (patch.getIsEnableSound() != null) {
+            base.setIsEnableSound(patch.getIsEnableSound());
+        }
+        if (patch.getPhotoCar2() != null) {
+            base.setPhotoCar2(patch.getPhotoCar2());
+        }
+    }
+
+    private static String nullToEmpty(String s) {
+        return s == null ? "" : s;
+    }
+
+    private static Number numOrZero(Number n) {
+        return n == null ? 0 : n;
+    }
+
+    private static Long dateToEpoch(Date d) {
+        return d == null ? null : d.getTime();
+    }
+
+    private static Object at(List<?> t, int idx) {
+        return idx < t.size() ? t.get(idx) : null;
+    }
+
+    private static String asString(List<?> t, int idx) {
+        Object v = at(t, idx);
+        if (v == null) {
+            return null;
+        }
+        String s = String.valueOf(v);
+        return s.isEmpty() ? null : s;
+    }
+
+    private static Short asShort(List<?> t, int idx) {
+        Object v = at(t, idx);
+        if (v == null) {
+            return null;
+        }
+        if (v instanceof Number) {
+            return ((Number) v).shortValue();
+        }
+        try {
+            return Short.valueOf(String.valueOf(v));
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    private static Long asLong(List<?> t, int idx) {
+        Object v = at(t, idx);
+        if (v == null) {
+            return null;
+        }
+        if (v instanceof Number) {
+            return ((Number) v).longValue();
+        }
+        try {
+            return Long.valueOf(String.valueOf(v));
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    private static Date asDate(List<?> t, int idx) {
+        Object v = at(t, idx);
+        if (v == null) {
+            return null;
+        }
+        if (v instanceof Date) {
+            return (Date) v;
+        }
+        if (v instanceof Number) {
+            long epoch = ((Number) v).longValue();
+            return epoch <= 0 ? null : new Date(epoch);
+        }
+        return null;
+    }
+}

+ 30 - 0
src/main/java/com/steerinfo/meterwork/meterworkmonitor/scheduler/MeterWorkMonitorSyncScheduler.java

@@ -0,0 +1,30 @@
+package com.steerinfo.meterwork.meterworkmonitor.scheduler;
+
+import com.steerinfo.meterwork.meterworkmonitor.service.MeterWorkMonitorSyncService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/**
+ * Tarantool METER_WORK_MONITOR → Oracle 定时回写。
+ */
+@Component
+@ConditionalOnBean(MeterWorkMonitorSyncService.class)
+@ConditionalOnProperty(name = "meter.monitor.sync.enabled", havingValue = "true", matchIfMissing = false)
+public class MeterWorkMonitorSyncScheduler {
+
+    private static final Logger log = LoggerFactory.getLogger(MeterWorkMonitorSyncScheduler.class);
+
+    @Autowired
+    private MeterWorkMonitorSyncService meterWorkMonitorSyncService;
+
+    @Scheduled(cron = "${meter.monitor.sync.cron:0 */1 * * * ?}")
+    public void scheduledSync() {
+        log.info("========== 开始执行 METER_WORK_MONITOR Tarantool→Oracle 同步 ==========");
+        meterWorkMonitorSyncService.doSync();
+    }
+}

+ 64 - 0
src/main/java/com/steerinfo/meterwork/meterworkmonitor/service/MeterWorkMonitorSyncService.java

@@ -0,0 +1,64 @@
+package com.steerinfo.meterwork.meterworkmonitor.service;
+
+import com.steerinfo.meterwork.meterworkmonitor.mapper.MeterWorkMonitorMapper;
+import com.steerinfo.meterwork.meterworkmonitor.model.MeterWorkMonitor;
+import com.steerinfo.meterwork.meterworkmonitor.rtdb.MeterWorkMonitorRtdbRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * 将 Tarantool METER_WORK_MONITOR 脏数据回写 Oracle。
+ */
+@Service
+@ConditionalOnBean(MeterWorkMonitorRtdbRepository.class)
+public class MeterWorkMonitorSyncService {
+
+    private static final Logger log = LoggerFactory.getLogger(MeterWorkMonitorSyncService.class);
+
+    private final AtomicBoolean running = new AtomicBoolean(false);
+
+    @Resource
+    private MeterWorkMonitorRtdbRepository meterWorkMonitorRtdbRepository;
+
+    @Resource
+    private MeterWorkMonitorMapper meterWorkMonitorMapper;
+
+    public void doSync() {
+        if (!running.compareAndSet(false, true)) {
+            log.warn("MeterWorkMonitor sync skipped: previous run still in progress");
+            return;
+        }
+        long start = System.currentTimeMillis();
+        int success = 0;
+        int failed = 0;
+        try {
+            List<MeterWorkMonitor> dirtyRows = meterWorkMonitorRtdbRepository.selectDirty();
+            log.info("MeterWorkMonitor sync start, dirtyCount={}", dirtyRows.size());
+            if (dirtyRows.isEmpty()) {
+                return;
+            }
+            for (MeterWorkMonitor row : dirtyRows) {
+                try {
+                    meterWorkMonitorMapper.updateByPrimaryKeySelective(row);
+                    meterWorkMonitorRtdbRepository.clearDirty(row.getPointNo());
+                    success++;
+                } catch (Exception ex) {
+                    failed++;
+                    log.error("sync row failed, pointNo={}", row.getPointNo(), ex);
+                }
+            }
+        } catch (Exception e) {
+            log.error("MeterWorkMonitor sync failed", e);
+        } finally {
+            running.set(false);
+            log.info("MeterWorkMonitor sync done, success={}, failed={}, costMs={}",
+                    success, failed, System.currentTimeMillis() - start);
+        }
+    }
+}

+ 80 - 17
src/main/java/com/steerinfo/meterwork/meterworkmonitor/service/impl/MeterWorkMonitorServiceImpl.java

@@ -8,20 +8,21 @@ import com.steerinfo.framework.service.pagehelper.PageList;
 import com.steerinfo.meterwork.meterworkmonitor.dto.MeterWorkMonitorDto;
 import com.steerinfo.meterwork.meterworkmonitor.mapper.MeterWorkMonitorMapper;
 import com.steerinfo.meterwork.meterworkmonitor.model.MeterWorkMonitor;
+import com.steerinfo.meterwork.meterworkmonitor.rtdb.MeterWorkMonitorRtdbRepository;
+import com.steerinfo.meterwork.meterworkmonitor.rtdb.MeterWorkMonitorTupleCodec;
 import com.steerinfo.meterwork.meterworkmonitor.service.IMeterWorkMonitorService;
 import com.steerinfo.meterwork.meterworkonline.mapper.MeterWorkOnlineMapper;
 import com.steerinfo.meterwork.meterworkonline.model.MeterWorkOnline;
+import com.steerinfo.meterwork.rtdb.RtdbPoolProperties;
 import com.steerinfo.util.FieldsCollector;
 import com.steerinfo.util.StringUtils;
-import io.micrometer.core.instrument.Meter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.scheduling.annotation.EnableAsync;
-import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-import org.springframework.transaction.interceptor.TransactionAspectSupport;
 import org.springframework.util.Assert;
 
 import javax.annotation.Resource;
@@ -46,12 +47,20 @@ import java.util.*;
 @EnableAsync        //开启多线程
 public class MeterWorkMonitorServiceImpl extends BaseServiceImpl<MeterWorkMonitor, String> implements IMeterWorkMonitorService {
 
+    private static final Logger log = LoggerFactory.getLogger(MeterWorkMonitorServiceImpl.class);
+
     @Resource
     private MeterWorkMonitorMapper meterWorkMonitorMapper;
 
     @Resource
     private MeterWorkOnlineMapper meterWorkOnlineMapper;
 
+    @Autowired(required = false)
+    private MeterWorkMonitorRtdbRepository meterWorkMonitorRtdbRepository;
+
+    @Autowired(required = false)
+    private RtdbPoolProperties rtdbPoolProperties;
+
     @Override
     protected IBaseMapper<MeterWorkMonitor, String> getMapper() {
         return meterWorkMonitorMapper;
@@ -75,37 +84,91 @@ public class MeterWorkMonitorServiceImpl extends BaseServiceImpl<MeterWorkMonito
     }
 
     @Override
-    @Transactional
     public String doUpdateWf(MeterWorkMonitor model) {
         try {
+            Assert.isTrue(model != null && StringUtils.isNotEmpty(model.getPointNo()), "秤点编号为空");
+            if (isRtdbWriteEnabled()) {
+                try {
+                    MeterWorkMonitor existing = meterWorkMonitorRtdbRepository.getByPointNo(model.getPointNo());
+                    if (existing == null || existing.getPointNo() == null) {
+                        existing = meterWorkMonitorMapper.selectByPrimaryKey(model.getPointNo());
+                    }
+                    if (existing == null || existing.getPointNo() == null) {
+                        existing = model;
+                    } else {
+                        MeterWorkMonitorTupleCodec.mergeNonNull(existing, model);
+                    }
+                    meterWorkMonitorRtdbRepository.replace(existing, true);
+                    return existing.getPointNo();
+                } catch (Exception rtdbEx) {
+                    log.error("doUpdateWf write Tarantool failed, pointNo={}", model.getPointNo(), rtdbEx);
+                    if (isFallbackOracle()) {
+                        meterWorkMonitorMapper.updateByPrimaryKeySelective(model);
+                        return model.getPointNo();
+                    }
+                    return "";
+                }
+            }
             meterWorkMonitorMapper.updateByPrimaryKeySelective(model);
             return model.getPointNo();
         } catch (Exception ex) {
-            ex.printStackTrace();
-            TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
-            return null;
+            log.error("doUpdateWf failed", ex);
+            return "";
         }
     }
 
     @Override
     public String updateLastTime(MeterWorkMonitor model) {
         try {
-            MeterWorkMonitor tempModel = new MeterWorkMonitor();
-            Assert.isTrue(Objects.nonNull(model.getPointNo()), "秤点编号为空");
-            if(Objects.nonNull(model.getPointNo()) && StringUtils.isNotEmpty(model.getPointNo())) {
-                tempModel = meterWorkMonitorMapper.selectByPrimaryKey(model.getPointNo());
+            Assert.isTrue(Objects.nonNull(model) && StringUtils.isNotEmpty(model.getPointNo()), "秤点编号为空");
+            Date now = new Date();
+            if (isRtdbWriteEnabled()) {
+                try {
+                    MeterWorkMonitor existing = meterWorkMonitorRtdbRepository.getByPointNo(model.getPointNo());
+                    if (existing == null || existing.getPointNo() == null) {
+                        existing = meterWorkMonitorMapper.selectByPrimaryKey(model.getPointNo());
+                        if (existing == null || existing.getPointNo() == null) {
+                            return "";
+                        }
+                        existing.setLastTime(now);
+                        meterWorkMonitorRtdbRepository.replace(existing, true);
+                    } else {
+                        meterWorkMonitorRtdbRepository.updateLastTime(model.getPointNo(), now.getTime());
+                    }
+                    return model.getPointNo();
+                } catch (Exception rtdbEx) {
+                    log.error("updateLastTime write Tarantool failed, pointNo={}", model.getPointNo(), rtdbEx);
+                    if (isFallbackOracle()) {
+                        MeterWorkMonitor tempModel = meterWorkMonitorMapper.selectByPrimaryKey(model.getPointNo());
+                        Assert.isTrue(tempModel != null && tempModel.getPointNo() != null, "未查询到秤点信息");
+                        tempModel.setLastTime(now);
+                        meterWorkMonitorMapper.updateByPrimaryKeySelective(tempModel);
+                        return tempModel.getPointNo();
+                    }
+                    return "";
+                }
             }
-            Assert.isTrue(Objects.nonNull(tempModel.getPointNo()), "未查询到秤点信息");
-            tempModel.setLastTime(new Date());
+            MeterWorkMonitor tempModel = meterWorkMonitorMapper.selectByPrimaryKey(model.getPointNo());
+            Assert.isTrue(tempModel != null && tempModel.getPointNo() != null, "未查询到秤点信息");
+            tempModel.setLastTime(now);
             meterWorkMonitorMapper.updateByPrimaryKeySelective(tempModel);
             return tempModel.getPointNo();
         } catch (Exception ex) {
-            ex.printStackTrace();
-            TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
-            return null;
+            log.error("updateLastTime failed", ex);
+            return "";
         }
     }
 
+    private boolean isRtdbWriteEnabled() {
+        return meterWorkMonitorRtdbRepository != null
+                && rtdbPoolProperties != null
+                && rtdbPoolProperties.getMonitor().isEnabled();
+    }
+
+    private boolean isFallbackOracle() {
+        return rtdbPoolProperties != null && rtdbPoolProperties.getMonitor().isFallbackOracle();
+    }
+
     @Autowired
     DataSource dataSource;
 

+ 17 - 0
src/main/java/com/steerinfo/meterwork/rtdb/RtdbClientConfiguration.java

@@ -0,0 +1,17 @@
+package com.steerinfo.meterwork.rtdb;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@EnableConfigurationProperties(RtdbPoolProperties.class)
+public class RtdbClientConfiguration {
+
+    @Bean(destroyMethod = "close")
+    @ConditionalOnProperty(name = "rtdb.monitor.enabled", havingValue = "true", matchIfMissing = true)
+    public RtdbClientPool rtdbClientPool(RtdbPoolProperties properties) {
+        return new RtdbClientPool(properties);
+    }
+}

+ 189 - 0
src/main/java/com/steerinfo/meterwork/rtdb/RtdbClientPool.java

@@ -0,0 +1,189 @@
+package com.steerinfo.meterwork.rtdb;
+
+import com.steerinfo.rtdb.Client;
+import com.steerinfo.rtdb.ClientConfig;
+import com.steerinfo.rtdb.ClientImpl;
+import com.steerinfo.rtdb.SingleSocketChannelProviderImpl;
+import com.steerinfo.rtdb.SocketChannelProvider;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * 基于 icore-rtdb 的多 Client 连接池(借用/归还 + 故障重建)。
+ */
+public class RtdbClientPool implements AutoCloseable {
+
+    private static final Logger log = LoggerFactory.getLogger(RtdbClientPool.class);
+
+    private final RtdbPoolProperties properties;
+    private final BlockingQueue<Client> idle;
+    private final AtomicBoolean closed = new AtomicBoolean(false);
+    private final int capacity;
+    private final java.util.concurrent.atomic.AtomicInteger created = new java.util.concurrent.atomic.AtomicInteger(0);
+
+    public RtdbClientPool(RtdbPoolProperties properties) {
+        this.properties = properties;
+        this.capacity = Math.max(1, properties.getPool().getSize());
+        this.idle = new ArrayBlockingQueue<>(capacity);
+        // 启动时尝试预创建,失败不阻断应用(借用时再创建)
+        for (int i = 0; i < capacity; i++) {
+            try {
+                idle.offer(createClient());
+                created.incrementAndGet();
+            } catch (Exception e) {
+                log.warn("RTDB pre-create client failed ({}/{}): {}", i + 1, capacity, e.getMessage());
+                break;
+            }
+        }
+        log.info("RtdbClientPool initialized: address={}, preCreated={}/{}",
+                properties.address(), created.get(), capacity);
+    }
+
+    public Client borrow() throws InterruptedException {
+        ensureOpen();
+        Client client = idle.poll(properties.getPool().getBorrowTimeoutMs(), TimeUnit.MILLISECONDS);
+        if (client == null) {
+            if (created.get() < capacity) {
+                try {
+                    client = createClient();
+                    created.incrementAndGet();
+                } catch (Exception e) {
+                    throw new IllegalStateException("RTDB pool empty and create failed: " + e.getMessage(), e);
+                }
+            } else {
+                throw new IllegalStateException("RTDB pool borrow timeout after "
+                        + properties.getPool().getBorrowTimeoutMs() + "ms");
+            }
+        }
+        if (!isHealthy(client)) {
+            closeQuietly(client);
+            created.decrementAndGet();
+            client = createClient();
+            created.incrementAndGet();
+        }
+        return client;
+    }
+
+    public void release(Client client) {
+        if (client == null || closed.get()) {
+            closeQuietly(client);
+            if (client != null) {
+                created.decrementAndGet();
+            }
+            return;
+        }
+        if (!idle.offer(client)) {
+            closeQuietly(client);
+            created.decrementAndGet();
+        }
+    }
+
+    /**
+     * 业务失败时丢弃坏连接并补一个新连接回池。
+     */
+    public void invalidate(Client client) {
+        closeQuietly(client);
+        created.decrementAndGet();
+        if (closed.get()) {
+            return;
+        }
+        try {
+            idle.offer(createClient());
+            created.incrementAndGet();
+        } catch (Exception e) {
+            log.warn("Failed to recreate RTDB client after invalidate: {}", e.getMessage());
+        }
+    }
+
+    public <T> T execute(RtdbCallback<T> callback) {
+        Client client = null;
+        boolean borrowed = false;
+        try {
+            client = borrow();
+            borrowed = true;
+            return callback.doInRtdb(client);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new IllegalStateException("Interrupted while borrowing RTDB client", e);
+        } catch (RuntimeException e) {
+            if (borrowed && client != null) {
+                invalidate(client);
+                borrowed = false;
+                client = null;
+            }
+            throw e;
+        } catch (Exception e) {
+            if (borrowed && client != null) {
+                invalidate(client);
+                borrowed = false;
+                client = null;
+            }
+            throw new IllegalStateException("RTDB operation failed", e);
+        } finally {
+            if (borrowed && client != null) {
+                release(client);
+            }
+        }
+    }
+
+    private Client createClient() {
+        ClientConfig config = new ClientConfig();
+        config.username = properties.getUsername();
+        config.password = properties.getPassword() == null ? "" : properties.getPassword();
+        SocketChannelProvider socket =
+                new SingleSocketChannelProviderImpl(properties.address());
+        return new ClientImpl(socket, config);
+    }
+
+    private boolean isHealthy(Client client) {
+        try {
+            if (client == null || client.isClosed() || !client.isAlive()) {
+                return false;
+            }
+            client.syncOps().ping();
+            return true;
+        } catch (Exception e) {
+            log.warn("RTDB client ping failed: {}", e.getMessage());
+            return false;
+        }
+    }
+
+    private void ensureOpen() {
+        if (closed.get()) {
+            throw new IllegalStateException("RtdbClientPool is closed");
+        }
+    }
+
+    private void closeQuietly(Client client) {
+        if (client == null) {
+            return;
+        }
+        try {
+            client.close();
+        } catch (Exception ignored) {
+            // ignore
+        }
+    }
+
+    @Override
+    public void close() {
+        if (!closed.compareAndSet(false, true)) {
+            return;
+        }
+        Client client;
+        while ((client = idle.poll()) != null) {
+            closeQuietly(client);
+        }
+        log.info("RtdbClientPool closed");
+    }
+
+    @FunctionalInterface
+    public interface RtdbCallback<T> {
+        T doInRtdb(Client client) throws Exception;
+    }
+}

+ 115 - 0
src/main/java/com/steerinfo/meterwork/rtdb/RtdbPoolProperties.java

@@ -0,0 +1,115 @@
+package com.steerinfo.meterwork.rtdb;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * Tarantool / icore-rtdb 连接池配置。
+ */
+@ConfigurationProperties(prefix = "rtdb")
+public class RtdbPoolProperties {
+
+    private String host = "172.22.42.3";
+    private int port = 2101;
+    private String username = "guest";
+    private String password = "";
+    private final Pool pool = new Pool();
+    private final Monitor monitor = new Monitor();
+
+    public String getHost() {
+        return host;
+    }
+
+    public void setHost(String host) {
+        this.host = host;
+    }
+
+    public int getPort() {
+        return port;
+    }
+
+    public void setPort(int port) {
+        this.port = port;
+    }
+
+    public String getUsername() {
+        return username;
+    }
+
+    public void setUsername(String username) {
+        this.username = username;
+    }
+
+    public String getPassword() {
+        return password;
+    }
+
+    public void setPassword(String password) {
+        this.password = password;
+    }
+
+    public Pool getPool() {
+        return pool;
+    }
+
+    public Monitor getMonitor() {
+        return monitor;
+    }
+
+    public String address() {
+        return host + ":" + port;
+    }
+
+    public static class Pool {
+        private int size = 8;
+        private long borrowTimeoutMs = 3000L;
+
+        public int getSize() {
+            return size;
+        }
+
+        public void setSize(int size) {
+            this.size = size;
+        }
+
+        public long getBorrowTimeoutMs() {
+            return borrowTimeoutMs;
+        }
+
+        public void setBorrowTimeoutMs(long borrowTimeoutMs) {
+            this.borrowTimeoutMs = borrowTimeoutMs;
+        }
+    }
+
+    public static class Monitor {
+        /** 是否启用 Monitor 写 Tarantool */
+        private boolean enabled = true;
+        /** Tarantool 失败时是否回退 Oracle */
+        private boolean fallbackOracle = false;
+        /** 启动时从 Oracle 全量灌入 Tarantool */
+        private boolean bootstrapEnabled = false;
+
+        public boolean isEnabled() {
+            return enabled;
+        }
+
+        public void setEnabled(boolean enabled) {
+            this.enabled = enabled;
+        }
+
+        public boolean isFallbackOracle() {
+            return fallbackOracle;
+        }
+
+        public void setFallbackOracle(boolean fallbackOracle) {
+            this.fallbackOracle = fallbackOracle;
+        }
+
+        public boolean isBootstrapEnabled() {
+            return bootstrapEnabled;
+        }
+
+        public void setBootstrapEnabled(boolean bootstrapEnabled) {
+            this.bootstrapEnabled = bootstrapEnabled;
+        }
+    }
+}

+ 13 - 0
src/main/resources/application.properties

@@ -131,3 +131,16 @@ server.tomcat.max-threads=400
 server.connection-timeout=20000
 
 logging.level.com=debug
+
+# Tarantool / METER_WORK_MONITOR 内存加速
+rtdb.host=172.22.42.3
+rtdb.port=2101
+rtdb.username=guest
+rtdb.password=
+rtdb.pool.size=8
+rtdb.pool.borrow-timeout-ms=3000
+rtdb.monitor.enabled=true
+rtdb.monitor.fallback-oracle=false
+rtdb.monitor.bootstrap-enabled=false
+meter.monitor.sync.enabled=false
+meter.monitor.sync.cron=0 */1 * * * ?