diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/DevicesSyncEntity.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/DevicesSyncEntity.java new file mode 100644 index 0000000..8ce42d4 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/DevicesSyncEntity.java @@ -0,0 +1,107 @@ +package me.iiv.agent.mcp.sync; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import me.iiv.agent.entity.m0.HardwareView; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.mapper.m0.HardwareViewMapper; +import me.iiv.agent.mcp.McpErrorCode; +import me.iiv.agent.mcp.ToolCallException; +import me.iiv.agent.permission.PermissionService; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * devices 同步实体(m0.hardware_view 出纸机当前状态视图)。 + * 用途:P2 设备经营域事项化底座——离线/故障/上下线、剩余纸量(补货)、出纸用量、 + * 电池异常、质保到期等当前态事实。 + * status 1/2/3 映射 ONLINE/FAULT/OFFLINE,未知编码 fail-closed(与 contracts 同一铁律); + * device_type/online/power 等状态位取值域较宽,整型透传不造枚举; + * battery_fault_flag 0/1 映射布尔(与 contracts.expire_remind 同一先例)。 + * 行级权限:复用 PermissionService.applyHardwareRowFilter(与 hardware.list.query 同一 ACL)。 + * 注意:hardware_view 是数据库视图,游标依赖其暴露的 modify_time/create_time 列。 + */ +@Component +public class DevicesSyncEntity extends AbstractSyncEntity implements SyncEntity { + + public static final String NAME = "devices"; + + final HardwareViewMapper hardwareViewMapper; + final PermissionService permissionService; + + public DevicesSyncEntity(HardwareViewMapper hardwareViewMapper, + PermissionService permissionService) { + this.hardwareViewMapper = hardwareViewMapper; + this.permissionService = permissionService; + } + + @Override + public String name() { + return NAME; + } + + @Override + public List fetchPage(Long fromTs, long cutoffTs, Long cursorMt, Long cursorId, + int limit, User user) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (user != null) { + permissionService.applyHardwareRowFilter(wrapper, user); + } + applyWindow(wrapper, fromTs, cutoffTs, cursorMt, cursorId); + applyOrderAndLimit(wrapper, limit); + List rows = new ArrayList<>(); + for (HardwareView row : hardwareViewMapper.selectList(wrapper)) { + long mt = ChangesEngine.effectiveTs(row.getModifyTime(), row.getCreateTime()); + rows.add(new SyncRow(toItem(row, mt), mt, row.getId())); + } + return rows; + } + + private Map toItem(HardwareView row, long mt) { + boolean deleted = row.getSoftDelete() != null && row.getSoftDelete() == 1; + Date effective = row.getModifyTime() != null ? row.getModifyTime() : row.getCreateTime(); + return SyncChangeSchemas.toMap(new SyncChangeSchemas.DeviceChange( + stableId(row.getId()), row.getNo(), row.getName(), row.getSnCode(), row.getDeviceType(), + publicStatus(row.getStatus()), row.getOnline(), + stableId(row.getAgentId()), stableId(row.getPropertyId()), stableId(row.getDistrictId()), + stableId(row.getToiletId()), stableId(row.getBuildingId()), row.getFloor(), + row.getPosition(), row.getInstallScenes(), + row.getPaperSurplus(), row.getPaperCount(), row.getSenseCount(), row.getScanCount(), + flag(row.getBatteryFaultFlag()), row.getBatteryTemp(), + row.getNetworkSignal(), row.getPowerSupply(), row.getPoweredType(), + row.getVersion(), row.getFirmwareVersion(), + ChangesEngine.iso(row.getOnlineTime()), ChangesEngine.iso(row.getFirstOnTime()), + ChangesEngine.iso(row.getWarrantyExpirationTime()), + ChangesEngine.iso(effective), mt, deleted ? ChangesEngine.iso(effective) : null)); + } + + /** Legacy status (1在线/2故障/3离线) to the stable public enum. */ + static String publicStatus(Integer status) { + if (status == null) { + throw invalidStatus(null); + } + return switch (status) { + case 1 -> "ONLINE"; + case 2 -> "FAULT"; + case 3 -> "OFFLINE"; + default -> throw invalidStatus(status); + }; + } + + private static ToolCallException invalidStatus(Integer status) { + return new ToolCallException(McpErrorCode.DATA_INTEGRITY_ERROR, + "设备状态无法映射到公开契约: " + status); + } + + /** 0/1 标记 → Boolean;null 原样返回 null。 */ + static Boolean flag(Integer value) { + return value == null ? null : value == 1; + } + + private static String stableId(Long id) { + return id == null ? null : id.toString(); + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/SyncChangeSchemas.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/SyncChangeSchemas.java index 18ec94b..ce7cb84 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/SyncChangeSchemas.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/sync/SyncChangeSchemas.java @@ -229,4 +229,40 @@ final class SyncChangeSchemas { long version, @JsonProperty("deleted_at") String deletedAt) { } + + /** 出纸机当前状态(hardware_view 视图):status 映射 ONLINE/FAULT/OFFLINE,其余状态位整型透传。 */ + record DeviceChange( + @JsonProperty("device_id") String deviceId, + @JsonProperty("device_no") String deviceNo, + @JsonProperty("device_name") String deviceName, + @JsonProperty("sn_code") String snCode, + @JsonProperty("device_type") Integer deviceType, + String status, + @JsonProperty("online_state") Integer onlineState, + @JsonProperty("agent_id") String agentId, + @JsonProperty("property_id") String propertyId, + @JsonProperty("district_id") String districtId, + @JsonProperty("toilet_id") String toiletId, + @JsonProperty("building_id") String buildingId, + String floor, + String position, + @JsonProperty("install_scenes") Integer installScenes, + @JsonProperty("paper_surplus") Integer paperSurplus, + @JsonProperty("paper_count") Long paperCount, + @JsonProperty("sense_count") Integer senseCount, + @JsonProperty("scan_count") Integer scanCount, + @JsonProperty("battery_fault") Boolean batteryFault, + @JsonProperty("battery_temp") Integer batteryTemp, + @JsonProperty("network_signal") Integer networkSignal, + @JsonProperty("power_supply") Integer powerSupply, + @JsonProperty("powered_type") Integer poweredType, + @JsonProperty("model") String model, + @JsonProperty("firmware_version") String firmwareVersion, + @JsonProperty("online_at") String onlineAt, + @JsonProperty("first_online_at") String firstOnlineAt, + @JsonProperty("warranty_expire_at") String warrantyExpireAt, + @JsonProperty("updated_at") String updatedAt, + long version, + @JsonProperty("deleted_at") String deletedAt) { + } } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/ChangesTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/ChangesTools.java index e2c5c1e..683e09a 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/ChangesTools.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/ChangesTools.java @@ -8,6 +8,7 @@ import me.iiv.agent.mcp.sync.BatteryFaultsSyncEntity; import me.iiv.agent.mcp.sync.ChangesEngine; import me.iiv.agent.mcp.sync.ContractsSyncEntity; import me.iiv.agent.mcp.sync.DeviceLifecyclesSyncEntity; +import me.iiv.agent.mcp.sync.DevicesSyncEntity; import me.iiv.agent.mcp.sync.MallOrdersSyncEntity; import me.iiv.agent.mcp.sync.PropertiesSyncEntity; import me.iiv.agent.mcp.sync.RepaymentDetailsSyncEntity; @@ -30,7 +31,7 @@ import java.util.Map; /** * 增量同步工具(REQ-20260911-0013;P2 域扩展于 REQ-20260902-0002): * users/properties/contracts/repayment-targets/repayment-details/mall-orders/ - * device-lifecycles/battery-faults 八个 changes 工具, + * device-lifecycles/battery-faults/devices 九个 changes 工具, * 协议(snapshot/cursor/tombstone/sync_token)由 ChangesEngine 统一实现。 * 数据域硬约束:本类不声明 tenant_id/scope 入参,数据域只能从 Token 推导; * GuardedToolCallback 对 *.changes.list 工具的原始入参做二次拦截,传 tenant_id/scope 一律拒绝。 @@ -196,6 +197,22 @@ public class ChangesTools implements ToolObject { return changesEngine.execute(BatteryFaultsSyncEntity.NAME, syncToken, cursor, limit); } + @XqTool(category = "sync", kind = "read") + @Tool(name = "devices.changes.list", description = "增量同步出纸机当前状态(m0.hardware_view 视图)。" + PROTOCOL_DESC + + "items 字段:device_id/device_no/device_name/sn_code/device_type/" + + "status(ONLINE/FAULT/OFFLINE)/online_state/agent_id/property_id/district_id/toilet_id/" + + "building_id/floor/position/install_scenes/" + + "paper_surplus(剩余次数,补货依据)/paper_count/sense_count/scan_count(用量)/" + + "battery_fault/battery_temp/network_signal/power_supply/powered_type/" + + "model/firmware_version/online_at/first_online_at/warranty_expire_at/updated_at/version/deleted_at。" + + "行级权限跟随 Token 绑定的人(与 hardware.list.query 同一 ACL)。") + public Map devicesChangesList( + @ToolParam(required = false, description = SYNC_TOKEN_DESC) String syncToken, + @ToolParam(required = false, description = CURSOR_DESC) String cursor, + @ToolParam(required = false, description = LIMIT_DESC) Integer limit) { + return changesEngine.execute(DevicesSyncEntity.NAME, syncToken, cursor, limit); + } + private static List parseContractIds(List contractIds) { if (contractIds == null || contractIds.isEmpty()) { throw new ToolCallException(McpErrorCode.INVALID_ARGUMENT, "contractIds必须包含1至200个合同ID"); diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/sync/SyncEntitiesTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/sync/SyncEntitiesTest.java index c65f2a1..31a596b 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/sync/SyncEntitiesTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/sync/SyncEntitiesTest.java @@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import me.iiv.agent.entity.m0.BatteryFaultLog; import me.iiv.agent.entity.m0.DeviceLifeCycle; +import me.iiv.agent.entity.m0.HardwareView; import me.iiv.agent.entity.m0.Properyt; import me.iiv.agent.entity.m0.User; import me.iiv.agent.entity.m1.MallGoodsOrder; @@ -13,6 +14,7 @@ import me.iiv.agent.entity.m1.SalerRepaymentCustomer; import me.iiv.agent.entity.m2.ContractNew; import me.iiv.agent.mapper.m0.BatteryFaultLogMapper; import me.iiv.agent.mapper.m0.DeviceLifeCycleMapper; +import me.iiv.agent.mapper.m0.HardwareViewMapper; import me.iiv.agent.mapper.m0.ProperytMapper; import me.iiv.agent.mapper.m0.UserMapper; import me.iiv.agent.mapper.m1.MallGoodsOrderMapper; @@ -71,6 +73,8 @@ class SyncEntitiesTest { @Mock BatteryFaultLogMapper batteryFaultLogMapper; @Mock + HardwareViewMapper hardwareViewMapper; + @Mock PermissionService permissionService; @BeforeEach @@ -83,6 +87,7 @@ class SyncEntitiesTest { TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), MallGoodsOrder.class); TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), DeviceLifeCycle.class); TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), BatteryFaultLog.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), HardwareView.class); } @Test @@ -580,4 +585,58 @@ class SyncEntitiesTest { assertEquals(88, item.get("peak_value")); assertEquals("2026-10-11T10:00:00+08:00", item.get("deleted_at"), "软删行返回 tombstone"); } + + @Test + void devicesMapsStatusAndPaperFields() { + DevicesSyncEntity entity = new DevicesSyncEntity(hardwareViewMapper, permissionService); + HardwareView device = new HardwareView(); + device.setId(81L); + device.setNo("ZQ-0001"); + device.setName("出纸机"); + device.setSnCode("SN123456"); + device.setDeviceType(0); + device.setStatus(3); + device.setOnline(1); + device.setAgentId(21L); + device.setPropertyId(9L); + device.setDistrictId(3L); + device.setFloor("3F"); + device.setPosition("男卫"); + device.setPaperSurplus(12); + device.setPaperCount(34567L); + device.setBatteryFaultFlag(1); + device.setNetworkSignal(22); + device.setVersion("ZQ3.0"); + device.setFirmwareVersion("v2.1"); + device.setFirstOnTime(Timestamp.valueOf("2026-01-15 10:00:00")); + device.setWarrantyExpirationTime(Timestamp.valueOf("2027-01-15 00:00:00")); + device.setSoftDelete(0); + device.setModifyTime(Timestamp.valueOf("2026-10-11 08:30:00")); + when(hardwareViewMapper.selectList(any())).thenReturn(List.of(device)); + User user = new User(); + user.setId(7L); + + List rows = entity.fetchPage(null, 2000L, null, null, 3, user); + + verify(permissionService).applyHardwareRowFilter(any(), any()); + Map item = rows.get(0).item(); + assertEquals("81", item.get("device_id")); + assertEquals("ZQ-0001", item.get("device_no")); + assertEquals("OFFLINE", item.get("status")); + assertEquals("9", item.get("property_id")); + assertEquals(12, item.get("paper_surplus"), "剩余次数是补货依据,必须透传"); + assertEquals(34567L, item.get("paper_count")); + assertEquals(true, item.get("battery_fault")); + assertEquals("2026-01-15T10:00:00+08:00", item.get("first_online_at")); + assertEquals("2027-01-15T00:00:00+08:00", item.get("warranty_expire_at")); + assertNull(item.get("deleted_at")); + } + + @Test + void devicesRejectsUnknownStatus() { + ToolCallException error = assertThrows(ToolCallException.class, + () -> DevicesSyncEntity.publicStatus(9)); + + assertEquals(McpErrorCode.DATA_INTEGRITY_ERROR, error.getCode()); + } } diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/ChangesToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/ChangesToolsTest.java index 0db6da0..9e307d2 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/ChangesToolsTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/ChangesToolsTest.java @@ -23,7 +23,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** - * 八个 changes 工具的注册与路由(REQ-20260911-0013,P2 域扩展于 REQ-20260902-0002)。 + * 九个 changes 工具的注册与路由(REQ-20260911-0013,P2 域扩展于 REQ-20260902-0002)。 */ @ExtendWith(MockitoExtension.class) class ChangesToolsTest { @@ -41,10 +41,11 @@ class ChangesToolsTest { callback -> callback.getToolDefinition().name(), callback -> callback.getToolDefinition().inputSchema())); - assertEquals(9, schemas.size()); + assertEquals(10, schemas.size()); for (String name : new String[]{"users.changes.list", "properties.changes.list", "contracts.changes.list", "repayment-targets.changes.list", "repayment-details.changes.list", - "mall-orders.changes.list", "device-lifecycles.changes.list", "battery-faults.changes.list"}) { + "mall-orders.changes.list", "device-lifecycles.changes.list", "battery-faults.changes.list", + "devices.changes.list"}) { assertTrue(schemas.containsKey(name), "缺少工具 " + name); String schema = schemas.get(name); assertTrue(schema.contains("syncToken")); @@ -85,6 +86,9 @@ class ChangesToolsTest { tools.batteryFaultsChangesList(null, null, null); verify(changesEngine).execute("battery-faults", null, null, null); + + tools.devicesChangesList("t6", null, 20); + verify(changesEngine).execute("devices", "t6", null, 20); } @Test