feat(mcp): 新增代理/物业详情工具(含发票信息)+ 本机测试环境脚本
xiaoqu-agent-service CI / test (push) Canceled after 0s

- agent.detail.query / property.detail.query:主体详情 + invoice_info
  全量抬头列表(默认条优先),行权限复用列表过滤语义
- 新增 InvoiceQueries 共享发票查询助手;InvoiceInfo 实体/Mapper
  按线上库 codegen 生成(m0 白名单加 invoice_info)
- Properyt.customerId 补 @TableField("customerId"):库中驼峰列名,
  否则映射到不存在的 customer_id(prod 曾报 Unknown column)
- seed-dev.sql 补两个详情工具的 scope 绑定;PermissionServiceTest
  补详情行权限断言(合并远端 district 测试冲突)
- 新增 deploy/test-up.sh:本机测试环境一键脚本(构建→重启→刷新
  scope 绑定→自检);.env.example 修正引导变量名 APP_ 前缀误写
This commit is contained in:
xiaoqu
2026-09-09 11:41:35 +08:00
parent 40e61cd300
commit 1e42323697
11 changed files with 465 additions and 8 deletions
+3 -3
View File
@@ -2,9 +2,9 @@
# 数据库/Redis 连接信息已明文写在 application-dev.yml,无需在此配置
# 后台管理员首次引导(仅首次启动且 admin_user 为空时建号,之后可删除)
APP_ADMIN_BOOTSTRAP_ACCOUNT=admin
APP_ADMIN_BOOTSTRAP_PASSWORD=
# APP_ADMIN_BOOTSTRAP_NAME=系统管理员
ADMIN_BOOTSTRAP_ACCOUNT=admin
ADMIN_BOOTSTRAP_PASSWORD=
# ADMIN_BOOTSTRAP_NAME=系统管理员
# 默认拒绝为非空库自动建基线;仅已有 V1 库首次接入时临时改为 true
SVC_FLYWAY_BASELINE_ON_MIGRATE=false
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# 本机即测试环境:一键 构建 → 重启 → 刷新 scope 工具绑定 → 自检
# 用法:
# ./deploy/test-up.sh # 全量(含 Maven 构建,约 7 分钟)
# ./deploy/test-up.sh --skip-build # 跳过构建,直接重启+刷新绑定
# 首次运行会自动生成 deploy/.env.dev(含随机 admin 初始密码,注意看输出)
set -euo pipefail
APP_HOME="$(cd "$(dirname "$0")/.." && pwd)"
DEPLOY="$APP_HOME/deploy/deploy.sh"
ENV_FILE="$APP_HOME/deploy/.env.dev"
SEED_SQL="$APP_HOME/sql/seed-dev.sql"
MYSQL="${MYSQL_BIN:-mysql}"
# dev svc 库(与 application-dev.yml 保持一致)
SVC_DB_HOST=192.168.1.181
SVC_DB_USER=root
SVC_DB_PASSWORD=kaixinjiuhao
SVC_DB_NAME=xiaoqu_agent_svc_d
# 1. 首启引导配置(不存在才生成;admin 仅首次启动且 admin_user 为空时建号)
if [ ! -f "$ENV_FILE" ]; then
ADMIN_PWD="Dev@$(openssl rand -hex 6)"
cat > "$ENV_FILE" <<EOF
# dev 环境变量(本机使用,已 gitignore)
ADMIN_BOOTSTRAP_ACCOUNT=admin
ADMIN_BOOTSTRAP_PASSWORD=$ADMIN_PWD
ADMIN_BOOTSTRAP_NAME=系统管理员
SVC_FLYWAY_BASELINE_ON_MIGRATE=false
EOF
echo "[INIT] 已生成 $ENV_FILE"
echo "[INIT] admin 初始密码: $ADMIN_PWD (仅首次启动建号生效,请记录)"
fi
# 2. 构建
if [ "${1:-}" = "--skip-build" ]; then
echo "[SKIP] 跳过构建"
else
"$DEPLOY" build
fi
# 3. 重启(deploy.sh 内含 /mcp 健康检查)
"$DEPLOY" restart
# 4. 刷新 scope-工具绑定(tool 表已由启动时 ToolSyncService 同步;seed 幂等)
"$MYSQL" -h "$SVC_DB_HOST" -u"$SVC_DB_USER" -p"$SVC_DB_PASSWORD" "$SVC_DB_NAME" \
--default-character-set=utf8mb4 < "$SEED_SQL" > /dev/null
echo "[OK] scope 工具绑定已刷新(operation)"
# 5. 自检:后台登录接口存活(不验密码对错,只验服务可登录)
code=$(curl -s --noproxy '*' -o /dev/null -w "%{http_code}" -m 10 -X POST \
http://127.0.0.1:8088/api/admin/auth/login \
-H 'Content-Type: application/json' \
-d '{"account":"__selfcheck__","password":"__selfcheck__"}')
if [ "$code" = "401" ] || [ "$code" = "200" ]; then
echo "[OK] 登录接口存活 (HTTP $code)"
else
echo "[WARN] 登录接口异常 (HTTP $code),请查看: $DEPLOY log" >&2
fi
echo
echo "本机测试环境就绪: http://localhost:8088/ (admin 账号见 deploy/.env.dev)"
echo "日常命令: $DEPLOY {start|stop|restart|status|log}"
+2 -2
View File
@@ -14,10 +14,10 @@ INSERT INTO agent_scope (name, code, memo, status, create_id)
SELECT '运营', 'operation', 'dev 种子:运营作用域', 1, 0
WHERE NOT EXISTS (SELECT 1 FROM agent_scope WHERE code = 'operation');
-- 作用域-工具:sys.ping / agent.count / agent.list.query / property.list.query
-- 作用域-工具:sys.ping / agent.count / agent.list.query / property.list.query / agent.detail.query / property.detail.query
INSERT INTO scope_tool (scope_id, tool_id, create_id)
SELECT s.id, t.id, 0 FROM agent_scope s JOIN tool t
WHERE s.code = 'operation' AND t.name IN ('sys.ping', 'agent.count', 'agent.list.query', 'property.list.query')
WHERE s.code = 'operation' AND t.name IN ('sys.ping', 'agent.count', 'agent.list.query', 'property.list.query', 'agent.detail.query', 'property.detail.query')
AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id);
COMMIT;
@@ -0,0 +1,141 @@
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.util.Date;
/**
* <p>
* 发票信息表
* </p>
*
* @author codegen
* @since 2026-09-08
*/
@Getter
@Setter
@ToString
@TableName("invoice_info")
public class InvoiceInfo implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 创建id
*/
private Long createId;
/**
* 创建时间
*/
private Date createTime;
/**
* 创建人
*/
private String createName;
/**
* 修改id
*/
private Long modifyId;
/**
* 修改人
*/
private String modifyName;
/**
* 修改时间
*/
private Date modifyTime;
/**
* 数据状态
*/
private Integer softDelete;
/**
* 类型(0:代理;1:物业)
*/
private Integer type;
/**
* 客户ID
*/
private Long customerId;
/**
* 客户名称
*/
private String customerName;
/**
* 开票名称
*/
private String invoiceName;
/**
* 统一社会信用代码
*/
private String identificationNumber;
/**
* 联系人
*/
private String contact;
/**
* 联系人邮箱
*/
private String mail;
/**
* 银行账户户名称
*/
private String accountName;
/**
* 银行账号
*/
private String accountNo;
/**
* 开户行名称
*/
private String accountBank;
/**
* 开票通讯地址
*/
private String invoiceAddress;
/**
* 开票联系电话
*/
private String invoiceContact;
/**
* 备注信息
*/
private String memo;
/**
* 默认标识(0:非默认;1:默认)
*/
private Integer defaultFlag;
/**
* 发票抬头类型(0:企业单位;1:个人|非企业)
*/
private Integer invoiceType;
}
@@ -1,6 +1,7 @@
package me.iiv.agent.entity.m0;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Getter;
import lombok.Setter;
@@ -119,7 +120,10 @@ public class Properyt implements Serializable {
/**
* 客户id自动生成
* 注意:库中列名为驼峰 customerId(dev/prod 一致),需显式指定,
* 否则 MyBatis-Plus 默认驼峰转下划线会映射成不存在的 customer_id。
*/
@TableField("customerId")
private String customerId;
/**
@@ -0,0 +1,16 @@
package me.iiv.agent.mapper.m0;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m0.InvoiceInfo;
/**
* <p>
* 发票信息表 Mapper 接口
* </p>
*
* @author codegen
* @since 2026-09-08
*/
public interface InvoiceInfoMapper extends BaseMapper<InvoiceInfo> {
}
@@ -2,8 +2,10 @@ 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.InvoiceInfo;
import me.iiv.agent.entity.m0.User;
import me.iiv.agent.mapper.m0.AgentMapper;
import me.iiv.agent.mapper.m0.InvoiceInfoMapper;
import me.iiv.agent.mcp.ToolObject;
import me.iiv.agent.mcp.XqTool;
import me.iiv.agent.permission.PermissionService;
@@ -31,10 +33,13 @@ public class AgentTools implements ToolObject {
private static final int MAX_ROWS = 100;
final AgentMapper agentMapper;
final InvoiceInfoMapper invoiceInfoMapper;
final PermissionService permissionService;
public AgentTools(AgentMapper agentMapper, PermissionService permissionService) {
public AgentTools(AgentMapper agentMapper, InvoiceInfoMapper invoiceInfoMapper,
PermissionService permissionService) {
this.agentMapper = agentMapper;
this.invoiceInfoMapper = invoiceInfoMapper;
this.permissionService = permissionService;
}
@@ -154,4 +159,78 @@ public class AgentTools implements ToolObject {
result.put("records", records);
return result;
}
/**
* 查询代理商详情(含发票信息)。行权限复用列表精确查询语义:
* 代理商只能查自己或直属下级,总部不限。
* invoices 来自 m0.invoice_info(type=0 + customer_id=id),含该代理名下全部抬头。
*/
@XqTool(category = "customer", kind = "read")
@Tool(name = "agent.detail.query", description = "查询代理商详情(含发票信息)。"
+ "行级权限:代理商身份的用户(权限组 isagent=1)只能查询自己或直属下级代理;总部身份(isagent=0)可查询任意合法 id。"
+ "返回 record(代理商基础信息 + 默认发票镜像字段 + 纳税信息)和 invoices(该代理名下全部发票抬头,defaultFlag=1 为默认抬头,按默认优先+修改时间倒序)。"
+ "taxpayerType 含义:0=一般纳税人 1=小规模 2=个人;invoiceType 含义:0=企业单位 1=个人|非企业。"
+ "id 不存在或无权限时 record 为 null 并在 notice 字段说明。")
public Map<String, Object> agentDetailQuery(
@ToolParam(required = true, description = "代理商 id;代理商仅限自己或直属下级") Long id) {
User user = AuthContextHolder.get().getUser();
LambdaQueryWrapper<Agent> wrapper = new LambdaQueryWrapper<Agent>()
.eq(Agent::getSoftDelete, 0);
permissionService.applyAgentListRowFilter(wrapper, user, id);
Agent agent = agentMapper.selectOne(wrapper);
Map<String, Object> result = new LinkedHashMap<>();
if (agent == null) {
result.put("record", null);
result.put("invoices", List.of());
result.put("notice", "id=" + id + " 不存在或当前身份无权查看");
return result;
}
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Map<String, Object> record = new LinkedHashMap<>();
record.put("id", agent.getId());
record.put("name", agent.getName());
record.put("shortName", agent.getShortName());
record.put("level", agent.getLevel());
record.put("channelNo", agent.getChannelNo());
record.put("serialnumber", agent.getSerialnumber());
record.put("supervisorName", resolveSupervisorName(agent));
record.put("province", agent.getProvince());
record.put("city", agent.getCity());
record.put("countyseat", agent.getCountyseat());
record.put("address", agent.getAddress());
record.put("agencyarea", agent.getAgencyarea());
record.put("contact", agent.getContact());
record.put("phone", agent.getPhone());
record.put("mail", agent.getMail());
record.put("saleName", agent.getSaleName());
record.put("memo", agent.getMemo());
record.put("createTime", agent.getCreateTime() == null ? null : sdf.format(agent.getCreateTime()));
// 默认发票镜像字段(与 invoice_info 中 default_flag=1 的抬头同步)
record.put("invoiceName", agent.getInvoiceName());
record.put("invoiceType", agent.getInvoiceType());
record.put("identificationNumber", agent.getIdentificationNumber());
record.put("accountName", agent.getAccountName());
record.put("accountNo", agent.getAccountNo());
record.put("accountBank", agent.getAccountBank());
record.put("invoiceAddress", agent.getInvoiceAddress());
record.put("invoiceContact", agent.getInvoiceContact());
record.put("lastInvoiceTime", agent.getLastInvoiceTime() == null ? null : sdf.format(agent.getLastInvoiceTime()));
// 纳税信息(agent 独有)
record.put("taxpayerType", agent.getTaxpayerType());
record.put("taxpayerPoint", agent.getTaxpayerPoint());
record.put("taxpayerServiceCharge", agent.getTaxpayerServiceCharge());
result.put("record", record);
result.put("invoices", InvoiceQueries.query(invoiceInfoMapper, id, 0));
return result;
}
private String resolveSupervisorName(Agent agent) {
if (agent.getSupervisorId() == null || agent.getSupervisorId() == 0) {
return "-";
}
Agent supervisor = agentMapper.selectById(agent.getSupervisorId());
return supervisor == null ? "-" : supervisor.getName();
}
}
@@ -0,0 +1,48 @@
package me.iiv.agent.mcp.tool;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import me.iiv.agent.entity.m0.InvoiceInfo;
import me.iiv.agent.mapper.m0.InvoiceInfoMapper;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* invoice_info 抬头查询助手(m0 库,type:0=代理 1=物业)。
* 默认条(defaultFlag=1)优先,其余按修改时间倒序。
*/
final class InvoiceQueries {
private InvoiceQueries() {
}
static List<Map<String, Object>> query(InvoiceInfoMapper mapper, Long customerId, int type) {
List<InvoiceInfo> invoices = mapper.selectList(new LambdaQueryWrapper<InvoiceInfo>()
.eq(InvoiceInfo::getSoftDelete, 0)
.eq(InvoiceInfo::getCustomerId, customerId)
.eq(InvoiceInfo::getType, type)
.orderByDesc(InvoiceInfo::getDefaultFlag)
.orderByDesc(InvoiceInfo::getModifyTime));
List<Map<String, Object>> rows = new ArrayList<>();
for (InvoiceInfo invoice : invoices) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", invoice.getId());
row.put("invoiceName", invoice.getInvoiceName());
row.put("invoiceType", invoice.getInvoiceType());
row.put("identificationNumber", invoice.getIdentificationNumber());
row.put("accountName", invoice.getAccountName());
row.put("accountNo", invoice.getAccountNo());
row.put("accountBank", invoice.getAccountBank());
row.put("invoiceAddress", invoice.getInvoiceAddress());
row.put("invoiceContact", invoice.getInvoiceContact());
row.put("contact", invoice.getContact());
row.put("mail", invoice.getMail());
row.put("defaultFlag", invoice.getDefaultFlag());
row.put("memo", invoice.getMemo());
rows.add(row);
}
return rows;
}
}
@@ -5,6 +5,7 @@ import me.iiv.agent.entity.m0.Agent;
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.InvoiceInfoMapper;
import me.iiv.agent.mapper.m0.ProperytMapper;
import me.iiv.agent.mapper.m0.UserMapper;
import me.iiv.agent.mcp.ToolObject;
@@ -37,13 +38,16 @@ public class PropertyTools implements ToolObject {
final ProperytMapper properytMapper;
final AgentMapper agentMapper;
final UserMapper userMapper;
final InvoiceInfoMapper invoiceInfoMapper;
final PermissionService permissionService;
public PropertyTools(ProperytMapper properytMapper, AgentMapper agentMapper,
UserMapper userMapper, PermissionService permissionService) {
UserMapper userMapper, InvoiceInfoMapper invoiceInfoMapper,
PermissionService permissionService) {
this.properytMapper = properytMapper;
this.agentMapper = agentMapper;
this.userMapper = userMapper;
this.invoiceInfoMapper = invoiceInfoMapper;
this.permissionService = permissionService;
}
@@ -149,4 +153,73 @@ public class PropertyTools implements ToolObject {
result.put("records", records);
return result;
}
/**
* 查询物业详情(含发票信息)。行权限:列表过滤语义 + 精确 id 叠加,
* 代理商/销售只见其有权限的物业,总部不限。
* invoices 来自 m0.invoice_info(type=1 + customer_id=id),含该物业名下全部抬头。
*/
@XqTool(category = "customer", kind = "read")
@Tool(name = "property.detail.query", description = "查询物业(客户)详情(含发票信息),数据来源 m0 库 properyt 表。"
+ "行级权限与 property.list.query 一致:代理商身份只能查 自己+直属下级代理+被授权代管代理 名下或被授权代管的物业;"
+ "销售部门非负责人只能查自己创建的及被授权代管代理名下的物业;总部身份可查任意合法 id。"
+ "返回 record(物业基础信息 + 默认发票镜像字段)和 invoices(该物业名下全部发票抬头,defaultFlag=1 为默认抬头,按默认优先+修改时间倒序)。"
+ "invoiceType 含义:0=企业单位 1=个人|非企业。id 不存在或无权限时 record 为 null 并在 notice 字段说明。")
public Map<String, Object> propertyDetailQuery(
@ToolParam(required = true, description = "物业 id;仅可查有行级权限的物业") Long id) {
User user = AuthContextHolder.get().getUser();
LambdaQueryWrapper<Properyt> wrapper = new LambdaQueryWrapper<Properyt>()
.eq(Properyt::getSoftDelete, 0)
.eq(Properyt::getId, id);
permissionService.applyPropertyRowFilter(wrapper, user);
Properyt properyt = properytMapper.selectOne(wrapper);
Map<String, Object> result = new LinkedHashMap<>();
if (properyt == null) {
result.put("record", null);
result.put("invoices", List.of());
result.put("notice", "id=" + id + " 不存在或当前身份无权查看");
return result;
}
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Map<String, Object> record = new LinkedHashMap<>();
record.put("id", properyt.getId());
record.put("name", properyt.getName());
record.put("serialnumber", properyt.getSerialnumber());
record.put("agentName", resolveAgentName(properyt));
record.put("province", properyt.getProvince());
record.put("city", properyt.getCity());
record.put("countyseat", properyt.getCountyseat());
record.put("address", properyt.getAddress());
record.put("contact", properyt.getContact());
record.put("phone", properyt.getPhone());
record.put("mail", properyt.getMail());
record.put("memo", properyt.getMemo());
record.put("districtOnCount", properyt.getDistrictOnCount());
record.put("hardOnCount", properyt.getHardOnCount());
record.put("userCount", properyt.getUserCount());
record.put("createTime", properyt.getCreateTime() == null ? null : sdf.format(properyt.getCreateTime()));
// 默认发票镜像字段(与 invoice_info 中 default_flag=1 的抬头同步)
record.put("invoiceName", properyt.getInvoiceName());
record.put("invoiceType", properyt.getInvoiceType());
record.put("identificationNumber", properyt.getIdentificationNumber());
record.put("accountName", properyt.getAccountName());
record.put("accountNo", properyt.getAccountNo());
record.put("accountBank", properyt.getAccountBank());
record.put("invoiceAddress", properyt.getInvoiceAddress());
record.put("invoiceContact", properyt.getInvoiceContact());
record.put("lastInvoiceTime", properyt.getLastInvoiceTime() == null ? null : sdf.format(properyt.getLastInvoiceTime()));
result.put("record", record);
result.put("invoices", InvoiceQueries.query(invoiceInfoMapper, id, 1));
return result;
}
private String resolveAgentName(Properyt properyt) {
if (properyt.getAgentId() == null) {
return "未查询到";
}
Agent agent = agentMapper.selectById(properyt.getAgentId());
return agent == null ? "未查询到" : agent.getName();
}
}
@@ -27,6 +27,8 @@ import java.util.Arrays;
* CODEGEN_SCHEMA_M0 默认 xiaoqu_comples_d(生产 xiaoqu_complex)
* CODEGEN_SCHEMA_M2 默认 xiaoqu_franchisee_d(生产 xiaoqu_franchisee)
* CODEGEN_SCHEMA_SVC 默认 xiaoqu_agent_svc_d(svc 表结构由 Flyway 管理,各环境一致,一般无需覆盖)
* 注意:properyt.customerId 是驼峰列名(dev/prod 一致),生成后必须保留实体上的
* @TableField("customerId") 手工补丁,否则运行时会映射成不存在的 customer_id。
*/
public class CodeGenerator {
@@ -38,7 +40,7 @@ public class CodeGenerator {
public static void main(String[] args) {
generate(envOr("CODEGEN_SCHEMA_M0", "xiaoqu_comples_d"), "m0",
"user", "agent", "authority", "properyt", "agent_manage_district_auth");
"user", "agent", "authority", "properyt", "agent_manage_district_auth", "invoice_info");
generate(envOr("CODEGEN_SCHEMA_M2", "xiaoqu_franchisee_d"), "m2",
"warehouse_agent_auth");
generate(envOr("CODEGEN_SCHEMA_SVC", "xiaoqu_agent_svc_d"), "svc",
@@ -200,6 +200,37 @@ class PermissionServiceTest {
assertTrue(wrapper.getParamNameValuePairs().values().contains(55L));
}
@Test
void propertyDetailKeepsExactIdAndAgentBoundary() {
// property.detail.query 的过滤形态:eq(id) 精确查询 + applyPropertyRowFilter 行权限
User user = user(1000L, 108L, 1L);
when(authorityMapper.selectById(108L)).thenReturn(authority(108L, 1));
LambdaQueryWrapper<Properyt> wrapper = new LambdaQueryWrapper<Properyt>()
.eq(Properyt::getId, 55L);
permissionService.applyPropertyRowFilter(wrapper, user);
String sql = wrapper.getSqlSegment();
Collection<Object> values = wrapper.getParamNameValuePairs().values();
assertTrue(sql.contains("id ="));
assertTrue(sql.contains("agent_id"));
assertTrue(values.contains(55L));
assertTrue(values.contains(1L));
}
@Test
void headquartersPropertyDetailKeepsOnlyExactId() {
User user = user(46L, 1L, 0L);
when(authorityMapper.selectById(1L)).thenReturn(authority(1L, 0));
LambdaQueryWrapper<Properyt> wrapper = new LambdaQueryWrapper<Properyt>()
.eq(Properyt::getId, 55L);
permissionService.applyPropertyRowFilter(wrapper, user);
assertTrue(wrapper.getSqlSegment().contains("id ="));
assertFalse(wrapper.getSqlSegment().contains("agent_id"));
}
private static Authority authority(Long id, int isAgent) {
Authority authority = new Authority();
authority.setId(id);