feat(mcp): 出纸机当前状态 changes 流(devices.changes.list,P2 设备域)

- m0.hardware_view 视图:status 1/2/3→ONLINE/FAULT/OFFLINE fail-closed;
  剩余纸量/出纸计数/电池异常/网络信号/质保到期等设备经营事实透传
- 行权限复用 applyHardwareRowFilter(与 hardware.list.query 同一 ACL)
- 全量 460 项测试通过
This commit is contained in:
zhiyuncai
2026-10-16 12:36:08 +08:00
parent f4cdc8e7bf
commit da18e5c37c
5 changed files with 227 additions and 4 deletions
@@ -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<SyncRow> fetchPage(Long fromTs, long cutoffTs, Long cursorMt, Long cursorId,
int limit, User user) {
LambdaQueryWrapper<HardwareView> wrapper = new LambdaQueryWrapper<>();
if (user != null) {
permissionService.applyHardwareRowFilter(wrapper, user);
}
applyWindow(wrapper, fromTs, cutoffTs, cursorMt, cursorId);
applyOrderAndLimit(wrapper, limit);
List<SyncRow> 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<String, Object> 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();
}
}
@@ -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) {
}
}
@@ -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<String, Object> 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<Long> parseContractIds(List<String> contractIds) {
if (contractIds == null || contractIds.isEmpty()) {
throw new ToolCallException(McpErrorCode.INVALID_ARGUMENT, "contractIds必须包含1至200个合同ID");
@@ -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<SyncRow> rows = entity.fetchPage(null, 2000L, null, null, 3, user);
verify(permissionService).applyHardwareRowFilter(any(), any());
Map<String, Object> 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());
}
}
@@ -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