Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0692320dea | ||
|
|
7c2b159aba | ||
|
|
aa4cc26416 | ||
|
|
686ce2daf8 | ||
|
|
b8cc1992b7 | ||
|
|
6b2edcf06c | ||
|
|
da60b0ab99 | ||
|
|
0009c47abb | ||
|
|
dc2367e1cf |
@@ -0,0 +1,39 @@
|
||||
name: xiaoqu-agent-service CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- "xiaoqu-agent-service/**"
|
||||
- ".gitea/workflows/xiaoqu-agent-service-ci.yml"
|
||||
|
||||
concurrency:
|
||||
group: xiaoqu-agent-service-ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: self-hosted
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: xiaoqu-agent-service
|
||||
steps:
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init .
|
||||
git remote remove origin 2>/dev/null || true
|
||||
git remote add origin https://gitea.ququclean.com/wangheng/xiaoqu_mcp_service.git
|
||||
git -c credential.helper= \
|
||||
-c 'credential.helper=!f() { test "$1" = get || exit 0; printf "%s\n" "username=x-access-token" "password=$GITHUB_TOKEN"; }; f "$@"' \
|
||||
fetch --depth=1 origin "${{ github.sha }}"
|
||||
git checkout --force --detach FETCH_HEAD
|
||||
|
||||
- name: Test
|
||||
run: ./mvnw test
|
||||
@@ -9,5 +9,6 @@ front/dist/
|
||||
|
||||
# 本地部署凭据与运行态
|
||||
deploy/.env.dev
|
||||
deploy/remote.conf
|
||||
deploy/app.pid
|
||||
logs/
|
||||
|
||||
@@ -90,6 +90,99 @@ do_status() {
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------- 远程部署 ----------
|
||||
REMOTE_CONF="$APP_HOME/deploy/remote.conf"
|
||||
|
||||
load_remote_conf() {
|
||||
if [ ! -f "$REMOTE_CONF" ]; then
|
||||
echo "[ERROR] 缺少 $REMOTE_CONF,请复制 deploy/remote.conf.example 并填写" >&2
|
||||
exit 1
|
||||
fi
|
||||
set -a; source "$REMOTE_CONF"; set +a
|
||||
: "${REMOTE_SSH_HOST:?未配置 REMOTE_SSH_HOST}"
|
||||
: "${REMOTE_DIR:?未配置 REMOTE_DIR}"
|
||||
REMOTE_SSH_PORT="${REMOTE_SSH_PORT:-22}"
|
||||
REMOTE_PROFILE="${REMOTE_PROFILE:-prod}"
|
||||
REMOTE_PORT="${REMOTE_PORT:-8088}"
|
||||
REMOTE_JAVA_OPTS="${REMOTE_JAVA_OPTS:-}"
|
||||
REMOTE_SPRING_ARGS="${REMOTE_SPRING_ARGS:-}"
|
||||
REMOTE_JAVA_BIN="${REMOTE_JAVA_BIN:-java}"
|
||||
}
|
||||
|
||||
ssh_remote() { ssh -p "$REMOTE_SSH_PORT" "$REMOTE_SSH_HOST" "$@"; }
|
||||
|
||||
do_push() {
|
||||
[ -f "$JAR" ] || { echo "[ERROR] 未找到 jar,先执行: $0 build" >&2; exit 1; }
|
||||
load_remote_conf
|
||||
ssh_remote "mkdir -p '$REMOTE_DIR/logs'"
|
||||
scp -P "$REMOTE_SSH_PORT" "$JAR" "$REMOTE_SSH_HOST:$REMOTE_DIR/xiaoqu-agent-service.jar.new"
|
||||
echo "[OK] 已上传到 $REMOTE_SSH_HOST:$REMOTE_DIR/xiaoqu-agent-service.jar.new"
|
||||
}
|
||||
|
||||
# 在远程执行 停旧→(可选)换包→启动→健康检查;$1: 1=换上新传的 jar,0=仅重启现有 jar
|
||||
run_remote_script() {
|
||||
load_remote_conf
|
||||
ssh_remote "REMOTE_DIR='$REMOTE_DIR' REMOTE_PROFILE='$REMOTE_PROFILE' REMOTE_PORT='$REMOTE_PORT' REMOTE_JAVA_OPTS='$REMOTE_JAVA_OPTS' REMOTE_SPRING_ARGS='$REMOTE_SPRING_ARGS' REMOTE_JAVA_BIN='$REMOTE_JAVA_BIN' DEPLOY_NEW='$1' bash -s" <<'EOS'
|
||||
set -euo pipefail
|
||||
cd "$REMOTE_DIR"
|
||||
if [ -f app.pid ] && kill -0 "$(cat app.pid)" 2>/dev/null; then
|
||||
kill "$(cat app.pid)"
|
||||
for i in $(seq 1 15); do kill -0 "$(cat app.pid)" 2>/dev/null || break; sleep 1; done
|
||||
kill -0 "$(cat app.pid)" 2>/dev/null && kill -9 "$(cat app.pid)"
|
||||
fi
|
||||
if [ "$DEPLOY_NEW" = "1" ]; then
|
||||
[ -f xiaoqu-agent-service.jar ] && cp xiaoqu-agent-service.jar "xiaoqu-agent-service.jar.bak.$(date +%Y%m%d%H%M%S)"
|
||||
mv -f xiaoqu-agent-service.jar.new xiaoqu-agent-service.jar
|
||||
fi
|
||||
if [ -f app.env ]; then
|
||||
set -a; source app.env; set +a
|
||||
else
|
||||
echo "[WARN] 未找到 $REMOTE_DIR/app.env,环境变量未注入,prd 可能启动失败" >&2
|
||||
fi
|
||||
nohup "$REMOTE_JAVA_BIN" $REMOTE_JAVA_OPTS -jar xiaoqu-agent-service.jar --spring.profiles.active="$REMOTE_PROFILE" $REMOTE_SPRING_ARGS >> logs/agent-service.log 2>&1 &
|
||||
echo $! > app.pid
|
||||
echo "[OK] 远程已启动 (pid $(cat app.pid)),等待健康检查..."
|
||||
for i in $(seq 1 90); do
|
||||
if ! kill -0 "$(cat app.pid)" 2>/dev/null; then
|
||||
echo "[ERROR] 远程进程已退出,最后 40 行日志:" >&2
|
||||
tail -40 logs/agent-service.log >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "http://127.0.0.1:$REMOTE_PORT/mcp" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Accept: application/json, text/event-stream' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"deploy-check","version":"0"}}}' || true)
|
||||
if [ "$code" = "200" ] || [ "$code" = "401" ]; then
|
||||
echo "[OK] 远程服务已就绪 (HTTP $code, /mcp)"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "[ERROR] 远程健康检查超时,日志: $REMOTE_DIR/logs/agent-service.log" >&2
|
||||
exit 1
|
||||
EOS
|
||||
}
|
||||
|
||||
do_remote_deploy() {
|
||||
do_build
|
||||
do_push
|
||||
run_remote_script 1
|
||||
}
|
||||
|
||||
do_remote_restart() {
|
||||
run_remote_script 0
|
||||
}
|
||||
|
||||
do_remote_stop() {
|
||||
load_remote_conf
|
||||
ssh_remote "cd '$REMOTE_DIR' && if [ -f app.pid ] && kill -0 \$(cat app.pid) 2>/dev/null; then kill \$(cat app.pid) && rm -f app.pid && echo '[OK] 远程已停止'; else echo '[SKIP] 远程未在运行'; fi"
|
||||
}
|
||||
|
||||
do_remote_status() {
|
||||
load_remote_conf
|
||||
ssh_remote "cd '$REMOTE_DIR' && if [ -f app.pid ] && kill -0 \$(cat app.pid) 2>/dev/null; then echo \"远程运行中 (pid \$(cat app.pid))\"; else echo '远程未运行'; exit 1; fi"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
build) do_build ;;
|
||||
start) do_start ;;
|
||||
@@ -97,7 +190,13 @@ case "${1:-}" in
|
||||
restart) do_stop; do_start ;;
|
||||
status) do_status ;;
|
||||
log) tail -f "$LOG_FILE" ;;
|
||||
push) do_push ;;
|
||||
remote-deploy) do_remote_deploy ;;
|
||||
remote-restart) do_remote_restart ;;
|
||||
remote-stop) do_remote_stop ;;
|
||||
remote-status) do_remote_status ;;
|
||||
remote-log) load_remote_conf; ssh_remote "tail -f '$REMOTE_DIR/logs/agent-service.log'" ;;
|
||||
*)
|
||||
echo "用法: $0 {build|start|stop|restart|status|log}" >&2
|
||||
echo "用法: $0 {build|start|stop|restart|status|log|push|remote-deploy|remote-restart|remote-stop|remote-status|remote-log}" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# 远程部署配置(本文件为模板;实际使用复制为 remote.conf,已 gitignore,不入库)
|
||||
# 用法: cp deploy/remote.conf.example deploy/remote.conf,填好后执行 ./deploy/deploy.sh remote-deploy
|
||||
|
||||
# --- SSH 连接(必填)---
|
||||
# 推荐先在 ~/.ssh/config 配置别名和密钥,这里直接填别名,避免明文密码
|
||||
# 例如: REMOTE_SSH_HOST=xiaoqu-prd 或 REMOTE_SSH_HOST=deploy@192.168.1.100
|
||||
REMOTE_SSH_HOST=
|
||||
REMOTE_SSH_PORT=22
|
||||
|
||||
# --- 远程目录(必填)---
|
||||
# 远程服务器上的部署目录,jar、pid、日志都放在这里
|
||||
REMOTE_DIR=/opt/xiaoqu/agent-service
|
||||
|
||||
# --- 运行参数 ---
|
||||
# Spring profile(对应 application-<profile>.yml;若远程没有对应 yml,
|
||||
# 用下面的 REMOTE_SPRING_ARGS 覆盖具体配置项)
|
||||
REMOTE_PROFILE=prod
|
||||
# 服务端口(健康检查用)
|
||||
REMOTE_PORT=8088
|
||||
|
||||
# JVM 参数(可选)
|
||||
REMOTE_JAVA_OPTS="-Xms512m -Xmx1024m"
|
||||
|
||||
# java 命令路径(可选,默认用 PATH 里的 java;远程有多个 JDK 时建议显式指定 17)
|
||||
# REMOTE_JAVA_BIN=/usr/lib/jvm/jre-17-openjdk/bin/java
|
||||
REMOTE_JAVA_BIN=
|
||||
|
||||
# 追加给 Spring Boot 的启动参数(可选),用于覆盖远程环境配置,例如:
|
||||
# REMOTE_SPRING_ARGS=--spring.datasource.url=jdbc:mysql://xxx:3306/db --spring.datasource.password=xxx
|
||||
REMOTE_SPRING_ARGS=
|
||||
|
||||
# 远程服务器要求: JDK 17+、curl(健康检查用)
|
||||
@@ -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 工具,证明链路连通)。
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ xiaoqu-ai iOS / Android / Web
|
||||
▼
|
||||
xiaoqu-ai Gateway / Agent Runtime ───┐
|
||||
│ 用户 MCP Token(仅服务端持有)
|
||||
xiaoqu-operations-ai iOS / Android │
|
||||
xiaoqu-operations-ai iOS / Android / Web
|
||||
│ 产品登录 Token │
|
||||
▼ │
|
||||
ops-assistant-service ───────────────┤
|
||||
@@ -28,7 +28,7 @@ ops-assistant-service ───────────────┤
|
||||
|---|---|---|---|
|
||||
| 总体 | xiaoqu-mcp | 当前仓库 `xiaoqu-agent-service` | 本文 |
|
||||
| 详细设计 | xiaoqu-ai iOS、Android、Web、服务端 Agent | `/Users/tonylai/xiaoquai/xiaoqu-ai`、`/Users/tonylai/jiaxk/xiaoqu-ai-android` | [`xiaoqu-ai-mcp-integration-design.md`](./xiaoqu-ai-mcp-integration-design.md) |
|
||||
| 详细设计 | xiaoqu-operations-ai iOS、Android、服务端 Agent | `/Users/tonylai/jiaxk/xiaoqu-operations-ai` | [`xiaoqu-operations-ai-mcp-integration-design.md`](./xiaoqu-operations-ai-mcp-integration-design.md) |
|
||||
| 详细设计 | xiaoqu-operations-ai iOS、Android、Web、服务端 Agent | `/Users/tonylai/wangheng/xiaoqu-operations-ai` | [`xiaoqu-operations-ai-mcp-integration-design.md`](./xiaoqu-operations-ai-mcp-integration-design.md) |
|
||||
|
||||
本文只定义两个产品必须共同遵守的架构和安全契约。项目代码落点、配置、Capability 映射和项目级验收只写在对应详细设计中。
|
||||
|
||||
@@ -124,7 +124,7 @@ Accept: application/json, text/event-stream
|
||||
|---|---|---|
|
||||
| 1 | 确认两个产品的用户映射与实际 scope | scope 已存在并绑定所需工具,不依赖客户端身份字段 |
|
||||
| 2 | 完成 xiaoqu-ai 服务端接入 | iOS、Android、Web 经同一服务端适配器调用 |
|
||||
| 3 | 完成 xiaoqu-operations-ai 服务端接入 | 运营 iOS、Android 经 ops-assistant-service 调用 |
|
||||
| 3 | 完成 xiaoqu-operations-ai 服务端接入 | 运营 iOS、Android、Web 经 ops-assistant-service 调用 |
|
||||
| 4 | 跨用户、跨租户和跨产品回归 | 无 Token 泄露、串号、越权或副作用自动重放 |
|
||||
|
||||
## 8. 总体验收与回退
|
||||
|
||||
@@ -2,111 +2,180 @@
|
||||
|
||||
## 1. 目标与边界
|
||||
|
||||
本文是 [`xiaoqu 多产品 Agent 统一接入 xiaoqu-mcp 总体设计`](./xiaoqu-mcp-multi-product-agent-design.md) 的运营项目落地设计。
|
||||
本文是 [`xiaoqu 多产品 Agent 统一接入 xiaoqu-mcp 总体设计`](./xiaoqu-mcp-multi-product-agent-design.md) 的运营项目落地设计,代码基线为 `/Users/tonylai/wangheng/xiaoqu-operations-ai`。
|
||||
|
||||
`ops-assistant-service` 是运营项目唯一 MCP Client,同时服务运营 iOS 和 Android,并保留现有编排:
|
||||
运营 iOS、Android、Web 共用 `ops-assistant-service` 中的一套 MCP Client:
|
||||
|
||||
```text
|
||||
运营 iOS / Android
|
||||
iOS / Android / Web
|
||||
│ 产品登录 Token
|
||||
▼
|
||||
Router → Planner → Executor → Answer
|
||||
│
|
||||
▼
|
||||
xiaoqu-mcp
|
||||
ops-assistant-service REST
|
||||
│
|
||||
▼
|
||||
Router → Resolver → Planner → Executor → Answer
|
||||
│
|
||||
▼
|
||||
Capability Registry
|
||||
│ 固定 adapter_id
|
||||
▼
|
||||
xiaoqu-mcp Adapter
|
||||
│ 用户 MCP Token(仅服务端持有)
|
||||
▼
|
||||
xiaoqu-agent-service /mcp
|
||||
```
|
||||
|
||||
客户端不新增 MCP 代码。项目目录:`/Users/tonylai/jiaxk/xiaoqu-operations-ai`。
|
||||
WebSocket 只负责会话进度和完成通知,不承载 MCP 调用。客户端不新增 MCP 代码,不保存用户 MCP Token、MCP Session ID 或 `X-Open-Key`。
|
||||
|
||||
## 2. 当前代码依据
|
||||
|
||||
- `services/ops-assistant-service` 是 FastAPI + Python 3.12 服务。
|
||||
- 当前依赖中没有 MCP SDK,也没有 `tools/list` / `tools/call` 客户端。
|
||||
- `src/ops_assistant_service/capability_registry.py` 维护静态 `CAPABILITY_REGISTRY`。
|
||||
- `src/ops_assistant_service/executor.py` 当前执行 `customer_issues.*`、`smartclean.query` 等本地 Capability。
|
||||
- 用户身份来自 Gateway JWT,并通过 `ops_identity_mappings` 解析租户、用户和 `scope_tags`。
|
||||
- 最小接入点是 Executor 的工具适配层,不新增第二套编排器。
|
||||
- `web/ops-assistant/src/api.ts` 通过 `/api/v1/ops-assistant/*` REST 提交消息和读取业务数据。
|
||||
- `web/ops-assistant/src/ConversationPage.tsx` 使用 WebSocket 订阅会话进度,消息提交仍走 REST。
|
||||
- `services/ops-assistant-service` 是 FastAPI + Python 3.12 服务,现有 `httpx` 足够实现 MCP HTTP Client。
|
||||
- `worker.py` 同时运行旧流水线和 `contextual_fulfillment_v2`;新流水线包含 Router、Resolver、Planner、Recovery、Executor、Answer。
|
||||
- `capability_registry.py` 维护静态 `CAPABILITY_REGISTRY`、冻结的 Capability Contract 和 `adapter_id` 白名单。
|
||||
- 旧 `executor.py` 与新 `contextual_execution.py` 最终都调用 `execute_registered_tool()`。
|
||||
- 当前 Registry 包含 `customer_issues.*`、`vision_analyze`、`work_items.list`、`read_only_todos.list`、`knowledge_entries.search`,已不包含 `smartclean.query`。
|
||||
- Gateway JWT 在入口解析为共享身份,再通过 `ops_identity_mappings` 映射为运营 `tenant_id`、`user_id` 和 DataScope;后台 Worker 执行时使用运营 UUID。
|
||||
|
||||
## 3. 配置设计
|
||||
因此不在 Web、路由层或某一条流水线中增加专用 MCP 分支。唯一接入点是两套流水线共用的 Capability Adapter 执行边界。
|
||||
|
||||
在 `src/ops_assistant_service/config.py` 的 `Settings` 中增加以下 `OPS_ASSISTANT_` 配置:
|
||||
## 3. 设计决策
|
||||
|
||||
### 3.1 统一 Adapter
|
||||
|
||||
在 `CAPABILITY_REGISTRY` 中注册稳定的本地 Capability 和固定 `adapter_id`,由 `execute_registered_tool()` 将 MCP Adapter ID 路由到 `XiaoquMcpAdapter`。
|
||||
|
||||
```text
|
||||
Planner 选择本地 Capability
|
||||
→ 校验冻结的 Capability Contract
|
||||
→ 校验输入 schema、scope_tags、DataScope
|
||||
→ execute_registered_tool(adapter_id)
|
||||
→ XiaoquMcpAdapter
|
||||
→ MCP tools/call
|
||||
```
|
||||
|
||||
不得把远端 `tools/list` 原样注册给 Planner。`tools/list` 只用于启动或联调时验证远端工具是否与本地冻结契约一致。
|
||||
|
||||
### 3.2 显式依赖传递
|
||||
|
||||
`XiaoquMcpRuntime` 由 `worker.build_pipeline_runtime()` 创建,并通过显式参数传给旧 `run_executor_tick` 和新 `run_contextual_executor_tick`。禁止用模块级全局变量或从请求上下文隐式读取用户。
|
||||
|
||||
`PipelineRuntime.close()` 负责关闭 MCP HTTP Client 和清理进程内 Session 缓存。未启用 MCP 时传入 `None`,现有本地 Capability 保持原行为。
|
||||
|
||||
## 4. 配置设计
|
||||
|
||||
在 `src/ops_assistant_service/config.py` 的 `Settings` 增加以下 `OPS_ASSISTANT_` 配置:
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `XQ_MCP_ENABLED` | `false` | 是否启用 MCP Capability |
|
||||
| `XQ_MCP_URL` | unset | xiaoqu-mcp 内网地址 |
|
||||
| `XQ_MCP_OPEN_KEY` | unset | Token 初始化凭证,类型使用 `SecretStr` |
|
||||
| `XQ_MCP_SCOPE_CODE` | `operation` | 服务端固定 scope,禁止由 Planner 或客户端选择 |
|
||||
| `XQ_MCP_OPEN_KEY` | unset | Token 初始化凭证,使用 `SecretStr` |
|
||||
| `XQ_MCP_SCOPE_CODE` | `operation` | 服务端固定 scope,禁止由客户端或模型选择 |
|
||||
| `XQ_MCP_TIMEOUT_SECONDS` | `10` | MCP 请求超时 |
|
||||
|
||||
启用时必须同时提供 URL、Open Key 和 scope。`operation` 已存在于 dev 种子,其他环境启用前仍需核对实际 scope 和工具绑定;生产配置不得写入仓库。
|
||||
启用时 URL、Open Key 和 scope 必填。`operation` 已存在于 xiaoqu-mcp dev 种子;development、test、staging、production 均须分别核对真实 scope 和工具绑定。凭证只由部署 Secret 注入。
|
||||
|
||||
## 4. 最小模块
|
||||
## 5. 最小模块
|
||||
|
||||
只新增两个模块:
|
||||
### 5.1 `mcp_token_provider.py`
|
||||
|
||||
- `src/ops_assistant_service/mcp_client.py`
|
||||
- 使用现有 `httpx` 完成 JSON-RPC、Session ID、Bearer Header、超时和错误映射。
|
||||
- 按租户、`user_id` 和 scope 隔离 MCP Session。
|
||||
- `src/ops_assistant_service/mcp_token_provider.py`
|
||||
- 接收已验证的 `SharedIdentity`。
|
||||
- 从受信任用户资料源取得 mobile/email 并调用 `ensure`。
|
||||
- 按租户、`user_id` 和 scope 缓存 Token;401 时只刷新当前用户一次。
|
||||
- 不接受客户端或 Planner 传入的身份字段作为覆盖参数。
|
||||
输入为 Worker 已持有的运营 `tenant_id`、`user_id` 和固定 scope:
|
||||
|
||||
不引入通用 MCP 框架、不新增 Agent,也不直连 xiaoqu-mcp 数据库。
|
||||
1. 从 `ops_identity_mappings` 反查可信的 `shared_tenant_id`、`shared_user_id`。
|
||||
2. 通过现有只读共享用户 Port 获取权威 mobile;权威数据源存在 email 时可作为备用。
|
||||
3. 使用固定 `scopeCode` 调用 `/api/open/token/ensure`。
|
||||
4. 按运营租户、运营用户、共享用户和 scope 缓存 Token。
|
||||
|
||||
## 5. Capability 映射
|
||||
现有 `SharedUserPort` 尚未暴露 mobile/email,需要最小扩展其只读 DTO 和 PostgreSQL Adapter;不得复制用户表,也不得使用 Web、Planner、工具参数或模型输出中的身份字段。
|
||||
|
||||
在 `capability_registry.py` 注册固定的本地 Capability,不把远端 `tools/list` 原样暴露给 Planner:
|
||||
### 5.2 `mcp_client.py`
|
||||
|
||||
| 本地 Capability | MCP 工具 | 首期权限 |
|
||||
|---|---|---|
|
||||
| `xiaoqu.agent_count` | `agent.count` | read |
|
||||
| `xiaoqu.agent_list` | `agent.list.query` | read |
|
||||
| `xiaoqu.property_list` | `property.list.query` | read |
|
||||
使用现有 `httpx.Client` 管理:
|
||||
|
||||
Planner 只能选择注册表中的名称和 schema。Executor 在调用前执行本地 Capability、用户 `scope_tags`、租户/DataScope 校验;xiaoqu-mcp 再执行 Token scope 和服务端数据权限校验。
|
||||
- `initialize`、`notifications/initialized`、`tools/list`、`tools/call`。
|
||||
- Bearer Header、`Mcp-Session-Id`、JSON-RPC ID 和响应校验。
|
||||
- 连接、读取、写入和总超时。
|
||||
- 按运营租户、运营用户和 scope 隔离 Session。
|
||||
- 关闭时释放 HTTP Client,不持久化 MCP Session。
|
||||
|
||||
远端结果经过字段白名单投影后再进入 Answer。Token、Authorization Header 和未授权字段不得进入 execution trace。
|
||||
### 5.3 `mcp_adapter.py`
|
||||
|
||||
## 6. Executor 接入
|
||||
`XiaoquMcpAdapter` 只接收已经验证的 Capability Contract、运营身份和参数:
|
||||
|
||||
在 `executor.py` 增加 MCP 只读分支:
|
||||
- 根据固定映射选择远端 MCP 工具。
|
||||
- 调用 Token Provider 和 MCP Client。
|
||||
- 校验远端响应并执行字段白名单投影。
|
||||
- 返回现有 Executor/Answer 可消费的字典,不返回 Token、Header 或原始未授权字段。
|
||||
|
||||
不引入通用 MCP 框架或第二套 Agent 编排器。
|
||||
|
||||
## 6. Capability 映射
|
||||
|
||||
首期只注册只读 Capability:
|
||||
|
||||
| 本地 Capability | `adapter_id` | MCP 工具 | 权限 |
|
||||
|---|---|---|---|
|
||||
| `xiaoqu.agent_count` | `mcp.xiaoqu.agent_count.v1` | `agent.count` | read |
|
||||
| `xiaoqu.agent_list` | `mcp.xiaoqu.agent_list.v1` | `agent.list.query` | read |
|
||||
| `xiaoqu.property_list` | `mcp.xiaoqu.property_list.v1` | `property.list.query` | read |
|
||||
|
||||
每项必须在 `CAPABILITY_REGISTRY` 中声明输入/输出 schema、风险、审批、超时重试、审计事件和结果模板,并将精确 `adapter_id` 加入 `EXECUTION_ADAPTER_IDS`。已有本地 Capability 不改名、不改 Adapter、不改变执行语义。
|
||||
|
||||
## 7. 身份与权限
|
||||
|
||||
最终授权取交集:
|
||||
|
||||
```text
|
||||
validate_tool_input
|
||||
→ 校验本地 Capability 和参数白名单
|
||||
→ 读取 SharedIdentity / DataScope
|
||||
→ mcp_token_provider 获取当前用户 Token
|
||||
→ mcp_client 调用 tools/call
|
||||
→ 结果字段投影
|
||||
→ 现有 execution_trace / Answer
|
||||
本地 Capability allowlist
|
||||
∩ 当前运营用户 scope_tags / DataScope
|
||||
∩ MCP Token scope
|
||||
∩ xiaoqu-mcp 用户、租户和行级数据范围
|
||||
```
|
||||
|
||||
现有 `customer_issues.*` 和 `smartclean.query` 保持不变。MCP 不可用时返回稳定的 `TOOL_UNAVAILABLE`,不得回退公共 Token 或数据库直连。
|
||||
- REST 和 WebSocket 只负责验证产品登录态并建立运营身份。
|
||||
- Planner 只能选择 Registry 中的 Capability,不决定用户、租户或 scope。
|
||||
- Worker 必须在每次执行前确认 `ops_identity_mappings` 仍有效,不能仅依赖消息入库时的旧快照。
|
||||
- `tools/list` 与 `tools/call` 使用同一用户 Token 和 MCP Session;服务端在两个入口都强制鉴权。
|
||||
|
||||
## 7. 开发顺序
|
||||
## 8. 异步执行与生命周期
|
||||
|
||||
1. 核对目标环境中的 `operation` scope、工具绑定和用户资料映射。
|
||||
2. 增加 Settings 配置及启用校验。
|
||||
3. 实现 `mcp_token_provider.py` 与 `mcp_client.py`,覆盖 JSON-RPC 和 Session 单元测试。
|
||||
4. 注册 3 个只读 Capability 并接入 Executor。
|
||||
5. 增加用户隔离、scope 拒绝、401 重连、Token 脱敏和 MCP 不可用测试。
|
||||
6. 在 development/test 环境联调,通过后再进入 staging。
|
||||
Web、iOS、Android 提交消息后获得 `202`;Worker 在后台完成 Agent 和工具执行,再由消息查询或 WebSocket 返回进度与结果。
|
||||
|
||||
## 8. 项目验收
|
||||
- `build_pipeline_runtime()` 创建一个进程级 `XiaoquMcpRuntime`,但 Token 和 Session 按用户隔离。
|
||||
- Worker 重启后 Session 不恢复;首次调用重新 `ensure` 和 `initialize`。
|
||||
- 用户映射失效、scope 版本变化或 MCP 401 时,只清理该用户缓存。
|
||||
- MCP 401:重新 `ensure` 和连接一次;只读调用最多重试一次。
|
||||
- MCP 403:直接返回权限不足,不刷新 Token。
|
||||
- 超时、协议错误或服务不可用:映射为稳定的 `TOOL_UNAVAILABLE`,并让当前 Execution Attempt 进入可审计失败状态。
|
||||
- 不自动重放写操作;首期不接写工具。
|
||||
|
||||
- 运营 iOS、Android 继续只调用 `ops-assistant-service`,客户端无 MCP Token。
|
||||
## 9. 开发顺序
|
||||
|
||||
1. 核对各环境 `operation` scope、三项工具绑定和用户映射数据源。
|
||||
2. 扩展只读 SharedUser Port,完成运营 UUID 到 mobile/email 的可信解析测试。
|
||||
3. 增加 Settings 配置、启用校验和 `XiaoquMcpRuntime` 生命周期。
|
||||
4. 实现 Token Provider、MCP Client 和 Adapter 的协议单元测试。
|
||||
5. 注册三项本地 Capability,并让旧 Executor 与 contextual v2 共用同一 Adapter。
|
||||
6. 覆盖用户隔离、租户隔离、scope 拒绝、401、403、超时、脱敏和 Worker 重启测试。
|
||||
7. 分别从 iOS、Android、Web 提交对话请求,验证异步进度和最终结果一致。
|
||||
|
||||
## 10. 项目验收
|
||||
|
||||
- iOS、Android、Web 均只调用 `ops-assistant-service`,客户端没有 MCP Token、Session ID 或 Open Key。
|
||||
- Web 消息走 REST,WebSocket 仅订阅进度/完成通知。
|
||||
- 旧流水线和 `contextual_fulfillment_v2` 对同一 MCP Capability 使用同一个 Adapter。
|
||||
- 用户 A/B、不同租户和 scope 的 Token 与 MCP Session 不互用。
|
||||
- Planner 不能选择未注册的远程工具,Executor 不能绕过 `scope_tags` 和 DataScope。
|
||||
- `tools/list`、`tools/call` 使用同一用户 Session 和 Bearer Token。
|
||||
- MCP 401 只刷新当前用户;只读请求最多重试一次。
|
||||
- 测试、日志、trace、配置和 Git 历史中均无凭证明文。
|
||||
- Planner 不能选择未注册工具;冻结 Contract、`scope_tags`、DataScope 或 MCP scope 任一不满足即拒绝。
|
||||
- 现有七项本地 Capability 的契约和行为不受影响。
|
||||
- MCP 401 只刷新当前用户;MCP 不可用时不回退公共 Token、数据库直连或 xiaoqu-ai Agent。
|
||||
- 测试、日志、trace、配置和 Git 历史中无凭证明文。
|
||||
|
||||
## 9. 当前不做
|
||||
## 11. 当前不做
|
||||
|
||||
- 不把 MCP Client 放进运营 iOS 或 Android。
|
||||
- 不在运营 iOS、Android、Web 中实现 MCP Client。
|
||||
- 不动态注册全部远程工具。
|
||||
- 不在首期引入写工具;写工具需另行设计审批、幂等和补偿协议。
|
||||
- 不复制 xiaoqu-mcp 数据库,不调用 xiaoqu-ai Agent 代查数据。
|
||||
- 不恢复或假定已删除的 `smartclean.query`。
|
||||
- 不复制 xiaoqu-mcp 或共享用户数据库。
|
||||
- 不在首期引入写工具;写工具需另行审批幂等、确认和补偿协议。
|
||||
|
||||
@@ -15,7 +15,7 @@ import java.util.Date;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@@ -62,21 +62,11 @@ public class Agent implements Serializable {
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 渠道编号(Z:总部;F:分公司;D:代理)
|
||||
*/
|
||||
private String channelNo;
|
||||
|
||||
/**
|
||||
* 上级id
|
||||
*/
|
||||
private Long supervisorId;
|
||||
|
||||
/**
|
||||
* 上级代理
|
||||
*/
|
||||
private String supervisorName;
|
||||
|
||||
/**
|
||||
* 企业地址
|
||||
*/
|
||||
@@ -137,6 +127,11 @@ public class Agent implements Serializable {
|
||||
*/
|
||||
private Integer level;
|
||||
|
||||
/**
|
||||
* 渠道编号(Z:总部;F:分公司;D:代理)
|
||||
*/
|
||||
private String channelNo;
|
||||
|
||||
/**
|
||||
* 户名
|
||||
*/
|
||||
@@ -192,6 +187,36 @@ public class Agent implements Serializable {
|
||||
*/
|
||||
private String shortName;
|
||||
|
||||
/**
|
||||
* 金蝶供应商编号
|
||||
*/
|
||||
private String supplierNo;
|
||||
|
||||
/**
|
||||
* 开票通讯地址
|
||||
*/
|
||||
private String invoiceAddress;
|
||||
|
||||
/**
|
||||
* 开票联系电话
|
||||
*/
|
||||
private String invoiceContact;
|
||||
|
||||
/**
|
||||
* 组织内码
|
||||
*/
|
||||
private String saleOrgInternalId;
|
||||
|
||||
/**
|
||||
* 客户分组
|
||||
*/
|
||||
private String custerGroupNo;
|
||||
|
||||
/**
|
||||
* 金蝶内码
|
||||
*/
|
||||
private Long jindieInternalId;
|
||||
|
||||
/**
|
||||
* 金蝶客户编号
|
||||
*/
|
||||
@@ -212,41 +237,16 @@ public class Agent implements Serializable {
|
||||
*/
|
||||
private String saleOrgId;
|
||||
|
||||
/**
|
||||
* 金蝶内码
|
||||
*/
|
||||
private Long jindieInternalId;
|
||||
|
||||
/**
|
||||
* 组织内码
|
||||
*/
|
||||
private String saleOrgInternalId;
|
||||
|
||||
/**
|
||||
* 客户分组
|
||||
*/
|
||||
private String custerGroupNo;
|
||||
|
||||
/**
|
||||
* 金蝶供应商编号
|
||||
*/
|
||||
private String supplierNo;
|
||||
|
||||
/**
|
||||
* 开票通讯地址
|
||||
*/
|
||||
private String invoiceAddress;
|
||||
|
||||
/**
|
||||
* 开票联系电话
|
||||
*/
|
||||
private String invoiceContact;
|
||||
|
||||
/**
|
||||
* 金蝶客户名称
|
||||
*/
|
||||
private String jinideName;
|
||||
|
||||
/**
|
||||
* 上级代理
|
||||
*/
|
||||
private String supervisorName;
|
||||
|
||||
/**
|
||||
* 最后开票时间
|
||||
*/
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ import java.util.Date;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
|
||||
@@ -15,7 +15,7 @@ import java.util.Date;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
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 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;
|
||||
@TableField("buildCount")
|
||||
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;
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import java.util.Date;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@@ -28,15 +28,10 @@ public class Properyt implements Serializable {
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 销售id
|
||||
* 创建id
|
||||
*/
|
||||
private Long createId;
|
||||
|
||||
/**
|
||||
* 销售名称
|
||||
*/
|
||||
private String createName;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@@ -62,11 +57,6 @@ public class Properyt implements Serializable {
|
||||
*/
|
||||
private Integer type;
|
||||
|
||||
/**
|
||||
* 金蝶客户名称
|
||||
*/
|
||||
private String jinideName;
|
||||
|
||||
/**
|
||||
* 企业名称
|
||||
*/
|
||||
@@ -77,11 +67,6 @@ public class Properyt implements Serializable {
|
||||
*/
|
||||
private Long agentId;
|
||||
|
||||
/**
|
||||
* 代理名称
|
||||
*/
|
||||
private String agentName;
|
||||
|
||||
/**
|
||||
* 企业地址
|
||||
*/
|
||||
@@ -178,14 +163,14 @@ public class Properyt implements Serializable {
|
||||
private String invoiceName;
|
||||
|
||||
/**
|
||||
* 金蝶客户编号
|
||||
* 身份编码
|
||||
*/
|
||||
private String identifier;
|
||||
|
||||
/**
|
||||
* 金蝶内码
|
||||
* 金蝶客户名称
|
||||
*/
|
||||
private Long jindieInternalId;
|
||||
private String jinideName;
|
||||
|
||||
/**
|
||||
* 开票通讯地址
|
||||
@@ -197,6 +182,21 @@ public class Properyt implements Serializable {
|
||||
*/
|
||||
private String invoiceContact;
|
||||
|
||||
/**
|
||||
* 金蝶内码
|
||||
*/
|
||||
private Long jindieInternalId;
|
||||
|
||||
/**
|
||||
* 销售名称
|
||||
*/
|
||||
private String createName;
|
||||
|
||||
/**
|
||||
* 代理名称
|
||||
*/
|
||||
private String agentName;
|
||||
|
||||
/**
|
||||
* 最后开票时间
|
||||
*/
|
||||
|
||||
@@ -15,7 +15,7 @@ import java.util.Date;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@@ -43,7 +43,7 @@ public class User implements Serializable {
|
||||
private Date modifyTime;
|
||||
|
||||
/**
|
||||
* 数据状态(0:启用:1:删除)
|
||||
* 数据状态
|
||||
*/
|
||||
private Integer softDelete;
|
||||
|
||||
@@ -72,6 +72,11 @@ public class User implements Serializable {
|
||||
*/
|
||||
private String password;
|
||||
|
||||
/**
|
||||
* 代理ID
|
||||
*/
|
||||
private Long agentId;
|
||||
|
||||
/**
|
||||
* 所属公司名称
|
||||
*/
|
||||
@@ -103,7 +108,7 @@ public class User implements Serializable {
|
||||
private Integer approle;
|
||||
|
||||
/**
|
||||
* 系统权限0:管理权限;1:普通权限
|
||||
* 角色类别(管理员,主管,其他)
|
||||
*/
|
||||
private Integer roleType;
|
||||
|
||||
@@ -112,11 +117,6 @@ public class User implements Serializable {
|
||||
*/
|
||||
private Long districtId;
|
||||
|
||||
/**
|
||||
* 代理ID
|
||||
*/
|
||||
private Long agentId;
|
||||
|
||||
/**
|
||||
* 物业ID
|
||||
*/
|
||||
@@ -128,7 +128,7 @@ public class User implements Serializable {
|
||||
private Long supervisorId;
|
||||
|
||||
/**
|
||||
* 考勤状态0:上班;1:下班;2:请假;3休息
|
||||
* 工作状态
|
||||
*/
|
||||
private Integer workStatus;
|
||||
|
||||
@@ -142,8 +142,6 @@ public class User implements Serializable {
|
||||
*/
|
||||
private String email;
|
||||
|
||||
private String emailMask;
|
||||
|
||||
/**
|
||||
* 身份证号
|
||||
*/
|
||||
@@ -175,7 +173,7 @@ public class User implements Serializable {
|
||||
private Integer backHardFlag;
|
||||
|
||||
/**
|
||||
* 权限id
|
||||
* 权限组id
|
||||
*/
|
||||
private Long authorityId;
|
||||
|
||||
@@ -227,7 +225,7 @@ public class User implements Serializable {
|
||||
private String modifyName;
|
||||
|
||||
/**
|
||||
* 物业权限id
|
||||
* 物业平台权限组id
|
||||
*/
|
||||
private Long propertyAuthorityId;
|
||||
|
||||
@@ -237,48 +235,24 @@ public class User implements Serializable {
|
||||
private String dingUserId;
|
||||
|
||||
/**
|
||||
* 工作中状态0:作业;1:转场;2:空闲;3:休息
|
||||
* 工作状态
|
||||
*/
|
||||
private Integer workAtStatus;
|
||||
|
||||
/**
|
||||
* 年龄
|
||||
*/
|
||||
private Integer age;
|
||||
|
||||
/**
|
||||
* 试用期保护0:未启用;1:启用
|
||||
*/
|
||||
private Integer probationProtection;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
private String note;
|
||||
|
||||
/**
|
||||
* 上班时间
|
||||
*/
|
||||
private String onTime;
|
||||
|
||||
/**
|
||||
* 下班时间
|
||||
*/
|
||||
private String offTime;
|
||||
|
||||
/**
|
||||
* 出纸机权限
|
||||
*/
|
||||
private Integer czjAuth;
|
||||
|
||||
/**
|
||||
* 空感权限
|
||||
*/
|
||||
private Integer kgAuth;
|
||||
|
||||
/**
|
||||
* 信标权限
|
||||
*/
|
||||
private Integer xbAuth;
|
||||
|
||||
/**
|
||||
@@ -311,16 +285,6 @@ public class User implements Serializable {
|
||||
*/
|
||||
private Integer productOpAuth;
|
||||
|
||||
/**
|
||||
* 金蝶人员销售订单编号
|
||||
*/
|
||||
private String jindieSalerid;
|
||||
|
||||
/**
|
||||
* 泡沫机APP显示0:关闭;1:打开;
|
||||
*/
|
||||
private Integer foamFunctionAuth;
|
||||
|
||||
/**
|
||||
* 金蝶内码
|
||||
*/
|
||||
@@ -351,6 +315,16 @@ public class User implements Serializable {
|
||||
*/
|
||||
private Integer flowAuth;
|
||||
|
||||
/**
|
||||
* 泡沫机APP显示0:关闭;1:打开;
|
||||
*/
|
||||
private Integer foamFunctionAuth;
|
||||
|
||||
/**
|
||||
* 金蝶人员销售订单编号
|
||||
*/
|
||||
private String jindieSalerid;
|
||||
|
||||
/**
|
||||
* xiaoqu-ai 用户ID
|
||||
*/
|
||||
@@ -360,44 +334,4 @@ public class User implements Serializable {
|
||||
* 同步到 xiaoqu-ai 的时间
|
||||
*/
|
||||
private Date xiaoquAiSyncedAt;
|
||||
|
||||
/**
|
||||
* 0待回填/1已解析/2歧义/3悬空;property_id非空→初始UPDATE=1
|
||||
*/
|
||||
private Byte tenantResolveStatus;
|
||||
|
||||
/**
|
||||
* 0正常/1身份待核(冲突fail-closed锁B3人证比对;⊥tenant_resolve_status,不进PEP单路)
|
||||
*/
|
||||
private Byte idcardResolveStatus;
|
||||
|
||||
/**
|
||||
* AES-256-GCM(IV‖ct‖tag);canonical后加密;明文绝不落盘
|
||||
*/
|
||||
private byte[] idcardEnc;
|
||||
|
||||
/**
|
||||
* keyed HMAC-SHA256(canonical idcard);等值/dedup/唯一
|
||||
*/
|
||||
private byte[] idcardBidx;
|
||||
|
||||
/**
|
||||
* 存储预算掩码 前3后4(默认零解密展示)
|
||||
*/
|
||||
private String idcardMask;
|
||||
|
||||
/**
|
||||
* AES-256-GCM
|
||||
*/
|
||||
private byte[] mobileEnc;
|
||||
|
||||
/**
|
||||
* keyed HMAC-SHA256(canonical mobile);等值查找(非唯一)
|
||||
*/
|
||||
private byte[] mobileBidx;
|
||||
|
||||
/**
|
||||
* 展示掩码 前3后4
|
||||
*/
|
||||
private String mobileMask;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import java.util.Date;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
|
||||
@@ -16,7 +16,7 @@ import java.util.Date;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@@ -38,7 +38,7 @@ public class AdminUser implements Serializable {
|
||||
private String account;
|
||||
|
||||
/**
|
||||
* PBKDF2 加盐密码哈希
|
||||
* PBKDF2 加盐密码哈希
|
||||
*/
|
||||
private String passwordHash;
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import java.util.Date;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@@ -48,7 +48,7 @@ public class AgentApiKey implements Serializable {
|
||||
private String keyHash;
|
||||
|
||||
/**
|
||||
* ak 明文 AES-GCM 加密密文(二次获取用),空=历史数据未存
|
||||
* ak 明文 AES-GCM 加密密文(Base64: iv‖cipher‖tag),空=历史数据未存
|
||||
*/
|
||||
private String keyCipher;
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import java.util.Date;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
|
||||
@@ -16,7 +16,7 @@ import java.util.Date;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
|
||||
@@ -16,7 +16,7 @@ import java.util.Date;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
|
||||
@@ -16,7 +16,7 @@ import java.util.Date;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
|
||||
@@ -15,7 +15,7 @@ import java.util.Date;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import me.iiv.agent.entity.m0.AgentManageDistrictAuth;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
public interface AgentManageDistrictAuthMapper extends BaseMapper<AgentManageDistrictAuth> {
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import me.iiv.agent.entity.m0.Agent;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
public interface AgentMapper extends BaseMapper<Agent> {
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import me.iiv.agent.entity.m0.Authority;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
public interface AuthorityMapper extends BaseMapper<Authority> {
|
||||
|
||||
|
||||
+7
@@ -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<DistrictDistrictStrategy> {
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import me.iiv.agent.entity.m0.Properyt;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
public interface ProperytMapper extends BaseMapper<Properyt> {
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import me.iiv.agent.entity.m0.User;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
public interface UserMapper extends BaseMapper<User> {
|
||||
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import me.iiv.agent.entity.m2.WarehouseAgentAuth;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
public interface WarehouseAgentAuthMapper extends BaseMapper<WarehouseAgentAuth> {
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import me.iiv.agent.entity.svc.AdminUser;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
public interface AdminUserMapper extends BaseMapper<AdminUser> {
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import me.iiv.agent.entity.svc.AgentApiKey;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
public interface AgentApiKeyMapper extends BaseMapper<AgentApiKey> {
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import me.iiv.agent.entity.svc.AgentScope;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
public interface AgentScopeMapper extends BaseMapper<AgentScope> {
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import me.iiv.agent.entity.svc.AuditLog;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
public interface AuditLogMapper extends BaseMapper<AuditLog> {
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import me.iiv.agent.entity.svc.ScopeTool;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
public interface ScopeToolMapper extends BaseMapper<ScopeTool> {
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import me.iiv.agent.entity.svc.TokenScope;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
public interface TokenScopeMapper extends BaseMapper<TokenScope> {
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import me.iiv.agent.entity.svc.Tool;
|
||||
* </p>
|
||||
*
|
||||
* @author codegen
|
||||
* @since 2026-09-03
|
||||
* @since 2026-09-08
|
||||
*/
|
||||
public interface ToolMapper extends BaseMapper<Tool> {
|
||||
|
||||
|
||||
@@ -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<String, Object> 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<DistrictDistrictStrategy> wrapper = new LambdaQueryWrapper<DistrictDistrictStrategy>()
|
||||
.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<DistrictDistrictStrategy> districts = districtMapper.selectList(wrapper);
|
||||
|
||||
Map<Long, String> propertyNames = propertyNames(districts);
|
||||
Map<Long, String> agentNames = agentNames(districts);
|
||||
List<Map<String, Object>> records = new ArrayList<>();
|
||||
for (DistrictDistrictStrategy district : districts) {
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> districtDetailGet(
|
||||
@ToolParam(description = "站点 id,正整数") Long id) {
|
||||
if (id == null) {
|
||||
throw new IllegalArgumentException("id 必填");
|
||||
}
|
||||
validatePositive(id, "id");
|
||||
User user = AuthContextHolder.get().getUser();
|
||||
LambdaQueryWrapper<DistrictDistrictStrategy> wrapper = new LambdaQueryWrapper<DistrictDistrictStrategy>()
|
||||
.eq(DistrictDistrictStrategy::getSoftDelete, 0)
|
||||
.eq(DistrictDistrictStrategy::getId, id);
|
||||
permissionService.applyDistrictRowFilter(wrapper, user);
|
||||
DistrictDistrictStrategy district = districtMapper.selectOne(wrapper);
|
||||
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> 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<Long, String> propertyNames(List<DistrictDistrictStrategy> districts) {
|
||||
List<Long> 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<Long, String> agentNames(List<DistrictDistrictStrategy> districts) {
|
||||
List<Long> 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<Long, String> 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 + " 必须为正整数");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<DistrictDistrictStrategy> wrapper, User user) {
|
||||
if (!isAgentAuthority(user.getAuthorityId())) {
|
||||
return;
|
||||
}
|
||||
if (user.getAgentId() == null) {
|
||||
throw new IllegalStateException("代理商用户未关联代理商,拒绝访问业务数据");
|
||||
}
|
||||
|
||||
List<Long> ids = new ArrayList<>(subordinateAndSelfIds(user));
|
||||
List<WarehouseAgentAuth> agentlist = managedAuths(user.getId());
|
||||
ids.addAll(agentlist.stream().map(WarehouseAgentAuth::getManagerAgentId).collect(Collectors.toList()));
|
||||
|
||||
if (isSalesNonLeader(user)) {
|
||||
List<Long> propertyIds = properytMapper.selectList(new LambdaQueryWrapper<Properyt>()
|
||||
.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<Properyt>()
|
||||
.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<Long> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# prd 环境:阿里云 RDS / Redis(拓扑沿用 xiaoqu-franchisee pom.xml prd profile)
|
||||
# 敏感值一律走环境变量,不写明文、不入库;
|
||||
# 服务器上由 /data/xiaoqu-agent-service/app.env 注入(deploy.sh 启动前 source)
|
||||
app:
|
||||
open:
|
||||
api-key: ${OPEN_API_KEY}
|
||||
# ak 明文 AES-GCM 加密密钥,prd 必须与 dev 不同值
|
||||
token-cipher-key: ${TOKEN_CIPHER_KEY}
|
||||
datasource:
|
||||
m0: # 权限库 xiaoqu_complex(user/agent/authority 等)
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
jdbc-url: ${SVC_DB_M0_URL}
|
||||
username: ${SVC_DB_M0_USER}
|
||||
password: ${SVC_DB_M0_PASSWORD}
|
||||
m1: # 商城库 xiaoqu_mall(按需启用)
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
jdbc-url: ${SVC_DB_M1_URL}
|
||||
username: ${SVC_DB_M1_USER}
|
||||
password: ${SVC_DB_M1_PASSWORD}
|
||||
m2: # 业务库 xiaoqu_franchisee
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
jdbc-url: ${SVC_DB_M2_URL}
|
||||
username: ${SVC_DB_M2_USER}
|
||||
password: ${SVC_DB_M2_PASSWORD}
|
||||
svc: # 自有库 xiaoqu_agent_svc(token/scope/tool/audit,读写,Flyway 自动建表)
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
jdbc-url: ${SVC_DB_SVC_URL}
|
||||
username: ${SVC_DB_SVC_USER}
|
||||
password: ${SVC_DB_SVC_PASSWORD}
|
||||
|
||||
spring:
|
||||
data:
|
||||
redis:
|
||||
host: ${SVC_REDIS_HOST}
|
||||
port: ${SVC_REDIS_PORT:6379}
|
||||
password: ${SVC_REDIS_PASSWORD}
|
||||
database: ${SVC_REDIS_DB:5}
|
||||
timeout: 5s
|
||||
@@ -21,24 +21,35 @@ import java.util.Arrays;
|
||||
* 运行方式:
|
||||
* ./mvnw -q test-compile org.codehaus.mojo:exec-maven-plugin:3.5.0:java \
|
||||
* -Dexec.mainClass=me.iiv.agent.codegen.CodeGenerator -Dexec.classpathScope=test
|
||||
*
|
||||
* 环境变量(按线上库生成时覆盖):
|
||||
* CODEGEN_DB_BASE_URL 默认 jdbc:mysql://192.168.1.181:3306/(生产可走 SSH 隧道 jdbc:mysql://127.0.0.1:13306/)
|
||||
* 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 管理,各环境一致,一般无需覆盖)
|
||||
*/
|
||||
public class CodeGenerator {
|
||||
|
||||
private static final String BASE_URL = "jdbc:mysql://192.168.1.181:3306/";
|
||||
private static final String BASE_URL = envOr("CODEGEN_DB_BASE_URL", "jdbc:mysql://192.168.1.181:3306/");
|
||||
private static final String URL_PARAMS = "?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true";
|
||||
private static final String USERNAME = requiredEnv("CODEGEN_DB_USERNAME");
|
||||
private static final String PASSWORD = requiredEnv("CODEGEN_DB_PASSWORD");
|
||||
private static final String OUTPUT_DIR = System.getProperty("user.dir") + "/src/main/java";
|
||||
|
||||
public static void main(String[] args) {
|
||||
generate("xiaoqu_comples_d", "m0",
|
||||
generate(envOr("CODEGEN_SCHEMA_M0", "xiaoqu_comples_d"), "m0",
|
||||
"user", "agent", "authority", "properyt", "agent_manage_district_auth");
|
||||
generate("xiaoqu_franchisee_d", "m2",
|
||||
generate(envOr("CODEGEN_SCHEMA_M2", "xiaoqu_franchisee_d"), "m2",
|
||||
"warehouse_agent_auth");
|
||||
generate("xiaoqu_agent_svc_d", "svc",
|
||||
generate(envOr("CODEGEN_SCHEMA_SVC", "xiaoqu_agent_svc_d"), "svc",
|
||||
"agent_api_key", "agent_scope", "tool", "scope_tool", "token_scope", "audit_log", "admin_user");
|
||||
}
|
||||
|
||||
private static String envOr(String name, String defaultValue) {
|
||||
String value = System.getenv(name);
|
||||
return value == null || value.isBlank() ? defaultValue : value;
|
||||
}
|
||||
|
||||
private static String requiredEnv(String name) {
|
||||
String value = System.getenv(name);
|
||||
if (value == null || value.isBlank()) {
|
||||
@@ -64,8 +75,10 @@ public class CodeGenerator {
|
||||
.addInclude(Arrays.asList(tables))
|
||||
.entityBuilder()
|
||||
.enableLombok()
|
||||
.enableFileOverride()
|
||||
.idType(IdType.AUTO)
|
||||
.mapperBuilder()
|
||||
.enableFileOverride()
|
||||
.serviceBuilder()
|
||||
.disableService()
|
||||
.disableServiceImpl()
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
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<String, String> 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<String, Object> 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<String, Object> record = ((List<Map<String, Object>>) 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<String, Object> 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<String, Object> result = tools.districtDetailGet(9L);
|
||||
|
||||
assertTrue((Boolean) result.get("found"));
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> data = (Map<String, Object>) 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<String, String> 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 districtEntityKeepsLegacyBuildCountColumnName() {
|
||||
assertEquals("buildCount", TableInfoHelper.getTableInfo(DistrictDistrictStrategy.class).getFieldList().stream()
|
||||
.filter(field -> "buildCount".equals(field.getProperty()))
|
||||
.findFirst().orElseThrow().getColumn());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listRejectsUnknownDistrictStateBeforeQuerying() {
|
||||
assertThrows(IllegalArgumentException.class, () -> tools.districtListQuery(
|
||||
null, null, 4, null, null, null, null, 1, 20));
|
||||
|
||||
verifyNoInteractions(districtMapper);
|
||||
}
|
||||
}
|
||||
@@ -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<DistrictDistrictStrategy> wrapper = new LambdaQueryWrapper<>();
|
||||
permissionService.applyDistrictRowFilter(wrapper, user);
|
||||
|
||||
String sql = wrapper.getSqlSegment();
|
||||
Collection<Object> 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<DistrictDistrictStrategy> 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<DistrictDistrictStrategy> 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);
|
||||
|
||||
Reference in New Issue
Block a user