From 686ce2daf82a39bba29a05855fa8385c6da759f4 Mon Sep 17 00:00:00 2001 From: xqzp2026 Date: Tue, 8 Sep 2026 19:25:12 +0930 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20=E6=96=B0=E5=A2=9E=E7=AB=99?= =?UTF-8?q?=E7=82=B9=E5=88=97=E8=A1=A8=E4=B8=8E=E8=AF=A6=E6=83=85=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=20(REQ-20260908-0006)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../doc/agent-mcp-tools-reference.md | 61 +++- .../entity/m0/DistrictDistrictStrategy.java | 92 +++++ .../m0/DistrictDistrictStrategyMapper.java | 7 + .../me/iiv/agent/mcp/tool/DistrictTools.java | 324 ++++++++++++++++++ .../agent/permission/PermissionService.java | 47 +++ .../iiv/agent/mcp/tool/DistrictToolsTest.java | 186 ++++++++++ .../permission/PermissionServiceTest.java | 60 ++++ 7 files changed, 776 insertions(+), 1 deletion(-) create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/DistrictDistrictStrategy.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/DistrictDistrictStrategyMapper.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/DistrictTools.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/DistrictToolsTest.java diff --git a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md index fd14202..6f165a4 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md @@ -119,7 +119,66 @@ --- -## 4. sys.ping +## 4. district.list.query + +分页查询站点列表,对应老系统 `/district/districtlistByPage`,但使用固定轻量字段契约。 + +- 分类:customer | 类型:读 +- 数据来源:m0 库 `district_district_strategy` 视图,固定 `soft_delete = 0`、`id DESC` +- 行级权限:总部看全部;代理商按自己、直属下级、代管代理及授权物业过滤;销售部门非负责人沿用老接口的互斥物业范围。筛选条件只能缩小权限结果。 + +**入参**: + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| id | number | 否 | 站点 id,正整数,精确匹配 | +| districtName | string | 否 | 站点名称,模糊匹配 | +| districtState | number | 否 | 0=测试 1=履约 2=演示 3=撤机 | +| province | string | 否 | 省份,模糊匹配 | +| city | string | 否 | 城市,模糊匹配 | +| agentId | number | 否 | 代理商 id,正整数,精确匹配 | +| propertyId | number | 否 | 物业 id,正整数,精确匹配 | +| page | number | 否 | 页码,从 1 开始,默认 1 | +| rows | number | 否 | 每页条数,默认 20,上限 100 | + +**出参 records 字段**: + +`id`、`districtName`、`districtNumber`、`propertyId`、`propertyName`、`agentId`、`agentName`、`province`、`city`、`countyseat`、`address`、`districtState`、`onlineState`、`hardwareCount`、`isSmart`。 + +`propertyName`、`agentName` 均为批量富化,查不到时返回 `未查询到`。 + +--- + +## 5. district.detail.get + +按站点 id 获取扩展详情。站点 id 与当前用户行权限在同一个数据库查询条件中组合。 + +- 分类:customer | 类型:读 +- 入参:`id`(number,必填,正整数) +- 找到:`{ "found": true, "data": { ... } }` +- 不存在或无权限:`{ "found": false, "message": "站点不存在或无权访问" }` + +**data 字段**: + +| 分组 | 字段 | +|---|---| +| 归属 | id、districtName、districtNumber、propertyId、propertyName、agentId、agentName、salesId、salesName、serviceCompanyId、serviceCompany | +| 地址联系人 | province、city、countyseat、address、contact、phone、mail | +| 站点属性 | businessFormat、attributeId、attributeName、installType、isSmart、lnfluenceLevel、buildCount、hardwareCount、airdetectorFlag | +| 状态周期 | districtState、onlineState、testDate、honourDate、weaningDate、testDays | +| 设备耗材 | deviceSalesType、paperSalesType、consumablesType、posDevicePaperNorm、toiletDevicePaperNorm | +| 功能开关 | pointState、assignmentWorkState、airState、messageFlag、reportFlag、lockMachineFlag、eventRepairFlag、rfidUseFlag、calibratedFlag、illegalPaperDisable | +| 语音配置 | lowerPaperState、noPaperState、powerWarning、waitTimes | +| 报警配置 | warning、warning2、warning3、warningTime、paperPercentWarn、maintainOrderWarning、foamCountWarning、foamPowerWarning | +| 业务配置 | pointDeduction、effectiveUseTime、consumablesPrice、billDate、billType、billTypeStr、fineMoney | +| 运行配置 | districtRefresh(Redis 未配置默认 5)、forbidFunction(Redis 未配置默认 0) | +| 审计信息 | createId、createName、createTime、modifyTime、memo | + +固定不返回 `softDelete`、`enterpriseId`、`modifyId`、内部服务 URL、重复 `districtId`、语义未确认的 `supervisorId` 和 SKU 列表。 + +--- + +## 6. sys.ping 服务自检(demo 工具,证明链路连通)。 diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/DistrictDistrictStrategy.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/DistrictDistrictStrategy.java new file mode 100644 index 0000000..1b009c5 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/DistrictDistrictStrategy.java @@ -0,0 +1,92 @@ +package me.iiv.agent.entity.m0; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.io.Serializable; +import java.time.LocalDate; +import java.util.Date; + +@Getter +@Setter +@ToString +@TableName("district_district_strategy") +public class DistrictDistrictStrategy implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + private Long createId; + private Date createTime; + private Date modifyTime; + private Integer softDelete; + private String districtName; + private String address; + private Long propertyId; + private Long agentId; + private String province; + private String city; + private String countyseat; + private String memo; + private String contact; + private String mail; + private Integer buildCount; + private String districtNumber; + private String phone; + private Integer installType; + private Integer warning; + private Integer airdetectorFlag; + private Integer warning2; + private Integer warning3; + private Integer warningTime; + private Integer onlineState; + private Integer paperPercentWarn; + private Integer pointState; + private Integer assignmentWorkState; + private Integer airState; + private String businessFormat; + private String posDevicePaperNorm; + private String toiletDevicePaperNorm; + private Integer messageFlag; + private Integer reportFlag; + private Double fineMoney; + private Integer lockMachineFlag; + private Integer eventRepairFlag; + private Integer rfidUseFlag; + private Integer calibratedFlag; + private String salesName; + private Long salesId; + private Integer waitTimes; + private Integer noPaperState; + private Integer lowerPaperState; + private Integer districtState; + private Integer deviceSalesType; + private Integer paperSalesType; + private LocalDate weaningDate; + private LocalDate honourDate; + private LocalDate testDate; + private Integer powerWarning; + private Integer pointDeduction; + private Integer testDays; + private Integer illegalPaperDisable; + private Integer effectiveUseTime; + private Long attributeId; + private String attributeName; + private Integer hardwareCount; + private Double consumablesPrice; + private Integer billDate; + private Integer foamCountWarning; + private Integer foamPowerWarning; + private Long serviceCompanyId; + private String serviceCompany; + private String billType; + private Integer consumablesType; + private Integer maintainOrderWarning; + private Integer lnfluenceLevel; + private Integer isSmart; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/DistrictDistrictStrategyMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/DistrictDistrictStrategyMapper.java new file mode 100644 index 0000000..02b27e5 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/DistrictDistrictStrategyMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m0; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m0.DistrictDistrictStrategy; + +public interface DistrictDistrictStrategyMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/DistrictTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/DistrictTools.java new file mode 100644 index 0000000..b5b76d0 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/DistrictTools.java @@ -0,0 +1,324 @@ +package me.iiv.agent.mcp.tool; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import me.iiv.agent.entity.m0.Agent; +import me.iiv.agent.entity.m0.DistrictDistrictStrategy; +import me.iiv.agent.entity.m0.Properyt; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.mapper.m0.AgentMapper; +import me.iiv.agent.mapper.m0.DistrictDistrictStrategyMapper; +import me.iiv.agent.mapper.m0.ProperytMapper; +import me.iiv.agent.mapper.m0.UserMapper; +import me.iiv.agent.mcp.ToolObject; +import me.iiv.agent.mcp.XqTool; +import me.iiv.agent.permission.PermissionService; +import me.iiv.agent.security.AuthContextHolder; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +@Component +public class DistrictTools implements ToolObject { + + private static final int DEFAULT_ROWS = 20; + private static final int MAX_ROWS = 100; + + final DistrictDistrictStrategyMapper districtMapper; + final ProperytMapper properytMapper; + final AgentMapper agentMapper; + final UserMapper userMapper; + final PermissionService permissionService; + final StringRedisTemplate redisTemplate; + + public DistrictTools(DistrictDistrictStrategyMapper districtMapper, ProperytMapper properytMapper, + AgentMapper agentMapper, UserMapper userMapper, + PermissionService permissionService, StringRedisTemplate redisTemplate) { + this.districtMapper = districtMapper; + this.properytMapper = properytMapper; + this.agentMapper = agentMapper; + this.userMapper = userMapper; + this.permissionService = permissionService; + this.redisTemplate = redisTemplate; + } + + @XqTool(category = "customer", kind = "read") + @Tool(name = "district.list.query", description = "分页查询当前用户可见的站点。" + + "支持站点 id、名称、状态、省市、代理商和物业筛选;名称、省、市为模糊匹配,其余为精确匹配。" + + "page 默认 1,rows 默认 20、上限 100,固定按 id 倒序。" + + "返回轻量站点字段,物业名和代理商名使用批量查询富化。") + public Map districtListQuery( + @ToolParam(required = false, description = "站点 id,正整数,精确匹配") Long id, + @ToolParam(required = false, description = "站点名称,模糊匹配") String districtName, + @ToolParam(required = false, description = "站点状态:0=测试 1=履约 2=演示 3=撤机") Integer districtState, + @ToolParam(required = false, description = "省份,模糊匹配") String province, + @ToolParam(required = false, description = "城市,模糊匹配") String city, + @ToolParam(required = false, description = "代理商 id,正整数,精确匹配") Long agentId, + @ToolParam(required = false, description = "物业 id,正整数,精确匹配") Long propertyId, + @ToolParam(required = false, description = "页码,从 1 开始,默认 1") Integer page, + @ToolParam(required = false, description = "每页条数,默认 20,上限 100") Integer rows) { + validatePositive(id, "id"); + validatePositive(agentId, "agentId"); + validatePositive(propertyId, "propertyId"); + if (districtState != null && (districtState < 0 || districtState > 3)) { + throw new IllegalArgumentException("districtState 仅支持 0、1、2、3"); + } + + User user = AuthContextHolder.get().getUser(); + int pageNo = page == null || page < 1 ? 1 : page; + boolean truncated = rows != null && rows > MAX_ROWS; + int pageSize = rows == null || rows < 1 ? DEFAULT_ROWS : Math.min(rows, MAX_ROWS); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(DistrictDistrictStrategy::getSoftDelete, 0); + if (id != null) { + wrapper.eq(DistrictDistrictStrategy::getId, id); + } + if (StringUtils.hasText(districtName)) { + wrapper.like(DistrictDistrictStrategy::getDistrictName, districtName); + } + if (districtState != null) { + wrapper.eq(DistrictDistrictStrategy::getDistrictState, districtState); + } + if (StringUtils.hasText(province)) { + wrapper.like(DistrictDistrictStrategy::getProvince, province); + } + if (StringUtils.hasText(city)) { + wrapper.like(DistrictDistrictStrategy::getCity, city); + } + if (agentId != null) { + wrapper.eq(DistrictDistrictStrategy::getAgentId, agentId); + } + if (propertyId != null) { + wrapper.eq(DistrictDistrictStrategy::getPropertyId, propertyId); + } + permissionService.applyDistrictRowFilter(wrapper, user); + + Long total = districtMapper.selectCount(wrapper); + wrapper.select(DistrictDistrictStrategy::getId, DistrictDistrictStrategy::getDistrictName, + DistrictDistrictStrategy::getDistrictNumber, DistrictDistrictStrategy::getPropertyId, + DistrictDistrictStrategy::getAgentId, DistrictDistrictStrategy::getProvince, + DistrictDistrictStrategy::getCity, DistrictDistrictStrategy::getCountyseat, + DistrictDistrictStrategy::getAddress, DistrictDistrictStrategy::getDistrictState, + DistrictDistrictStrategy::getOnlineState, DistrictDistrictStrategy::getHardwareCount, + DistrictDistrictStrategy::getIsSmart) + .orderByDesc(DistrictDistrictStrategy::getId) + .last("LIMIT " + (long) (pageNo - 1) * pageSize + "," + pageSize); + List districts = districtMapper.selectList(wrapper); + + Map propertyNames = propertyNames(districts); + Map agentNames = agentNames(districts); + List> records = new ArrayList<>(); + for (DistrictDistrictStrategy district : districts) { + Map record = new LinkedHashMap<>(); + record.put("id", district.getId()); + record.put("districtName", district.getDistrictName()); + record.put("districtNumber", district.getDistrictNumber()); + record.put("propertyId", district.getPropertyId()); + record.put("propertyName", name(propertyNames, district.getPropertyId())); + record.put("agentId", district.getAgentId()); + record.put("agentName", name(agentNames, district.getAgentId())); + record.put("province", district.getProvince()); + record.put("city", district.getCity()); + record.put("countyseat", district.getCountyseat()); + record.put("address", district.getAddress()); + record.put("districtState", district.getDistrictState()); + record.put("onlineState", district.getOnlineState()); + record.put("hardwareCount", district.getHardwareCount()); + record.put("isSmart", district.getIsSmart()); + records.add(record); + } + + Map result = new LinkedHashMap<>(); + result.put("total", total); + result.put("page", pageNo); + result.put("rows", pageSize); + if (truncated) { + result.put("notice", "rows 超过上限 100,已截断为 100"); + } + result.put("records", records); + return result; + } + + @XqTool(category = "customer", kind = "read") + @Tool(name = "district.detail.get", description = "按站点 id 查询当前用户可见的站点详情。" + + "站点不存在或无权访问均返回 found=false 和相同提示,防止枚举越权站点。" + + "返回固定业务字段白名单,不包含软删标记、内部服务地址和重复字段。") + public Map districtDetailGet( + @ToolParam(description = "站点 id,正整数") Long id) { + if (id == null) { + throw new IllegalArgumentException("id 必填"); + } + validatePositive(id, "id"); + User user = AuthContextHolder.get().getUser(); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(DistrictDistrictStrategy::getSoftDelete, 0) + .eq(DistrictDistrictStrategy::getId, id); + permissionService.applyDistrictRowFilter(wrapper, user); + DistrictDistrictStrategy district = districtMapper.selectOne(wrapper); + + Map result = new LinkedHashMap<>(); + if (district == null) { + result.put("found", false); + result.put("message", "站点不存在或无权访问"); + return result; + } + result.put("found", true); + result.put("data", detail(district)); + return result; + } + + private Map detail(DistrictDistrictStrategy district) { + Properyt property = district.getPropertyId() == null ? null : properytMapper.selectById(district.getPropertyId()); + Agent agent = district.getAgentId() == null ? null : agentMapper.selectById(district.getAgentId()); + User creator = district.getCreateId() == null ? null : userMapper.selectById(district.getCreateId()); + String billType = StringUtils.hasText(district.getBillType()) ? district.getBillType() : "1month"; + + Map data = new LinkedHashMap<>(); + data.put("id", district.getId()); + data.put("districtName", district.getDistrictName()); + data.put("districtNumber", district.getDistrictNumber()); + data.put("propertyId", district.getPropertyId()); + data.put("propertyName", property == null ? "未查询到" : property.getName()); + data.put("agentId", district.getAgentId()); + data.put("agentName", agent == null ? "未查询到" : agent.getName()); + data.put("salesId", district.getSalesId()); + data.put("salesName", district.getSalesName()); + data.put("serviceCompanyId", district.getServiceCompanyId()); + data.put("serviceCompany", district.getServiceCompany()); + + data.put("province", district.getProvince()); + data.put("city", district.getCity()); + data.put("countyseat", district.getCountyseat()); + data.put("address", district.getAddress()); + data.put("contact", district.getContact()); + data.put("phone", district.getPhone()); + data.put("mail", district.getMail()); + + data.put("businessFormat", district.getBusinessFormat()); + data.put("attributeId", district.getAttributeId()); + data.put("attributeName", district.getAttributeName()); + data.put("installType", district.getInstallType()); + data.put("isSmart", district.getIsSmart()); + data.put("lnfluenceLevel", district.getLnfluenceLevel()); + data.put("buildCount", district.getBuildCount()); + data.put("hardwareCount", district.getHardwareCount()); + data.put("airdetectorFlag", district.getAirdetectorFlag()); + + data.put("districtState", district.getDistrictState()); + data.put("onlineState", district.getOnlineState()); + data.put("testDate", district.getTestDate()); + data.put("honourDate", district.getHonourDate()); + data.put("weaningDate", district.getWeaningDate()); + data.put("testDays", district.getTestDays()); + + data.put("deviceSalesType", district.getDeviceSalesType()); + data.put("paperSalesType", district.getPaperSalesType()); + data.put("consumablesType", district.getConsumablesType()); + data.put("posDevicePaperNorm", district.getPosDevicePaperNorm()); + data.put("toiletDevicePaperNorm", district.getToiletDevicePaperNorm()); + + data.put("pointState", district.getPointState()); + data.put("assignmentWorkState", district.getAssignmentWorkState()); + data.put("airState", district.getAirState()); + data.put("messageFlag", district.getMessageFlag()); + data.put("reportFlag", district.getReportFlag()); + data.put("lockMachineFlag", district.getLockMachineFlag()); + data.put("eventRepairFlag", district.getEventRepairFlag()); + data.put("rfidUseFlag", district.getRfidUseFlag()); + data.put("calibratedFlag", district.getCalibratedFlag()); + data.put("illegalPaperDisable", district.getIllegalPaperDisable()); + + data.put("lowerPaperState", district.getLowerPaperState()); + data.put("noPaperState", district.getNoPaperState()); + data.put("powerWarning", district.getPowerWarning()); + data.put("waitTimes", district.getWaitTimes()); + + data.put("warning", district.getWarning()); + data.put("warning2", district.getWarning2()); + data.put("warning3", district.getWarning3()); + data.put("warningTime", district.getWarningTime()); + data.put("paperPercentWarn", district.getPaperPercentWarn()); + data.put("maintainOrderWarning", district.getMaintainOrderWarning()); + data.put("foamCountWarning", district.getFoamCountWarning()); + data.put("foamPowerWarning", district.getFoamPowerWarning()); + + data.put("pointDeduction", district.getPointDeduction()); + data.put("effectiveUseTime", district.getEffectiveUseTime()); + data.put("consumablesPrice", district.getConsumablesPrice()); + data.put("billDate", district.getBillDate()); + data.put("billType", billType); + data.put("billTypeStr", billTypeName(billType)); + data.put("fineMoney", district.getFineMoney()); + + data.put("districtRefresh", redisInteger("DistrictRefresh:" + district.getId(), 5)); + data.put("forbidFunction", redisInteger("DistrictForbidFunction:" + district.getId(), 0)); + + data.put("createId", district.getCreateId()); + data.put("createName", creator == null ? "未分配" : creator.getName()); + data.put("createTime", format(district.getCreateTime())); + data.put("modifyTime", format(district.getModifyTime())); + data.put("memo", district.getMemo()); + return data; + } + + private Map propertyNames(List districts) { + List ids = districts.stream().map(DistrictDistrictStrategy::getPropertyId) + .filter(Objects::nonNull).distinct().collect(Collectors.toList()); + if (ids.isEmpty()) { + return Map.of(); + } + return properytMapper.selectBatchIds(ids).stream() + .collect(Collectors.toMap(Properyt::getId, + property -> property.getName() == null ? "未查询到" : property.getName(), (a, b) -> a)); + } + + private Map agentNames(List districts) { + List ids = districts.stream().map(DistrictDistrictStrategy::getAgentId) + .filter(Objects::nonNull).distinct().collect(Collectors.toList()); + if (ids.isEmpty()) { + return Map.of(); + } + return agentMapper.selectBatchIds(ids).stream() + .collect(Collectors.toMap(Agent::getId, + agent -> agent.getName() == null ? "未查询到" : agent.getName(), (a, b) -> a)); + } + + private static String name(Map names, Long id) { + return id == null ? "未查询到" : names.getOrDefault(id, "未查询到"); + } + + private int redisInteger(String key, int defaultValue) { + String value = redisTemplate.opsForValue().get(key); + return value == null ? defaultValue : Integer.parseInt(value); + } + + private static String billTypeName(String billType) { + if (billType.contains("month")) { + return billType.replace("month", "个月账期"); + } + if (billType.contains("quarter")) { + return billType.replace("quarter", "季度账期"); + } + return billType; + } + + private static String format(java.util.Date value) { + return value == null ? null : new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(value); + } + + private static void validatePositive(Long value, String name) { + if (value != null && value < 1) { + throw new IllegalArgumentException(name + " 必须为正整数"); + } + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java index c130714..f36e54d 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import me.iiv.agent.entity.m0.Agent; import me.iiv.agent.entity.m0.AgentManageDistrictAuth; import me.iiv.agent.entity.m0.Authority; +import me.iiv.agent.entity.m0.DistrictDistrictStrategy; import me.iiv.agent.entity.m0.Properyt; import me.iiv.agent.entity.m0.User; import me.iiv.agent.entity.m2.WarehouseAgentAuth; @@ -243,4 +244,50 @@ public class PermissionService { wrapper.in(Properyt::getAgentId, ids); } } + + /** + * 站点行权限:1:1 对齐老接口 districtlistByPage 使用的 + * getByIsAgent(..., propertyFlag=true, className="property_id")。 + */ + public void applyDistrictRowFilter(LambdaQueryWrapper wrapper, User user) { + if (!isAgentAuthority(user.getAuthorityId())) { + return; + } + if (user.getAgentId() == null) { + throw new IllegalStateException("代理商用户未关联代理商,拒绝访问业务数据"); + } + + List ids = new ArrayList<>(subordinateAndSelfIds(user)); + List agentlist = managedAuths(user.getId()); + ids.addAll(agentlist.stream().map(WarehouseAgentAuth::getManagerAgentId).collect(Collectors.toList())); + + if (isSalesNonLeader(user)) { + List propertyIds = properytMapper.selectList(new LambdaQueryWrapper() + .eq(Properyt::getSoftDelete, 0) + .eq(Properyt::getCreateId, user.getId()) + .select(Properyt::getId)) + .stream().map(Properyt::getId).collect(Collectors.toList()); + if (!agentlist.isEmpty()) { + ids.remove(user.getAgentId()); + if (!ids.isEmpty()) { + propertyIds.addAll(properytMapper.selectList(new LambdaQueryWrapper() + .eq(Properyt::getSoftDelete, 0) + .in(Properyt::getAgentId, ids) + .select(Properyt::getId)) + .stream().map(Properyt::getId).collect(Collectors.toList())); + } + } + wrapper.in(DistrictDistrictStrategy::getPropertyId, + propertyIds.isEmpty() ? List.of(0L) : propertyIds); + return; + } + + List managedPropertyIds = managedPropertyIds(ids); + if (!managedPropertyIds.isEmpty()) { + wrapper.and(w -> w.in(DistrictDistrictStrategy::getAgentId, ids) + .or().in(DistrictDistrictStrategy::getPropertyId, managedPropertyIds)); + } else { + wrapper.in(DistrictDistrictStrategy::getAgentId, ids); + } + } } diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/DistrictToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/DistrictToolsTest.java new file mode 100644 index 0000000..8de1d85 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/DistrictToolsTest.java @@ -0,0 +1,186 @@ +package me.iiv.agent.mcp.tool; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.fasterxml.jackson.databind.ObjectMapper; +import me.iiv.agent.entity.m0.Agent; +import me.iiv.agent.entity.m0.DistrictDistrictStrategy; +import me.iiv.agent.entity.m0.Properyt; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.mapper.m0.AgentMapper; +import me.iiv.agent.mapper.m0.DistrictDistrictStrategyMapper; +import me.iiv.agent.mapper.m0.ProperytMapper; +import me.iiv.agent.mapper.m0.UserMapper; +import me.iiv.agent.permission.PermissionService; +import me.iiv.agent.security.AuthContext; +import me.iiv.agent.security.AuthContextHolder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.method.MethodToolCallbackProvider; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DistrictToolsTest { + + @Mock + DistrictDistrictStrategyMapper districtMapper; + @Mock + ProperytMapper properytMapper; + @Mock + AgentMapper agentMapper; + @Mock + UserMapper userMapper; + @Mock + PermissionService permissionService; + @Mock + StringRedisTemplate redisTemplate; + @Mock + ValueOperations valueOperations; + + DistrictTools tools; + User user; + + @BeforeEach + void setUp() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + DistrictDistrictStrategy.class); + tools = new DistrictTools(districtMapper, properytMapper, agentMapper, + userMapper, permissionService, redisTemplate); + user = new User(); + user.setId(7L); + AuthContextHolder.set(new AuthContext(null, user)); + } + + @AfterEach + void tearDown() { + AuthContextHolder.clear(); + } + + @Test + void listUsesReviewedContractAndTruncatesRows() { + DistrictDistrictStrategy district = new DistrictDistrictStrategy(); + district.setId(9L); + district.setDistrictName("测试站点"); + district.setDistrictNumber("D-009"); + district.setPropertyId(11L); + district.setAgentId(12L); + district.setProvince("广东省"); + district.setCity("深圳市"); + district.setCountyseat("南山区"); + district.setAddress("科技园"); + district.setDistrictState(1); + district.setOnlineState(1); + district.setHardwareCount(6); + district.setIsSmart(1); + when(districtMapper.selectCount(any())).thenReturn(1L); + when(districtMapper.selectList(any())).thenReturn(List.of(district)); + + Properyt property = new Properyt(); + property.setId(11L); + property.setName("测试物业"); + when(properytMapper.selectBatchIds(any())).thenReturn(List.of(property)); + Agent agent = new Agent(); + agent.setId(12L); + agent.setName("测试代理"); + when(agentMapper.selectBatchIds(any())).thenReturn(List.of(agent)); + + Map result = tools.districtListQuery( + null, null, null, null, null, null, null, 0, 500); + + assertEquals(1, result.get("page")); + assertEquals(100, result.get("rows")); + assertEquals("rows 超过上限 100,已截断为 100", result.get("notice")); + @SuppressWarnings("unchecked") + Map record = ((List>) result.get("records")).get(0); + assertEquals(List.of("id", "districtName", "districtNumber", "propertyId", "propertyName", + "agentId", "agentName", "province", "city", "countyseat", "address", + "districtState", "onlineState", "hardwareCount", "isSmart"), + record.keySet().stream().toList()); + assertEquals("测试物业", record.get("propertyName")); + assertEquals("测试代理", record.get("agentName")); + verify(permissionService).applyDistrictRowFilter(any(), any()); + } + + @Test + void detailDoesNotDistinguishMissingFromUnauthorized() { + when(districtMapper.selectOne(any())).thenReturn(null); + + Map result = tools.districtDetailGet(99L); + + assertFalse((Boolean) result.get("found")); + assertEquals("站点不存在或无权访问", result.get("message")); + verify(permissionService).applyDistrictRowFilter(any(), any()); + } + + @Test + void detailReturnsExpandedWhitelistWithoutInternalFields() { + DistrictDistrictStrategy district = new DistrictDistrictStrategy(); + district.setId(9L); + when(districtMapper.selectOne(any())).thenReturn(district); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + + Map result = tools.districtDetailGet(9L); + + assertTrue((Boolean) result.get("found")); + @SuppressWarnings("unchecked") + Map data = (Map) result.get("data"); + assertTrue(data.keySet().containsAll(List.of( + "districtName", "propertyName", "agentName", "businessFormat", "districtState", + "deviceSalesType", "pointState", "lowerPaperState", "warning", "billTypeStr", + "districtRefresh", "forbidFunction", "createName", "modifyTime"))); + assertFalse(data.containsKey("softDelete")); + assertFalse(data.containsKey("enterpriseId")); + assertFalse(data.containsKey("modifyId")); + assertFalse(data.containsKey("publicServiceUrl")); + } + + @Test + void detailRequiresPositiveId() { + assertThrows(IllegalArgumentException.class, () -> tools.districtDetailGet(null)); + assertThrows(IllegalArgumentException.class, () -> tools.districtDetailGet(0L)); + verifyNoInteractions(districtMapper); + } + + @Test + void springAiPublishesBothReviewedToolSchemas() throws Exception { + ToolCallback[] callbacks = MethodToolCallbackProvider.builder().toolObjects(tools).build().getToolCallbacks(); + Map schemas = Arrays.stream(callbacks).collect(Collectors.toMap( + callback -> callback.getToolDefinition().name(), + callback -> callback.getToolDefinition().inputSchema())); + + assertEquals(2, schemas.size()); + assertTrue(schemas.get("district.list.query").contains("districtName")); + assertTrue(schemas.get("district.list.query").contains("propertyId")); + assertTrue(new ObjectMapper().readTree(schemas.get("district.detail.get")) + .path("required").toString().contains("\"id\"")); + } + + @Test + void listRejectsUnknownDistrictStateBeforeQuerying() { + assertThrows(IllegalArgumentException.class, () -> tools.districtListQuery( + null, null, 4, null, null, null, null, 1, 20)); + + verifyNoInteractions(districtMapper); + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceTest.java index 0e832f4..09b2fae 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceTest.java @@ -4,7 +4,9 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import me.iiv.agent.entity.m0.Agent; +import me.iiv.agent.entity.m0.AgentManageDistrictAuth; import me.iiv.agent.entity.m0.Authority; +import me.iiv.agent.entity.m0.DistrictDistrictStrategy; import me.iiv.agent.entity.m0.Properyt; import me.iiv.agent.entity.m0.User; import me.iiv.agent.mapper.m0.AgentManageDistrictAuthMapper; @@ -22,6 +24,7 @@ import org.springframework.data.redis.core.HashOperations; import org.springframework.data.redis.core.StringRedisTemplate; import java.util.Collection; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -51,6 +54,8 @@ class PermissionServiceTest { @BeforeEach void setUp() { TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), Agent.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), Properyt.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), DistrictDistrictStrategy.class); permissionService = new PermissionService(authorityMapper, agentMapper, warehouseAgentAuthMapper, agentManageDistrictAuthMapper, properytMapper, redisTemplate); } @@ -140,6 +145,61 @@ class PermissionServiceTest { () -> permissionService.isSalesNonLeader(user)); } + @Test + void districtFilterKeepsAgentOrAuthorizedPropertyBoundary() { + User user = user(1000L, 108L, 1L); + when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 1)); + when(agentMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of()); + when(warehouseAgentAuthMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of()); + AgentManageDistrictAuth auth = new AgentManageDistrictAuth(); + auth.setPropertyId(44L); + when(agentManageDistrictAuthMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(auth)); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyDistrictRowFilter(wrapper, user); + + String sql = wrapper.getSqlSegment(); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(sql.contains("agent_id IN")); + assertTrue(sql.contains("property_id IN")); + assertTrue(values.contains(1L)); + assertTrue(values.contains(44L)); + } + + @Test + void headquartersDistrictFilterAddsNoBoundary() { + User user = user(46L, 1L, 0L); + when(authorityMapper.selectById(1L)).thenReturn(authority(1L, 0)); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyDistrictRowFilter(wrapper, user); + + assertTrue(wrapper.getSqlSegment().isBlank()); + } + + @Test + void salesDistrictFilterUsesOnlyVisiblePropertyIds() { + User user = user(1000L, 108L, 1L); + user.setDeptId(5L); + user.setPostId(7L); + when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 1)); + when(agentMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of()); + when(warehouseAgentAuthMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of()); + when(redisTemplate.opsForHash()).thenReturn(hashOperations); + when(hashOperations.get("Dictionary", "authDeptId")).thenReturn(",5,"); + when(hashOperations.get("Dictionary", "authDeptPostId")).thenReturn("8,"); + Properyt property = new Properyt(); + property.setId(55L); + when(properytMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(property)); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyDistrictRowFilter(wrapper, user); + + assertTrue(wrapper.getSqlSegment().contains("property_id IN")); + assertFalse(wrapper.getSqlSegment().contains("agent_id IN")); + assertTrue(wrapper.getParamNameValuePairs().values().contains(55L)); + } + private static Authority authority(Long id, int isAgent) { Authority authority = new Authority(); authority.setId(id);