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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user