Compare commits

..

12 Commits

Author SHA1 Message Date
jacy d9e101b287 merge(dev_jacy): 企微 MCP 增加开发计划版本与任务查询 2026-09-08 17:38:11 +08:00
jacy 5d0beb5733 feat(wecom): 企微 MCP 增加开发计划版本与任务查询
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 17:37:41 +08:00
jacy 41f000b364 merge(dev_jacy): 修复 MCP Factory 注入 2026-09-08 17:01:41 +08:00
jacy d0d5c9ac56 fix(wecom): 修复 MCP Factory 注入失败导致 API 无法启动
WecomPluginQueryService 不能用 import type,否则 Nest 拿不到 design:type。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 17:01:30 +08:00
jacy e00ba3fc4d merge(dev_jacy): 企微插件 MCP 端点 2026-09-08 16:58:01 +08:00
jacy 976bde37cc feat(wecom): 企微 API 插件增加 MCP Streamable HTTP 端点
与 REST/OpenAPI 并存,按实例权限自动 tools/list,免手填工具。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 16:57:42 +08:00
jacy 7006feff73 merge(dev_jacy): sync to dev 2026-09-08 14:32:29 +08:00
jacy 375e105fce merge(dev_jacy): sync to dev 2026-09-08 13:48:59 +08:00
jacy e3a27256ba merge(dev_jacy): v4.0.18 门店多收款账户与子账号继承二维码 2026-09-07 15:56:28 +08:00
jacy 1677386c60 merge(dev_jacy): sync to dev 2026-09-06 09:37:15 +08:00
jacy 5ae1816414 Merge pull request 'v4.0.17 小程序版本优化' (#72) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/72
2026-09-03 23:31:22 +08:00
jacy 9e0db27326 Merge pull request 'Dev jacy' (#70) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/70
2026-09-03 21:32:14 +08:00
24 changed files with 1595 additions and 64 deletions
@@ -19,8 +19,10 @@ import {
import type { ColumnsType } from 'antd/es/table';
import {
WECOM_PLUGIN_API_KEY_HEADER,
WECOM_PLUGIN_MCP_TRANSPORT,
WECOM_PLUGIN_PERMISSION_GROUPS,
WECOM_PLUGIN_PERMISSION_LABELS,
resolveWecomPluginMcpUrl,
resolveWecomPluginPublicUrl,
type WecomApiPluginDto,
type WecomApiPluginSecretDto,
@@ -53,6 +55,7 @@ export default function WecomApiPluginsPage() {
const [revealed, setRevealed] = useState<WecomApiPluginSecretDto | null>(null);
const pluginUrl = resolveWecomPluginPublicUrl(window.location.hostname);
const mcpUrl = resolveWecomPluginMcpUrl(window.location.hostname);
const permissionsWatch = Form.useWatch('permissions', form) as WecomPluginPermission[] | undefined;
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
@@ -224,7 +227,7 @@ export default function WecomApiPluginsPage() {
{settingsModal}
<AdminListHeader
settings={settingsButton}
description="每个实例对应企微里一只 API 插件:共用 Base URL,用不同 X-Api-Key 区分,并按勾选权限放行工具。"
description="每个实例对应企微里一只插件:共用 URL,用不同 X-Api-Key 区分,并按勾选权限放行工具。优先配 MCP 插件;API 插件(OpenAPI)仍可用。"
actions={
<Button type="primary" onClick={openCreate}>
@@ -236,20 +239,24 @@ export default function WecomApiPluginsPage() {
type="info"
showIcon
style={{ marginBottom: 16 }}
message="填企微「添加 API 插件」第 1 步"
message="填企微「添加插件」"
description={
<div>
<div>
URL
MCP URL{WECOM_PLUGIN_MCP_TRANSPORT}
<Typography.Text copyable>{mcpUrl}</Typography.Text>
</div>
<div>
API URL OpenAPI
<Typography.Text copyable>{pluginUrl}</Typography.Text>
</div>
<div>
OpenAPI Key
OpenAPIAPI 2 Key
<Typography.Text copyable>{`${pluginUrl}/openapi.json`}</Typography.Text>
</div>
<Typography.Text type="secondary">
Service token / API keyHeader {WECOM_PLUGIN_API_KEY_HEADER}
2 GET + Query
MCP API 2 GET
</Typography.Text>
</div>
}
+129 -27
View File
@@ -1,28 +1,75 @@
# 企微 API 插件 · 配置手册
# 企微 API / MCP 插件 · 配置手册
> 面向运营/技术:在企微后台「智能机器人 → 添加 API 插件」逐步填表。
> 后端前缀:`/api/v1/wecom/plugin` · 鉴权 Header `X-Api-Key` · 与 HQ 长连接 Bot **独立**。
> 密钥与权限在 HQ **企微机器人 → API 插件** 维护(v3.5.17+)。
> 面向运营/技术:在企微后台「智能机器人 → 添加插件」填表。
> 后端前缀:`/api/v1/wecom/plugin` · MCP`/api/v1/wecom/plugin/mcp` · 鉴权 Header `X-Api-Key` · 与 HQ 长连接 Bot **独立**。
> 密钥与权限在 HQ **企微机器人 → API 插件** 维护(v3.5.17+MCP 端点 v3.5.18)。
---
## 1. 推荐流程(优先 OpenAPI 导入
## 1. 推荐流程(MCP 插件
**不要 17 个工具全手填。** 推荐:
企微 5.0.6+ 支持 **MCP 插件**:自动 `tools/list` 拉工具,不必手填、也不必导入 OpenAPI。REST API 插件仍保留,已配好的机器人不用改。
1. HQ → **企微机器人 → API 插件** → 新建实例,勾选工具权限,复制 **Key**(只显示一次)。
2. 企微 **第 1 步**:填插件 URL + Header Key(见 §2)。
2. 企微工作台 → 智能机器人 → **插件****添加 MCP 插件**
- **插件 URL**:见下表(必须是企业域名,localhost 无法注入成员身份)
- **传输协议**Streamable HTTP
- **授权方式**Service token / API key
- **位置**Header
- **Parameter name**`X-Api-Key`
- **Service token**:该实例 Key
3. 企微会自动拉取已授权工具;把插件挂到「运营查询」类智能机器人即可。
4. 白名单会话试问:「查一下今日经营指标」「订单 DK…」。HQ **日志 → 智能机器人日志** 应出现 `botKey=plugin:{id}`
| 企微表单字段 | 测试环境 | 生产环境 |
|-------------|---------|---------|
| **MCP 插件 URL** | `https://api-test.dukanghaoke.com/api/v1/wecom/plugin/mcp` | `https://api.dukanghaoke.com/api/v1/wecom/plugin/mcp` |
| **传输协议** | Streamable HTTP | 同左 |
| **Header** | `X-Api-Key` | 同左 |
**自检(JSON-RPC,无 `{code,message,data}` 信封):**
```bash
# 无 Key → 401
curl -sS -o /dev/null -w '%{http_code}\n' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' \
"https://api-test.dukanghaoke.com/api/v1/wecom/plugin/mcp"
# 带 Keyinitialize
curl -sS -H "X-Api-Key: <你的Key>" -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":"curl","version":"1"}}}' \
"https://api-test.dukanghaoke.com/api/v1/wecom/plugin/mcp"
# 带 Keytools/list(仅含本实例已勾选权限)
curl -sS -H "X-Api-Key: <你的Key>" -H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
"https://api-test.dukanghaoke.com/api/v1/wecom/plugin/mcp"
```
若「添加插件」拉不到工具,再试传输协议 **SSE**(需运维给 `/api/v1/wecom/plugin/mcp``proxy_buffering`);第一期默认 Streamable HTTP 无状态即可。
---
## 2. 兼容流程(API 插件 + OpenAPI 导入)
**不要 17 个工具全手填。** API 插件推荐:
1. HQ 建实例并复制 Key(同上)。
2. 企微 **第 1 步**:填 API 插件 URL + Header Key(见 §3)。
3. 企微 **第 2 步****OpenAPI 导入**
- URL`{Base URL}/openapi.json`
- **同样带 Header `X-Api-Key`**(与第 1 步相同 Key
- 服务端按该 Key 的权限**只返回已授权路径**,与 HQ 勾选一致。
4. 导入后逐条核对 §3「全局规则」;类型不对时按 §5 手工修正。
4. 导入后逐条核对 §4「全局规则」;类型不对时按 §6 手工修正。
手填仅适用于:导入失败,或只加 2~3 个高频工具。
---
## 2. 第 1 步:添加 API 插件
## 3. 第 1 步:添加 API 插件
| 企微表单字段 | 测试环境 | 生产环境 |
|-------------|---------|---------|
@@ -43,7 +90,7 @@ HQ 页也会展示 Base URL 与 Header 名(不含密钥)。
---
## 3. 全局规则(调试不过先看这里)
## 4. 全局规则(调试不过先看这里)
| 项 | 正确 | 错误 |
|----|------|------|
@@ -69,20 +116,21 @@ HQ 页也会展示 Base URL 与 Header 名(不含密钥)。
---
## 4. HQ 权限与工具对照
## 5. HQ 权限与工具对照
只添加 HQ **已勾选**权限对应的工具;未授权返回 **403**
只添加 HQ **已勾选**权限对应的工具;未授权 REST 返回 **403**MCP 则不出现在 `tools/list`
| HQ 权限 | 路径 | 建议工具名 |
|---------|------|-----------|
| `metrics.read` | `/metrics` | 查询经营指标 |
| `order.read` | `/orders` | 查询订单 |
| `user.read` | `/users` | 查询用户 |
| `store.read` | `/stores` | 查询门店 |
| `redeem.read` | `/redeems` | 查询核销 |
| `promo.read` | `/promo-codes``/promo-codes/{code}/stats` | 查询推广码、推广码统计 |
| `store.audit.read` | `/store-audits``/store-info-audits``/store-info-audits/{id}``/store-package-audits``/store-package-audits/{id}` | 门店/信息/套餐审核 |
| `partner.read` | `/partners``/partners/{partnerId}/users|stores|orders` | 合伙人及关联数据 |
| HQ 权限 | REST 路径 | MCP 工具名 |
|---------|-----------|-----------|
| `metrics.read` | `/metrics` | `query_metrics` |
| `order.read` | `/orders` | `query_orders` |
| `user.read` | `/users` | `query_users` |
| `store.read` | `/stores` | `query_stores` |
| `redeem.read` | `/redeems` | `query_redeems` |
| `promo.read` | `/promo-codes``/promo-codes/{code}/stats` | `query_promo_codes``query_promo_code_stats` |
| `store.audit.read` | `/store-audits``/store-info-audits``/store-info-audits/{id}``/store-package-audits``/store-package-audits/{id}` | `query_store_audits` 等 5 个 |
| `partner.read` | `/partners``/partners/{partnerId}/users|stores|orders` | `query_partners` 等 4 个 |
| `dev_plan.read` | `/versions``/tasks` | `query_versions``query_tasks` |
**实例建议:**
@@ -91,10 +139,11 @@ HQ 页也会展示 Base URL 与 Header 名(不含密钥)。
| 日常运营 | metrics + order + user + store + promo + redeem |
| 审核值班 | 上表 + store.audit.read |
| 合伙人分析 | 上表 + partner.read |
| 开发计划 | `dev_plan.read`(已有实例需在 HQ 补勾才会出现这两个工具) |
---
## 5. 第 2 步:工具参数明细
## 6. 第 2 步:工具参数明细
以下均为 **GET**。Base URL 与 §2 相同,路径为相对路径。
@@ -332,7 +381,50 @@ HQ 页也会展示 Base URL 与 Header 名(不含密钥)。
---
## 6. OpenAPI 导入
### 5.16 查询开发版本
| 项 | 值 |
|----|-----|
| 权限 | `dev_plan.read` |
| 路径 | `/versions` |
| MCP | `query_versions` |
**输入(均可选):**
| 参数 | 位置 | 类型 | 模型可见 | 说明 |
|------|------|------|----------|------|
| q | Query | String | 是 | 版本号或说明,如 `v3.5.18` |
| status | Query | String | 是 | `PENDING` 待启动 / `IN_PROGRESS` 开发中 / `TESTING` 测试 / `RELEASED` 已上线 / `STOPPED` 已停止 |
| page / pageSize | Query | Integer | 否 | 分页 |
**`data.items[]`** versionNo、content、status、statusLabel、createdAt、releasedAt
---
### 5.17 查询开发任务
| 项 | 值 |
|----|-----|
| 权限 | `dev_plan.read` |
| 路径 | `/tasks` |
| MCP | `query_tasks` |
按**任务状态**筛选。可同时用 `q``versionNo`
**输入(均可选):**
| 参数 | 位置 | 类型 | 模型可见 | 说明 |
|------|------|------|----------|------|
| status | Query | String | **是** | `TODO` 待开发 / `IN_PROGRESS` 开发中 / `DEVELOPED` 已开发 / `RELEASED` 已上线 / `STOPPED` 已停止 |
| q | Query | String | 是 | 任务编号或内容 |
| versionNo | Query | String | 是 | 精确版本号,如 `v3.5.18`;传入则只返回该版本下任务 |
| page / pageSize | Query | Integer | 否 | 分页 |
**`data.items[]`** taskNo、content、type、typeLabel、status、statusLabel、creatorName、supportTicketNo、versions[]、createdAt、completedAt。不含附件 URL。
---
## 7. OpenAPI 导入
| 项 | 值 |
|----|-----|
@@ -350,7 +442,16 @@ curl -H "X-Api-Key: <Key>" \
---
## 7. 调试检查清单
## 8. 调试检查清单
### MCP 插件
- [ ] 无 Key / 错 Key → 401
- [ ] `initialize` + `tools/list` 仅含 HQ 已勾选权限对应工具
- [ ] 企微后台能自动拉到工具(Streamable HTTP
- [ ] 白名单会话能问到真实数据;HQ 日志 `botKey=plugin:{id}`
### API 插件(兼容)
- [ ] 第 1 步 Key 与 HQ 实例一致,实例 **已启用**
- [ ] curl 带 Key 返回 `code: 0`
@@ -363,7 +464,7 @@ curl -H "X-Api-Key: <Key>" \
---
## 8. 多实例与分工
## 9. 多实例与分工
| 企微插件 | HQ 实例 | 说明 |
|----------|---------|------|
@@ -373,8 +474,9 @@ curl -H "X-Api-Key: <Key>" \
---
## 9. 相关文档
## 10. 相关文档
- [杜康好客-v3.5.18-开发文档](./杜康好客-v3.5.18-开发文档.md) — MCP Streamable HTTP 端点
- [杜康好客-v3.5.17-开发文档](./杜康好客-v3.5.17-开发文档.md) — 表结构、Admin API、权限目录
- [杜康好客-v3.5.16-开发文档](./杜康好客-v3.5.16-开发文档.md) — 插件初版与 curl 示例
- [杜康好客-v3编码手册](./杜康好客-v3编码手册.md) — 编码验收一句
+3
View File
@@ -70,9 +70,12 @@
| 3.5.14 | [`提交订单/支付成功日志端回填 USER_MINI`](./杜康好客-v3.5.14-开发文档.md) | 🔶 开发完成 |
| 3.5.16 | [`企微智能机器人 API 插件`](./杜康好客-v3.5.16-开发文档.md) | 🔶 开发完成 |
| 3.5.17 | [`企微 API 插件迁入 HQ 后台`](./杜康好客-v3.5.17-开发文档.md) | 🔶 开发完成 |
| 3.5.18 | [`企微 API 插件 MCP 端点`](./杜康好客-v3.5.18-开发文档.md) | 🔶 开发完成 |
| 日期 | 说明 |
|------|------|
| 2026-09-08 | 企微 MCP/REST 插件增加 `dev_plan.read``query_versions` / `query_tasks`(按任务状态筛选) |
| 2026-09-08 | v3.5.18:企微插件增加 Streamable HTTP MCP 端点 `/api/v1/wecom/plugin/mcp`,与 REST/OpenAPI 并存 |
| 2026-09-08 | HQ「门店账户」主账号详情可 CRUD 店员子账号(`/admin/store-accounts/:id/staff` |
| 2026-09-06 | v3.5.17:企微 API 插件迁入 HQ「企微机器人 → API 插件」;多实例 Key + 工具权限;不再依赖 `WECOM_PLUGIN_*` env |
| 2026-09-03 | v3.5.16:企微 API 插件只读数据面 `GET /api/v1/wecom/plugin/*`X-Api-Key);与长连接 Bot 独立 |
+2 -1
View File
@@ -56,6 +56,7 @@
| `store.read` | `/stores`(名称搜索 + 评分/核销/累计核销/合伙人) |
| `store.audit.read` | `/store-audits``/store-info-audits``/store-package-audits``/{id}` 对比详情 |
| `partner.read` | `/partners``/partners/{id}/users|stores|orders`(可选 `from`/`to` 时间筛选) |
| `dev_plan.read` | `/versions``/tasks`MCP`query_versions``query_tasks`,任务可按 status 筛选) |
---
@@ -75,7 +76,7 @@
## 6. 企微侧怎么配
详见 **[企微 API 插件 · 配置手册](./企微API插件-配置手册.md)**(第 1 步插件、第 2 步工具/OpenAPI 导入、全接口参数表)
详见 **[企微 API 插件 · 配置手册](./企微API插件-配置手册.md)**。v3.5.18 起优先配 MCP 插件;本节 API + OpenAPI 仍可用
1. 插件 URL = HQ 页展示的 Base URL(全实例相同)
2. Header `X-Api-Key` = 该实例密钥
+51
View File
@@ -0,0 +1,51 @@
# 杜康好客 · v3.5.18 开发文档
> **2026-09-08** · integrations/wecom · admin-web · shared-types
> **主题**:企微智能机器人 **MCP 插件**Streamable HTTP),与 REST API 插件并存
---
## 1. 版本目标
企微后台「添加 MCP 插件」可对接本系统:托管大模型按 MCP `tools/list` / `tools/call` 拉数。不改长连接 Bot、不删 REST/OpenAPI。
**不做**:写操作、明文手机/地址、把 `/admin/*` JWT 暴露给企微、第一期 SSE 传输。
---
## 2. 端点
| 项 | 值 |
|----|-----|
| MCP URL | `/api/v1/wecom/plugin/mcp` |
| 传输 | Streamable HTTP,无状态(每请求新建 `McpServer` |
| 鉴权 | Header `X-Api-Key`(与 REST 同一套 HQ 实例) |
| 响应 | JSON-RPC**无** `{code,message,data}` 信封 |
| 工具结果 | `JSON.stringify(查询结果)` |
| 权限 | `tools/list` 只注册已勾选权限;QueryService 仍 `requirePerm` |
| 开发计划 | `dev_plan.read``query_versions``query_tasks`(任务按 TODO/IN_PROGRESS/DEVELOPED/RELEASED/STOPPED 筛选) |
生产:`https://api.dukanghaoke.com/api/v1/wecom/plugin/mcp`
测试:`https://api-test.dukanghaoke.com/api/v1/wecom/plugin/mcp`
---
## 3. 变更面
| 层 | 路径 |
|----|------|
| shared-types | `WECOM_PLUGIN_MCP_*``allowedWecomPluginMcpTools` |
| API | `wecom-plugin-mcp.*``WecomPluginMcpFactory`Interceptor skip MCP |
| HQ | `WecomApiPluginsPage` 展示 MCP URL |
| 文档 | [`企微API插件-配置手册.md`](./企微API插件-配置手册.md) |
---
## 4. 验收
- [ ] 无 Key / 错 Key → MCP 入口 401
- [ ] 带 Key `tools/list` 仅含该实例已勾选权限
- [ ] `tools/call` 查询口径与 REST 一致,结果无 REST 信封
- [ ] HQ 日志 `botKey=plugin:{id}`
- [ ] 现有 API 插件机器人行为不变
- [ ] 长连接 Bot 不变
+1 -1
View File
@@ -48,7 +48,7 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
**HQ 企微报告(v3.5.15**:企微机器人下「报告」与「消息推送」分开。日报/周报/月报各配 Webhook 与发送时刻;走群机器人 markdown。账期截在发送日北京 0 点(前一天 24 点),不含发送当天:日报=昨日存量+当日新增;周报/月报=上一自然周/月期末存量+本期新增。用户=有效未合并;合伙人=主账号;订单笔数与金额均排除待支付 / 已取消 / 退款中;订单金额=已付 `payAmount``paidAt`);核销=`RedeemRecord`
**企微 API 插件(v3.5.17**:实例在 HQ「企微机器人 → API 插件」维护;共用 `GET /api/v1/wecom/plugin/*`Header `X-Api-Key` 区分实例并按权限放行。经营指标含新增门店(`newStores`);门店搜索含经营数据;支持门店/信息/套餐审核对照只读查询;支持按合伙人查关联用户/门店/订单(`from`/`to` 时间筛选)。
**企微 API 插件(v3.5.17 / MCP v3.5.18**:实例在 HQ「企微机器人 → API 插件」维护;共用 Header `X-Api-Key`。REST `GET /api/v1/wecom/plugin/*` 与 MCP `POST /api/v1/wecom/plugin/mcp`Streamable HTTP 无状态)并存;MCP 按权限自动 `tools/list`。经营指标含新增门店(`newStores`);门店搜索含经营数据;支持门店/信息/套餐审核对照只读查询;支持按合伙人查关联用户/门店/订单(`from`/`to` 时间筛选)`dev_plan.read` 可查开发版本(`query_versions`)与按任务状态筛选的任务列表(`query_tasks`
**HQ 列表(v3.5.9**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)与列宽(拖表头)存 `hq_account.list_column_prefs`。主展示列下划线,点击进编辑或详情。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读(点击进详情);双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。
+1
View File
@@ -151,6 +151,7 @@ HQ 账号/角色(`hq-permissions`,生效=(角色∪追加)−撤销;可绑
|------|------|
| 智能机器人 | `/wecom/bots` 长连接指令 |
| API 插件 | HQ `/wecom/plugins` 多实例;`/api/v1/wecom/plugin` Header `X-Api-Key` 只读查询(与长连接独立) |
| MCP 插件 | 同上 Key`POST /api/v1/wecom/plugin/mcp` Streamable HTTP`tools/list` 按权限过滤;`dev_plan.read``query_versions` / `query_tasks` |
| 消息推送 | `/wecom/pushes` Webhook+eventKey |
| 日志 | `/logs/wecom-bots` |
| C 端微信客服 | 系统设置 `CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`;小程序须已关联该企业微信客服 |
@@ -2,10 +2,13 @@ import { describe, expect, it } from 'vitest';
import {
WECOM_PLUGIN_API_KEY_HEADER,
WECOM_PLUGIN_BASE_PATH,
WECOM_PLUGIN_MCP_PATH,
WECOM_PLUGIN_PATH_PERMISSION,
allowedWecomPluginMcpTools,
allowedWecomPluginOpenApiPaths,
maskWecomPluginApiKey,
parseWecomPluginPermissions,
resolveWecomPluginMcpUrl,
resolveWecomPluginPublicUrl,
wecomPluginHasPermission,
} from './wecom-plugin';
@@ -35,6 +38,15 @@ describe('resolveWecomPluginPublicUrl', () => {
it('uses X-Api-Key as the plugin auth header', () => {
expect(WECOM_PLUGIN_API_KEY_HEADER).toBe('X-Api-Key');
});
it('maps hosts to the Streamable HTTP MCP endpoint', () => {
expect(resolveWecomPluginMcpUrl('admin.dukanghaoke.com')).toBe(
`https://api.dukanghaoke.com${WECOM_PLUGIN_BASE_PATH}${WECOM_PLUGIN_MCP_PATH}`,
);
expect(resolveWecomPluginMcpUrl('admin-test.dukanghaoke.com')).toBe(
`https://api-test.dukanghaoke.com${WECOM_PLUGIN_BASE_PATH}${WECOM_PLUGIN_MCP_PATH}`,
);
});
});
describe('parseWecomPluginPermissions', () => {
@@ -76,4 +88,22 @@ describe('maskWecomPluginApiKey / path permission map', () => {
expect(wecomPluginHasPermission(['order.read'], 'order.read')).toBe(true);
expect(wecomPluginHasPermission(['order.read'], 'metrics.read')).toBe(false);
});
it('filters MCP tools by instance permissions', () => {
expect(allowedWecomPluginMcpTools(['order.read', 'user.read'])).toEqual([
'query_orders',
'query_users',
]);
expect(allowedWecomPluginMcpTools(['store.audit.read'])).toEqual([
'query_store_audits',
'query_store_info_audits',
'query_store_info_audit_detail',
'query_store_package_audits',
'query_store_package_audit_detail',
]);
expect(allowedWecomPluginMcpTools(['partner.read'])).toContain('query_partners');
expect(allowedWecomPluginMcpTools(['metrics.read'])).not.toContain('query_orders');
expect(allowedWecomPluginOpenApiPaths(['dev_plan.read'])).toEqual(['/versions', '/tasks']);
expect(allowedWecomPluginMcpTools(['dev_plan.read'])).toEqual(['query_versions', 'query_tasks']);
});
});
+114
View File
@@ -4,6 +4,11 @@ export const WECOM_PLUGIN_API_KEY_HEADER = 'X-Api-Key';
export const WECOM_PLUGIN_BASE_PATH = '/api/v1/wecom/plugin';
/** 企微 MCP 插件相对 Base 的路径(Streamable HTTP 无状态) */
export const WECOM_PLUGIN_MCP_PATH = '/mcp';
export const WECOM_PLUGIN_MCP_TRANSPORT = 'Streamable HTTP';
export const WECOM_PLUGIN_PAGE_SIZE_DEFAULT = 5;
export const WECOM_PLUGIN_PAGE_SIZE_MAX = 10;
@@ -21,6 +26,7 @@ export const WECOM_PLUGIN_PERMISSIONS = [
'metrics.read',
'store.audit.read',
'partner.read',
'dev_plan.read',
] as const;
export type WecomPluginPermission = (typeof WECOM_PLUGIN_PERMISSIONS)[number];
@@ -34,6 +40,7 @@ export const WECOM_PLUGIN_PERMISSION_LABELS: Record<WecomPluginPermission, strin
'metrics.read': '经营指标',
'store.audit.read': '门店/套餐审核',
'partner.read': '合伙人关联查询',
'dev_plan.read': '查询版本与任务',
};
export const WECOM_PLUGIN_PERMISSION_GROUPS: Array<{
@@ -59,6 +66,11 @@ export const WECOM_PLUGIN_PERMISSION_GROUPS: Array<{
label: '审核对照',
permissions: ['store.audit.read'],
},
{
key: 'dev_plan',
label: '开发计划',
permissions: ['dev_plan.read'],
},
];
/** OpenAPI path → 工具权限 */
@@ -79,6 +91,95 @@ export const WECOM_PLUGIN_PATH_PERMISSION: Record<string, WecomPluginPermission>
'/partners/{partnerId}/users': 'partner.read',
'/partners/{partnerId}/stores': 'partner.read',
'/partners/{partnerId}/orders': 'partner.read',
'/versions': 'dev_plan.read',
'/tasks': 'dev_plan.read',
};
export const WECOM_PLUGIN_MCP_TOOL_NAMES = [
'query_orders',
'query_users',
'query_stores',
'query_redeems',
'query_promo_codes',
'query_promo_code_stats',
'query_metrics',
'query_store_audits',
'query_store_info_audits',
'query_store_info_audit_detail',
'query_store_package_audits',
'query_store_package_audit_detail',
'query_partners',
'query_partner_users',
'query_partner_stores',
'query_partner_orders',
'query_versions',
'query_tasks',
] as const;
export type WecomPluginMcpToolName = (typeof WECOM_PLUGIN_MCP_TOOL_NAMES)[number];
export const WECOM_PLUGIN_MCP_TOOL_META: Record<
WecomPluginMcpToolName,
{ permission: WecomPluginPermission; description: string }
> = {
query_orders: { permission: 'order.read', description: '按订单号模糊查询' },
query_users: { permission: 'user.read', description: '按用户号或 11 位手机号查询;手机号脱敏' },
query_stores: {
permission: 'store.read',
description: '按门店名称模糊查询,返回经营数据:评分、核销笔数、累计核销好客权益、合伙人、审核状态',
},
query_redeems: { permission: 'redeem.read', description: '按核销单号或门店名查询' },
query_promo_codes: { permission: 'promo.read', description: '按推广码 code 或名称查询' },
query_promo_code_stats: { permission: 'promo.read', description: '按推广码 code 查询扫码/成交统计' },
query_metrics: {
permission: 'metrics.read',
description: '经营指标。kind=today 为今日截至当前;daily/weekly/monthly 与企微经营报告同一口径',
},
query_store_audits: { permission: 'store.audit.read', description: '门店入驻审核列表,默认 status=PENDING' },
query_store_info_audits: { permission: 'store.audit.read', description: '门店信息变更审核列表' },
query_store_info_audit_detail: {
permission: 'store.audit.read',
description: '门店信息变更审核对比,返回 live vs proposed',
},
query_store_package_audits: { permission: 'store.audit.read', description: '门店套餐审核列表' },
query_store_package_audit_detail: {
permission: 'store.audit.read',
description: '门店套餐审核对比,返回 proposedPackages 与 livePackages',
},
query_partners: { permission: 'partner.read', description: '按姓名、公司名、手机号或 ID 搜索合伙人主账号' },
query_partner_users: { permission: 'partner.read', description: '查询合伙人关联用户,可选 from/to' },
query_partner_stores: { permission: 'partner.read', description: '查询合伙人名下门店及经营数据,可选 from/to' },
query_partner_orders: {
permission: 'partner.read',
description: '查询佣金归属该合伙人的订单,可选 from/to',
},
query_versions: {
permission: 'dev_plan.read',
description: '按版本号关键词查询开发计划版本;可选 status 筛选版本状态 PENDING/IN_PROGRESS/TESTING/RELEASED/STOPPED',
},
query_tasks: {
permission: 'dev_plan.read',
description:
'查询开发任务列表。status 按任务状态筛选:TODO 待开发、IN_PROGRESS 开发中、DEVELOPED 已开发、RELEASED 已上线、STOPPED 已停止;可选 q 搜任务编号/内容,versionNo 限定某版本',
},
};
export const WECOM_PLUGIN_MCP_TOOLS: Record<WecomPluginPermission, WecomPluginMcpToolName[]> = {
'order.read': ['query_orders'],
'user.read': ['query_users'],
'store.read': ['query_stores'],
'redeem.read': ['query_redeems'],
'promo.read': ['query_promo_codes', 'query_promo_code_stats'],
'metrics.read': ['query_metrics'],
'store.audit.read': [
'query_store_audits',
'query_store_info_audits',
'query_store_info_audit_detail',
'query_store_package_audits',
'query_store_package_audit_detail',
],
'partner.read': ['query_partners', 'query_partner_users', 'query_partner_stores', 'query_partner_orders'],
'dev_plan.read': ['query_versions', 'query_tasks'],
};
export const WECOM_PLUGIN_TOOL_PATHS: Record<WecomPluginPermission, string[]> = {
@@ -101,6 +202,7 @@ export const WECOM_PLUGIN_TOOL_PATHS: Record<WecomPluginPermission, string[]> =
'/partners/{partnerId}/stores',
'/partners/{partnerId}/orders',
],
'dev_plan.read': ['/versions', '/tasks'],
};
export function parseWecomPluginPermissions(
@@ -145,6 +247,14 @@ export function allowedWecomPluginOpenApiPaths(
.map(([path]) => path);
}
/** 当前实例可暴露给企微 MCP 插件的 tools/list */
export function allowedWecomPluginMcpTools(
permissions: WecomPluginPermission[],
): WecomPluginMcpToolName[] {
const allowed = new Set(permissions);
return WECOM_PLUGIN_MCP_TOOL_NAMES.filter((name) => allowed.has(WECOM_PLUGIN_MCP_TOOL_META[name].permission));
}
export function maskWecomPluginApiKey(apiKey?: string | null): string {
const v = String(apiKey ?? '').trim();
if (!v) return '未配置';
@@ -205,3 +315,7 @@ export function resolveWecomPluginPublicUrl(hostname: string): string {
}
return `http://localhost:3010${WECOM_PLUGIN_BASE_PATH}`;
}
export function resolveWecomPluginMcpUrl(hostname: string): string {
return `${resolveWecomPluginPublicUrl(hostname)}${WECOM_PLUGIN_MCP_PATH}`;
}
+428
View File
@@ -322,6 +322,9 @@ importers:
'@dukang/shared-types':
specifier: workspace:*
version: link:../../packages/shared-types
'@modelcontextprotocol/sdk':
specifier: ^1.30.0
version: 1.30.0(zod@4.5.4)
'@nestjs/bullmq':
specifier: ^10.2.0
version: 10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(bullmq@5.79.2)
@@ -400,6 +403,9 @@ importers:
sharp:
specifier: ^0.34.5
version: 0.34.5
zod:
specifier: ^4.5.4
version: 4.5.4
devDependencies:
'@nestjs/cli':
specifier: ^10.4.0
@@ -437,6 +443,9 @@ importers:
typescript:
specifier: ^5.4.5
version: 5.9.3
vitest:
specifier: ^1.6.1
version: 1.6.1(@types/node@20.19.43)(sass@1.101.0)(terser@5.48.0)
packages:
@@ -1413,6 +1422,12 @@ packages:
'@hapi/topo@5.1.0':
resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==}
'@hono/node-server@2.1.1':
resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==}
engines: {node: '>=20'}
peerDependencies:
hono: ^4
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
@@ -1648,6 +1663,16 @@ packages:
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
engines: {node: '>=8'}
'@modelcontextprotocol/sdk@1.30.0':
resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==}
engines: {node: '>=18'}
peerDependencies:
'@cfworker/json-schema': ^4.1.1
zod: ^3.25 || ^4.0
peerDependenciesMeta:
'@cfworker/json-schema':
optional: true
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==}
cpu: [arm64]
@@ -3026,6 +3051,10 @@ packages:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -3064,6 +3093,14 @@ packages:
ajv:
optional: true
ajv-formats@3.0.1:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
peerDependencies:
ajv: ^8.0.0
peerDependenciesMeta:
ajv:
optional: true
ajv-keywords@3.5.2:
resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==}
peerDependencies:
@@ -3365,6 +3402,10 @@ packages:
resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
body-parser@2.3.0:
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
engines: {node: '>=18'}
bowser@1.9.4:
resolution: {integrity: sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==}
@@ -3682,16 +3723,28 @@ packages:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'}
content-disposition@1.1.0:
resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
engines: {node: '>=18'}
content-type@1.0.5:
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
engines: {node: '>= 0.6'}
content-type@2.1.0:
resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==}
engines: {node: '>=18'}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cookie-signature@1.0.7:
resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==}
cookie-signature@1.2.2:
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
engines: {node: '>=6.6.0'}
cookie@0.7.2:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
@@ -4165,6 +4218,14 @@ packages:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
eventsource-parser@3.1.1:
resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==}
engines: {node: '>=18.0.0'}
eventsource@3.0.7:
resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
engines: {node: '>=18.0.0'}
exceljs@4.4.0:
resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==}
engines: {node: '>=8.3.0'}
@@ -4173,10 +4234,20 @@ packages:
resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==}
engines: {node: '>=16.17'}
express-rate-limit@8.7.0:
resolution: {integrity: sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==}
engines: {node: '>= 16'}
peerDependencies:
express: '>= 4.11'
express@4.22.1:
resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==}
engines: {node: '>= 0.10.0'}
express@5.2.1:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'}
ext-list@2.2.2:
resolution: {integrity: sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==}
engines: {node: '>=0.10.0'}
@@ -4306,6 +4377,10 @@ packages:
resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==}
engines: {node: '>= 0.8'}
finalhandler@2.1.1:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
@@ -4380,6 +4455,10 @@ packages:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
from2@2.3.0:
resolution: {integrity: sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==}
@@ -4602,6 +4681,10 @@ packages:
hls.js@1.6.16:
resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==}
hono@4.13.7:
resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==}
engines: {node: '>=16.9.0'}
hookified@1.15.1:
resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==}
@@ -4656,6 +4739,10 @@ packages:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
iconv-lite@0.7.3:
resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
engines: {node: '>=0.10.0'}
ics@3.12.0:
resolution: {integrity: sha512-BBPiZ/TbUyZrxui7SHKGOa/n2HUxaW9ucn7cw47NW9OSKe5tItYxIdN7Uczti5nKURmfwVvYi2aLigCJGH1+cA==}
@@ -4731,6 +4818,10 @@ packages:
resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==}
engines: {node: '>=12.22.0'}
ip-address@10.7.0:
resolution: {integrity: sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==}
engines: {node: '>= 12'}
ip2region@2.3.0:
resolution: {integrity: sha512-zV5Xsadzrx9Ej6heoyhbXMsfGWWQ3C6bAIYStrHhw9kzLpGpVNlnAyRBxxPgxA1GNqr1Ti7oUxcWsMWNN3jZBg==}
peerDependencies:
@@ -4811,6 +4902,9 @@ packages:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
is-retry-allowed@1.2.0:
resolution: {integrity: sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==}
engines: {node: '>=0.10.0'}
@@ -4876,6 +4970,9 @@ packages:
joi@17.13.4:
resolution: {integrity: sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==}
jose@6.2.12:
resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==}
js-base64@2.6.4:
resolution: {integrity: sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==}
@@ -4912,6 +5009,9 @@ packages:
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
json-schema-typed@8.0.2:
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
@@ -5186,6 +5286,10 @@ packages:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
media-typer@1.1.1:
resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==}
engines: {node: '>= 0.8'}
memfs@3.5.3:
resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==}
engines: {node: '>= 4.0.0'}
@@ -5197,6 +5301,10 @@ packages:
merge-descriptors@1.0.3:
resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==}
merge-descriptors@2.0.0:
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
engines: {node: '>=18'}
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@@ -5220,10 +5328,18 @@ packages:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-db@1.54.0:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
mime-types@3.0.2:
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
engines: {node: '>=18'}
mime@1.6.0:
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
engines: {node: '>=4'}
@@ -5342,6 +5458,10 @@ packages:
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
engines: {node: '>= 0.6'}
negotiator@1.1.0:
resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==}
engines: {node: '>=18'}
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
@@ -5583,6 +5703,9 @@ packages:
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
path-to-regexp@8.4.2:
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
@@ -5643,6 +5766,10 @@ packages:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
pkce-challenge@5.0.1:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
@@ -5822,6 +5949,10 @@ packages:
resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==}
engines: {node: '>=0.6'}
qs@6.16.0:
resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==}
engines: {node: '>=0.6'}
query-string@5.1.1:
resolution: {integrity: sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw==}
engines: {node: '>=0.10.0'}
@@ -5847,6 +5978,10 @@ packages:
resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==}
engines: {node: '>= 0.8'}
raw-body@3.0.2:
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
engines: {node: '>= 0.10'}
rc-cascader@3.34.0:
resolution: {integrity: sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==}
peerDependencies:
@@ -6262,6 +6397,10 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
router@2.2.0:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
run-async@2.4.1:
resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==}
engines: {node: '>=0.12.0'}
@@ -6352,6 +6491,10 @@ packages:
resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==}
engines: {node: '>= 0.8.0'}
send@1.2.1:
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
engines: {node: '>= 18'}
sentence-case@3.0.4:
resolution: {integrity: sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==}
@@ -6372,6 +6515,10 @@ packages:
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
engines: {node: '>= 0.8.0'}
serve-static@2.2.1:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
@@ -6854,6 +7001,10 @@ packages:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'}
type-is@2.1.0:
resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==}
engines: {node: '>= 18'}
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
@@ -7270,6 +7421,14 @@ packages:
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
engines: {node: '>= 14'}
zod-to-json-schema@3.25.2:
resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
peerDependencies:
zod: ^3.25.28 || ^4
zod@4.5.4:
resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==}
zrender@6.1.0:
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
@@ -8528,6 +8687,10 @@ snapshots:
dependencies:
'@hapi/hoek': 9.3.0
'@hono/node-server@2.1.1(hono@4.13.7)':
dependencies:
hono: 4.13.7
'@humanfs/core@0.19.2':
dependencies:
'@humanfs/types': 0.15.0
@@ -8710,6 +8873,28 @@ snapshots:
'@lukeed/csprng@1.1.0': {}
'@modelcontextprotocol/sdk@1.30.0(zod@4.5.4)':
dependencies:
'@hono/node-server': 2.1.1(hono@4.13.7)
ajv: 8.20.0
ajv-formats: 3.0.1(ajv@8.20.0)
content-type: 1.0.5
cors: 2.8.5
cross-spawn: 7.0.6
eventsource: 3.0.7
eventsource-parser: 3.1.1
express: 5.2.1
express-rate-limit: 8.7.0(express@5.2.1)
hono: 4.13.7
jose: 6.2.12
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
raw-body: 3.0.2
zod: 4.5.4
zod-to-json-schema: 3.25.2(zod@4.5.4)
transitivePeerDependencies:
- supports-color
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
optional: true
@@ -10213,6 +10398,11 @@ snapshots:
mime-types: 2.1.35
negotiator: 0.6.3
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
negotiator: 1.1.0
acorn-jsx@5.3.2(acorn@8.17.0):
dependencies:
acorn: 8.17.0
@@ -10245,6 +10435,10 @@ snapshots:
optionalDependencies:
ajv: 8.20.0
ajv-formats@3.0.1(ajv@8.20.0):
optionalDependencies:
ajv: 8.20.0
ajv-keywords@3.5.2(ajv@6.15.0):
dependencies:
ajv: 6.15.0
@@ -10674,6 +10868,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
body-parser@2.3.0:
dependencies:
bytes: 3.1.2
content-type: 2.1.0
debug: 4.4.3
http-errors: 2.0.1
iconv-lite: 0.7.3
on-finished: 2.4.1
qs: 6.16.0
raw-body: 3.0.2
type-is: 2.1.0
transitivePeerDependencies:
- supports-color
bowser@1.9.4: {}
brace-expansion@1.1.15:
@@ -11060,12 +11268,18 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
content-disposition@1.1.0: {}
content-type@1.0.5: {}
content-type@2.1.0: {}
convert-source-map@2.0.0: {}
cookie-signature@1.0.7: {}
cookie-signature@1.2.2: {}
cookie@0.7.2: {}
copy-to-clipboard@3.3.3:
@@ -11594,6 +11808,12 @@ snapshots:
events@3.3.0: {}
eventsource-parser@3.1.1: {}
eventsource@3.0.7:
dependencies:
eventsource-parser: 3.1.1
exceljs@4.4.0:
dependencies:
archiver: 5.3.2
@@ -11618,6 +11838,14 @@ snapshots:
signal-exit: 4.1.0
strip-final-newline: 3.0.0
express-rate-limit@8.7.0(express@5.2.1):
dependencies:
debug: 4.4.3
express: 5.2.1
ip-address: 10.7.0
transitivePeerDependencies:
- supports-color
express@4.22.1:
dependencies:
accepts: 1.3.8
@@ -11654,6 +11882,39 @@ snapshots:
transitivePeerDependencies:
- supports-color
express@5.2.1:
dependencies:
accepts: 2.0.0
body-parser: 2.3.0
content-disposition: 1.1.0
content-type: 1.0.5
cookie: 0.7.2
cookie-signature: 1.2.2
debug: 4.4.3
depd: 2.0.0
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
finalhandler: 2.1.1
fresh: 2.0.0
http-errors: 2.0.1
merge-descriptors: 2.0.0
mime-types: 3.0.2
on-finished: 2.4.1
once: 1.4.0
parseurl: 1.3.3
proxy-addr: 2.0.7
qs: 6.14.2
range-parser: 1.2.1
router: 2.2.0
send: 1.2.1
serve-static: 2.2.1
statuses: 2.0.2
type-is: 2.1.0
vary: 1.1.2
transitivePeerDependencies:
- supports-color
ext-list@2.2.2:
dependencies:
mime-db: 1.52.0
@@ -11783,6 +12044,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
finalhandler@2.1.1:
dependencies:
debug: 4.4.3
encodeurl: 2.0.0
escape-html: 1.0.3
on-finished: 2.4.1
parseurl: 1.3.3
statuses: 2.0.2
transitivePeerDependencies:
- supports-color
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
@@ -11879,6 +12151,8 @@ snapshots:
fresh@0.5.2: {}
fresh@2.0.0: {}
from2@2.3.0:
dependencies:
inherits: 2.0.4
@@ -12138,6 +12412,8 @@ snapshots:
hls.js@1.6.16: {}
hono@4.13.7: {}
hookified@1.15.1: {}
hookified@2.2.0: {}
@@ -12202,6 +12478,10 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
iconv-lite@0.7.3:
dependencies:
safer-buffer: 2.1.2
ics@3.12.0:
dependencies:
nanoid: 3.3.15
@@ -12317,6 +12597,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
ip-address@10.7.0: {}
ip2region@2.3.0(@types/node@20.19.43):
dependencies:
'@types/node': 20.19.43
@@ -12369,6 +12651,8 @@ snapshots:
is-plain-object@5.0.0: {}
is-promise@4.0.0: {}
is-retry-allowed@1.2.0: {}
is-stream@1.1.0: {}
@@ -12432,6 +12716,8 @@ snapshots:
'@sideway/formula': 3.0.1
'@sideway/pinpoint': 2.0.0
jose@6.2.12: {}
js-base64@2.6.4: {}
js-md5@0.8.3: {}
@@ -12456,6 +12742,8 @@ snapshots:
json-schema-traverse@1.0.0: {}
json-schema-typed@8.0.2: {}
json-stable-stringify-without-jsonify@1.0.1: {}
json2mq@0.2.0:
@@ -12710,6 +12998,8 @@ snapshots:
media-typer@0.3.0: {}
media-typer@1.1.1: {}
memfs@3.5.3:
dependencies:
fs-monkey: 1.1.0
@@ -12718,6 +13008,8 @@ snapshots:
merge-descriptors@1.0.3: {}
merge-descriptors@2.0.0: {}
merge-stream@2.0.0: {}
merge2@1.4.1: {}
@@ -12733,10 +13025,16 @@ snapshots:
mime-db@1.52.0: {}
mime-db@1.54.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
mime-types@3.0.2:
dependencies:
mime-db: 1.54.0
mime@1.6.0: {}
mime@2.6.0: {}
@@ -12842,6 +13140,10 @@ snapshots:
negotiator@0.6.3: {}
negotiator@1.1.0:
dependencies:
content-type: 2.1.0
neo-async@2.6.2: {}
no-case@2.3.2:
@@ -13071,6 +13373,8 @@ snapshots:
path-to-regexp@6.3.0: {}
path-to-regexp@8.4.2: {}
path-type@4.0.0: {}
pathe@1.1.2: {}
@@ -13117,6 +13421,8 @@ snapshots:
pirates@4.0.7: {}
pkce-challenge@5.0.1: {}
pkg-types@1.3.1:
dependencies:
confbox: 0.1.8
@@ -13276,6 +13582,11 @@ snapshots:
dependencies:
side-channel: 1.1.1
qs@6.16.0:
dependencies:
es-define-property: 1.0.1
side-channel: 1.1.1
query-string@5.1.1:
dependencies:
decode-uri-component: 0.2.2
@@ -13308,6 +13619,13 @@ snapshots:
iconv-lite: 0.4.24
unpipe: 1.0.0
raw-body@3.0.2:
dependencies:
bytes: 3.1.2
http-errors: 2.0.1
iconv-lite: 0.7.3
unpipe: 1.0.0
rc-cascader@3.34.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@babel/runtime': 7.29.7
@@ -13838,6 +14156,16 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.62.2
fsevents: 2.3.3
router@2.2.0:
dependencies:
debug: 4.4.3
depd: 2.0.0
is-promise: 4.0.0
parseurl: 1.3.3
path-to-regexp: 8.4.2
transitivePeerDependencies:
- supports-color
run-async@2.4.1: {}
run-async@3.0.0: {}
@@ -13954,6 +14282,22 @@ snapshots:
transitivePeerDependencies:
- supports-color
send@1.2.1:
dependencies:
debug: 4.4.3
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
fresh: 2.0.0
http-errors: 2.0.1
mime-types: 3.0.2
ms: 2.1.3
on-finished: 2.4.1
range-parser: 1.2.1
statuses: 2.0.2
transitivePeerDependencies:
- supports-color
sentence-case@3.0.4:
dependencies:
no-case: 3.0.4
@@ -13979,6 +14323,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
serve-static@2.2.1:
dependencies:
encodeurl: 2.0.0
escape-html: 1.0.3
parseurl: 1.3.3
send: 1.2.1
transitivePeerDependencies:
- supports-color
set-blocking@2.0.0: {}
set-function-length@1.2.2:
@@ -14507,6 +14860,12 @@ snapshots:
media-typer: 0.3.0
mime-types: 2.1.35
type-is@2.1.0:
dependencies:
content-type: 2.1.0
media-typer: 1.1.1
mime-types: 3.0.2
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
@@ -14667,6 +15026,24 @@ snapshots:
vary@1.1.2: {}
vite-node@1.6.1(@types/node@20.19.43)(sass@1.101.0)(terser@5.48.0):
dependencies:
cac: 6.7.14
debug: 4.4.3
pathe: 1.1.2
picocolors: 1.1.1
vite: 5.4.21(@types/node@20.19.43)(sass@1.101.0)(terser@5.48.0)
transitivePeerDependencies:
- '@types/node'
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
vite-node@1.6.1(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0):
dependencies:
cac: 6.7.14
@@ -14693,6 +15070,17 @@ snapshots:
picocolors: 1.1.1
vite: 5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
vite@5.4.21(@types/node@20.19.43)(sass@1.101.0)(terser@5.48.0):
dependencies:
esbuild: 0.21.5
postcss: 8.5.15
rollup: 4.62.2
optionalDependencies:
'@types/node': 20.19.43
fsevents: 2.3.3
sass: 1.101.0
terser: 5.48.0
vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0):
dependencies:
esbuild: 0.21.5
@@ -14704,6 +15092,40 @@ snapshots:
sass: 1.101.0
terser: 5.48.0
vitest@1.6.1(@types/node@20.19.43)(sass@1.101.0)(terser@5.48.0):
dependencies:
'@vitest/expect': 1.6.1
'@vitest/runner': 1.6.1
'@vitest/snapshot': 1.6.1
'@vitest/spy': 1.6.1
'@vitest/utils': 1.6.1
acorn-walk: 8.3.5
chai: 4.5.0
debug: 4.4.3
execa: 8.0.1
local-pkg: 0.5.1
magic-string: 0.30.21
pathe: 1.1.2
picocolors: 1.1.1
std-env: 3.10.0
strip-literal: 2.1.1
tinybench: 2.9.0
tinypool: 0.8.4
vite: 5.4.21(@types/node@20.19.43)(sass@1.101.0)(terser@5.48.0)
vite-node: 1.6.1(@types/node@20.19.43)(sass@1.101.0)(terser@5.48.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 20.19.43
transitivePeerDependencies:
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
vitest@1.6.1(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0):
dependencies:
'@vitest/expect': 1.6.1
@@ -14992,6 +15414,12 @@ snapshots:
compress-commons: 6.0.2
readable-stream: 4.7.0
zod-to-json-schema@3.25.2(zod@4.5.4):
dependencies:
zod: 4.5.4
zod@4.5.4: {}
zrender@6.1.0:
dependencies:
tslib: 2.3.0
+6 -2
View File
@@ -9,6 +9,7 @@
"dev": "nest start --watch",
"start": "node dist/main",
"lint": "eslint src",
"test": "vitest run src",
"prisma:generate": "prisma generate",
"prisma:push": "prisma db push",
"prisma:migrate": "prisma migrate dev",
@@ -33,6 +34,7 @@
"@alicloud/openapi-client": "^0.4.15",
"@dukang/domain": "workspace:*",
"@dukang/shared-types": "workspace:*",
"@modelcontextprotocol/sdk": "^1.30.0",
"@nestjs/bullmq": "^10.2.0",
"@nestjs/common": "^10.4.0",
"@nestjs/config": "^3.2.0",
@@ -58,7 +60,8 @@
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sharp": "^0.34.5"
"sharp": "^0.34.5",
"zod": "^4.5.4"
},
"devDependencies": {
"@nestjs/cli": "^10.4.0",
@@ -72,6 +75,7 @@
"@types/qrcode": "^1.5.6",
"prisma": "^5.18.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
"typescript": "^5.4.5",
"vitest": "^1.6.1"
}
}
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { skipWecomPluginResponseWrap } from './response.interceptor';
describe('skipWecomPluginResponseWrap', () => {
it('skips OpenAPI and MCP JSON-RPC paths', () => {
expect(skipWecomPluginResponseWrap('/api/v1/wecom/plugin/openapi.json')).toBe(true);
expect(skipWecomPluginResponseWrap('/api/v1/wecom/plugin/mcp')).toBe(true);
expect(skipWecomPluginResponseWrap('/api/v1/wecom/plugin/mcp?x=1')).toBe(true);
expect(skipWecomPluginResponseWrap('/api/v1/wecom/plugin/metrics')).toBe(false);
});
});
@@ -6,9 +6,9 @@ import {
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
function skipResponseWrap(url?: string): boolean {
export function skipWecomPluginResponseWrap(url?: string): boolean {
const path = (url || '').split('?')[0];
return path.endsWith('/wecom/plugin/openapi.json');
return path.endsWith('/wecom/plugin/openapi.json') || path.includes('/wecom/plugin/mcp');
}
@Injectable()
@@ -16,7 +16,7 @@ export class ResponseInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = context.switchToHttp().getRequest<{ originalUrl?: string; url?: string }>();
const res = context.switchToHttp().getResponse<{ headersSent?: boolean }>();
if (skipResponseWrap(req.originalUrl || req.url)) {
if (skipWecomPluginResponseWrap(req.originalUrl || req.url)) {
return next.handle();
}
return next.handle().pipe(
@@ -0,0 +1,27 @@
import { Controller, Delete, Get, Post, Req, Res, UseGuards } from '@nestjs/common';
import type { Request, Response } from 'express';
import { CurrentWecomPlugin } from './wecom-plugin.decorators';
import { WecomPluginGuard } from './wecom-plugin.guard';
import { WecomPluginMcpFactory } from './wecom-plugin-mcp.factory';
import type { WecomPluginRuntime } from './wecom-plugin.types';
@Controller('wecom/plugin/mcp')
@UseGuards(WecomPluginGuard)
export class WecomPluginMcpController {
constructor(private readonly mcp: WecomPluginMcpFactory) {}
@Post()
post(@CurrentWecomPlugin() plugin: WecomPluginRuntime, @Req() req: Request, @Res() res: Response) {
return this.mcp.handle(req, res, plugin);
}
@Get()
get(@CurrentWecomPlugin() plugin: WecomPluginRuntime, @Req() req: Request, @Res() res: Response) {
return this.mcp.handle(req, res, plugin);
}
@Delete()
delete(@CurrentWecomPlugin() plugin: WecomPluginRuntime, @Req() req: Request, @Res() res: Response) {
return this.mcp.handle(req, res, plugin);
}
}
@@ -0,0 +1,63 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import { describe, expect, it, vi } from 'vitest';
import { createWecomPluginMcpServer, WecomPluginMcpFactory } from './wecom-plugin-mcp.factory';
import type { WecomPluginQueryService } from './wecom-plugin-query.service';
import type { WecomPluginRuntime } from './wecom-plugin.types';
const plugin: WecomPluginRuntime = {
id: '9',
name: 'ops-readonly',
permissions: ['order.read', 'metrics.read'],
};
async function listen(server: http.Server): Promise<string> {
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;
return `http://127.0.0.1:${port}/mcp`;
}
describe('WecomPluginMcpFactory Streamable HTTP', () => {
it('lists only permitted tools and calls query_orders', async () => {
const query = {
queryOrders: vi.fn().mockResolvedValue({ total: 1, items: [{ orderNo: 'DK1' }] }),
} as unknown as WecomPluginQueryService;
const factory = new WecomPluginMcpFactory(query);
const httpServer = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
(req as http.IncomingMessage & { body?: unknown }).body = raw ? JSON.parse(raw) : undefined;
void factory.handle(req, res, plugin);
});
});
const url = await listen(httpServer);
const client = new Client({ name: 'wecom-plugin-test', version: '1.0.0' });
const transport = new StreamableHTTPClientTransport(new URL(url));
try {
await client.connect(transport);
const listed = await client.listTools();
expect(listed.tools.map((t) => t.name).sort()).toEqual(['query_metrics', 'query_orders']);
const called = await client.callTool({ name: 'query_orders', arguments: { q: 'DK' } });
expect(called.isError).toBeFalsy();
const text = called.content.find((c) => c.type === 'text');
expect(text && 'text' in text ? JSON.parse(text.text) : null).toEqual({
total: 1,
items: [{ orderNo: 'DK1' }],
});
} finally {
await client.close().catch(() => undefined);
await new Promise<void>((resolve, reject) => httpServer.close((err) => (err ? reject(err) : resolve())));
}
});
it('createWecomPluginMcpServer does not register unauthorized tools', () => {
const query = {} as WecomPluginQueryService;
const server = createWecomPluginMcpServer(query, plugin, 'alice');
expect(server).toBeTruthy();
});
});
@@ -0,0 +1,71 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { allowedWecomPluginMcpTools } from '@dukang/shared-types';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { WECOM_PLUGIN_MCP_TOOL_DEFS } from './wecom-plugin-mcp.tools';
import { WecomPluginQueryService } from './wecom-plugin-query.service';
import type { WecomPluginRuntime } from './wecom-plugin.types';
import { wecomPluginCaller } from './wecom-plugin.util';
export function createWecomPluginMcpServer(
query: WecomPluginQueryService,
plugin: WecomPluginRuntime,
wecomUserId: string,
): McpServer {
const server = new McpServer({
name: plugin.name || 'dukang-wecom-plugin',
version: '1.0.0',
});
const allowed = new Set(allowedWecomPluginMcpTools(plugin.permissions));
const ctx = { query, plugin, wecomUserId };
for (const def of WECOM_PLUGIN_MCP_TOOL_DEFS) {
if (!allowed.has(def.name)) continue;
server.registerTool(
def.name,
{ title: def.name, description: def.description, inputSchema: def.inputSchema },
async (args) => def.invoke(ctx, (args ?? {}) as Record<string, unknown>),
);
}
return server;
}
@Injectable()
export class WecomPluginMcpFactory {
private readonly logger = new Logger(WecomPluginMcpFactory.name);
constructor(@Inject(WecomPluginQueryService) private readonly query: WecomPluginQueryService) {}
async handle(
req: IncomingMessage & { body?: unknown; headers: IncomingMessage['headers'] },
res: ServerResponse,
plugin: WecomPluginRuntime,
): Promise<void> {
const server = createWecomPluginMcpServer(this.query, plugin, wecomPluginCaller(req));
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
res.on('close', () => {
void transport.close();
void server.close();
});
try {
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
this.logger.error(`wecom plugin MCP failed: ${message}`);
if (!res.headersSent) {
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({
jsonrpc: '2.0',
error: { code: -32603, message },
id: null,
}),
);
}
}
}
}
@@ -0,0 +1,75 @@
import { BadRequestException } from '@nestjs/common';
import { WECOM_PLUGIN_MCP_TOOL_NAMES } from '@dukang/shared-types';
import { describe, expect, it, vi } from 'vitest';
import { WECOM_PLUGIN_MCP_TOOL_DEFS } from './wecom-plugin-mcp.tools';
import type { WecomPluginQueryService } from './wecom-plugin-query.service';
import type { WecomPluginRuntime } from './wecom-plugin.types';
const plugin: WecomPluginRuntime = {
id: '1',
name: 'ops',
permissions: ['order.read', 'metrics.read'],
};
function textOf(result: { content: Array<{ type: string; text?: string }> }): unknown {
const part = result.content[0];
expect(part?.type).toBe('text');
return JSON.parse(String(part?.text));
}
describe('WECOM_PLUGIN_MCP_TOOL_DEFS', () => {
it('covers the shared-types catalog 1:1', () => {
expect(WECOM_PLUGIN_MCP_TOOL_DEFS.map((d) => d.name).sort()).toEqual([...WECOM_PLUGIN_MCP_TOOL_NAMES].sort());
});
it('returns raw JSON without REST envelope', async () => {
const query = {
queryOrders: vi.fn().mockResolvedValue({ total: 1, items: [{ orderNo: 'DK1' }] }),
} as unknown as WecomPluginQueryService;
const def = WECOM_PLUGIN_MCP_TOOL_DEFS.find((d) => d.name === 'query_orders');
expect(def).toBeTruthy();
const result = await def!.invoke({ query, plugin, wecomUserId: 'alice' }, { q: 'DK' });
expect(result.isError).toBeFalsy();
expect(textOf(result)).toEqual({ total: 1, items: [{ orderNo: 'DK1' }] });
expect(query.queryOrders).toHaveBeenCalledWith(plugin, 'alice', 'DK', undefined, undefined);
});
it('maps HttpException to isError text', async () => {
const query = {
queryOrders: vi.fn().mockRejectedValue(new BadRequestException('请提供查询关键词 q')),
} as unknown as WecomPluginQueryService;
const def = WECOM_PLUGIN_MCP_TOOL_DEFS.find((d) => d.name === 'query_orders');
const result = await def!.invoke({ query, plugin, wecomUserId: 'alice' }, {});
expect(result.isError).toBe(true);
expect(result.content[0]).toMatchObject({ type: 'text', text: '请提供查询关键词 q' });
});
it('invokes query_versions and query_tasks', async () => {
const query = {
queryVersions: vi.fn().mockResolvedValue({ total: 1, items: [{ versionNo: 'v3.5.18' }] }),
queryTasks: vi.fn().mockResolvedValue({ total: 1, items: [{ taskNo: 'T1', status: 'TODO' }] }),
} as unknown as WecomPluginQueryService;
const versions = WECOM_PLUGIN_MCP_TOOL_DEFS.find((d) => d.name === 'query_versions');
const tasks = WECOM_PLUGIN_MCP_TOOL_DEFS.find((d) => d.name === 'query_tasks');
expect(versions).toBeTruthy();
expect(tasks).toBeTruthy();
await versions!.invoke(
{ query, plugin, wecomUserId: 'alice' },
{ q: 'v3.5', status: 'IN_PROGRESS', page: 1, pageSize: 5 },
);
expect(query.queryVersions).toHaveBeenCalledWith(plugin, 'alice', 'v3.5', 'IN_PROGRESS', '1', '5');
await tasks!.invoke(
{ query, plugin, wecomUserId: 'alice' },
{ status: 'TODO', q: '核销', versionNo: 'v3.5.18', page: 1, pageSize: 5 },
);
expect(query.queryTasks).toHaveBeenCalledWith(
plugin,
'alice',
'核销',
'TODO',
'v3.5.18',
'1',
'5',
);
});
});
@@ -0,0 +1,247 @@
import { WECOM_PLUGIN_MCP_TOOL_META, type WecomPluginMcpToolName } from '@dukang/shared-types';
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import { wecomPluginMcpErrorText, wecomPluginQueryArg } from './wecom-plugin.util';
import type { WecomPluginQueryService } from './wecom-plugin-query.service';
import type { WecomPluginRuntime } from './wecom-plugin.types';
const q = (description: string) => z.string().describe(description);
const page = z.number().int().min(1).optional().describe('页码,默认 1');
const pageSize = z.number().int().min(1).max(10).optional().describe('每页条数,默认 5,最大 10');
const from = z.string().optional().describe('起始日期 YYYY-MM-DD 或 ISO');
const to = z.string().optional().describe('结束日期 YYYY-MM-DD 或 ISO(含当天)');
const auditStatus = z
.enum(['PENDING', 'APPROVED', 'REJECTED'])
.optional()
.describe('审核状态,默认 PENDING');
export type WecomPluginMcpToolContext = {
query: WecomPluginQueryService;
plugin: WecomPluginRuntime;
wecomUserId: string;
};
async function jsonResult(run: () => Promise<unknown>): Promise<CallToolResult> {
try {
const data = await run();
return { content: [{ type: 'text', text: JSON.stringify(data) }] };
} catch (e) {
return { content: [{ type: 'text', text: wecomPluginMcpErrorText(e) }], isError: true };
}
}
type ToolDef = {
name: WecomPluginMcpToolName;
description: string;
inputSchema: Record<string, z.ZodTypeAny>;
invoke: (ctx: WecomPluginMcpToolContext, args: Record<string, unknown>) => Promise<CallToolResult>;
};
function str(args: Record<string, unknown>, key: string): string | undefined {
return wecomPluginQueryArg(args[key] as string | number | undefined);
}
export const WECOM_PLUGIN_MCP_TOOL_DEFS: ToolDef[] = [
{
name: 'query_orders',
description: WECOM_PLUGIN_MCP_TOOL_META.query_orders.description,
inputSchema: { q: q('订单号,如 DK20260903xxxx'), page, pageSize },
invoke: (ctx, args) =>
jsonResult(() => ctx.query.queryOrders(ctx.plugin, ctx.wecomUserId, str(args, 'q'), str(args, 'page'), str(args, 'pageSize'))),
},
{
name: 'query_users',
description: WECOM_PLUGIN_MCP_TOOL_META.query_users.description,
inputSchema: { q: q('用户号或 11 位手机号'), page, pageSize },
invoke: (ctx, args) =>
jsonResult(() => ctx.query.queryUsers(ctx.plugin, ctx.wecomUserId, str(args, 'q'), str(args, 'page'), str(args, 'pageSize'))),
},
{
name: 'query_stores',
description: WECOM_PLUGIN_MCP_TOOL_META.query_stores.description,
inputSchema: { q: q('门店名称关键词'), page, pageSize },
invoke: (ctx, args) =>
jsonResult(() => ctx.query.queryStores(ctx.plugin, ctx.wecomUserId, str(args, 'q'), str(args, 'page'), str(args, 'pageSize'))),
},
{
name: 'query_redeems',
description: WECOM_PLUGIN_MCP_TOOL_META.query_redeems.description,
inputSchema: { q: q('核销单号或门店名'), page, pageSize },
invoke: (ctx, args) =>
jsonResult(() => ctx.query.queryRedeems(ctx.plugin, ctx.wecomUserId, str(args, 'q'), str(args, 'page'), str(args, 'pageSize'))),
},
{
name: 'query_promo_codes',
description: WECOM_PLUGIN_MCP_TOOL_META.query_promo_codes.description,
inputSchema: { q: q('推广码或名称'), page, pageSize },
invoke: (ctx, args) =>
jsonResult(() =>
ctx.query.queryPromoCodes(ctx.plugin, ctx.wecomUserId, str(args, 'q'), str(args, 'page'), str(args, 'pageSize')),
),
},
{
name: 'query_promo_code_stats',
description: WECOM_PLUGIN_MCP_TOOL_META.query_promo_code_stats.description,
inputSchema: { code: q('推广码 code') },
invoke: (ctx, args) => jsonResult(() => ctx.query.queryPromoCodeStats(ctx.plugin, ctx.wecomUserId, str(args, 'code'))),
},
{
name: 'query_metrics',
description: WECOM_PLUGIN_MCP_TOOL_META.query_metrics.description,
inputSchema: {
kind: z.enum(['today', 'daily', 'weekly', 'monthly']).optional().describe('默认 today'),
},
invoke: (ctx, args) => jsonResult(() => ctx.query.queryMetrics(ctx.plugin, ctx.wecomUserId, str(args, 'kind'))),
},
{
name: 'query_store_audits',
description: WECOM_PLUGIN_MCP_TOOL_META.query_store_audits.description,
inputSchema: { q: z.string().optional().describe('门店名称'), status: auditStatus, page, pageSize },
invoke: (ctx, args) =>
jsonResult(() =>
ctx.query.queryStoreAudits(
ctx.plugin,
ctx.wecomUserId,
str(args, 'q'),
str(args, 'status'),
str(args, 'page'),
str(args, 'pageSize'),
),
),
},
{
name: 'query_store_info_audits',
description: WECOM_PLUGIN_MCP_TOOL_META.query_store_info_audits.description,
inputSchema: { status: auditStatus, page, pageSize },
invoke: (ctx, args) =>
jsonResult(() =>
ctx.query.queryStoreInfoAudits(ctx.plugin, ctx.wecomUserId, str(args, 'status'), str(args, 'page'), str(args, 'pageSize')),
),
},
{
name: 'query_store_info_audit_detail',
description: WECOM_PLUGIN_MCP_TOOL_META.query_store_info_audit_detail.description,
inputSchema: { id: q('信息变更审核 ID') },
invoke: (ctx, args) =>
jsonResult(() => ctx.query.queryStoreInfoAuditDetail(ctx.plugin, ctx.wecomUserId, str(args, 'id') ?? '')),
},
{
name: 'query_store_package_audits',
description: WECOM_PLUGIN_MCP_TOOL_META.query_store_package_audits.description,
inputSchema: { status: auditStatus, page, pageSize },
invoke: (ctx, args) =>
jsonResult(() =>
ctx.query.queryStorePackageAudits(ctx.plugin, ctx.wecomUserId, str(args, 'status'), str(args, 'page'), str(args, 'pageSize')),
),
},
{
name: 'query_store_package_audit_detail',
description: WECOM_PLUGIN_MCP_TOOL_META.query_store_package_audit_detail.description,
inputSchema: { id: q('套餐审核 ID') },
invoke: (ctx, args) =>
jsonResult(() => ctx.query.queryStorePackageAuditDetail(ctx.plugin, ctx.wecomUserId, str(args, 'id') ?? '')),
},
{
name: 'query_partners',
description: WECOM_PLUGIN_MCP_TOOL_META.query_partners.description,
inputSchema: { q: q('合伙人关键词'), page, pageSize },
invoke: (ctx, args) =>
jsonResult(() =>
ctx.query.queryPartners(ctx.plugin, ctx.wecomUserId, str(args, 'q'), str(args, 'page'), str(args, 'pageSize')),
),
},
{
name: 'query_partner_users',
description: WECOM_PLUGIN_MCP_TOOL_META.query_partner_users.description,
inputSchema: { partnerId: q('合伙人 ID'), from, to, page, pageSize },
invoke: (ctx, args) =>
jsonResult(() =>
ctx.query.queryPartnerUsers(
ctx.plugin,
ctx.wecomUserId,
str(args, 'partnerId') ?? '',
str(args, 'from'),
str(args, 'to'),
str(args, 'page'),
str(args, 'pageSize'),
),
),
},
{
name: 'query_partner_stores',
description: WECOM_PLUGIN_MCP_TOOL_META.query_partner_stores.description,
inputSchema: { partnerId: q('合伙人 ID'), from, to, page, pageSize },
invoke: (ctx, args) =>
jsonResult(() =>
ctx.query.queryPartnerStores(
ctx.plugin,
ctx.wecomUserId,
str(args, 'partnerId') ?? '',
str(args, 'from'),
str(args, 'to'),
str(args, 'page'),
str(args, 'pageSize'),
),
),
},
{
name: 'query_partner_orders',
description: WECOM_PLUGIN_MCP_TOOL_META.query_partner_orders.description,
inputSchema: { partnerId: q('合伙人 ID'), from, to, page, pageSize },
invoke: (ctx, args) =>
jsonResult(() =>
ctx.query.queryPartnerOrders(
ctx.plugin,
ctx.wecomUserId,
str(args, 'partnerId') ?? '',
str(args, 'from'),
str(args, 'to'),
str(args, 'page'),
str(args, 'pageSize'),
),
),
},
{
name: 'query_versions',
description: WECOM_PLUGIN_MCP_TOOL_META.query_versions.description,
inputSchema: {
q: z.string().optional().describe('版本号关键词,如 v3.5.18'),
status: z
.enum(['PENDING', 'IN_PROGRESS', 'TESTING', 'RELEASED', 'STOPPED'])
.optional()
.describe('版本状态:待启动/开发中/测试/已上线/已停止'),
page,
pageSize,
},
invoke: (ctx, args) =>
jsonResult(() =>
ctx.query.queryVersions(ctx.plugin, ctx.wecomUserId, str(args, 'q'), str(args, 'status'), str(args, 'page'), str(args, 'pageSize')),
),
},
{
name: 'query_tasks',
description: WECOM_PLUGIN_MCP_TOOL_META.query_tasks.description,
inputSchema: {
status: z
.enum(['TODO', 'IN_PROGRESS', 'DEVELOPED', 'RELEASED', 'STOPPED'])
.optional()
.describe('任务状态:TODO 待开发、IN_PROGRESS 开发中、DEVELOPED 已开发、RELEASED 已上线、STOPPED 已停止'),
q: z.string().optional().describe('任务编号或内容关键词'),
versionNo: z.string().optional().describe('限定某版本号,如 v3.5.18'),
page,
pageSize,
},
invoke: (ctx, args) =>
jsonResult(() =>
ctx.query.queryTasks(
ctx.plugin,
ctx.wecomUserId,
str(args, 'q'),
str(args, 'status'),
str(args, 'versionNo'),
str(args, 'page'),
str(args, 'pageSize'),
),
),
},
];
@@ -1,4 +1,10 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
clampWecomPluginPage,
clampWecomPluginPageSize,
@@ -13,14 +19,27 @@ import {
type WecomReportStats,
} from '@dukang/domain';
import {
WECOM_PLUGIN_MCP_PATH,
WECOM_PLUGIN_MCP_TRANSPORT,
WECOM_PLUGIN_TOOL_PATHS,
allowedWecomPluginMcpTools,
wecomPluginHasPermission,
STORE_INFO_CHANGEABLE_FIELD_LABELS,
DEV_PLAN_TASK_STATUSES,
DEV_PLAN_TASK_STATUS_LABELS,
DEV_PLAN_TASK_TYPE_LABELS,
DEV_PLAN_VERSION_STATUSES,
DEV_PLAN_VERSION_STATUS_LABELS,
type StoreInfoChangeableField,
type WecomPluginPermission,
type DevPlanTaskDto,
type DevPlanVersionDto,
type DevPlanTaskStatusDto,
type DevPlanVersionStatusDto,
} from '@dukang/shared-types';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { DevPlanService } from '../../modules/dev-plan/dev-plan.service';
import { PromoCodeService } from '../../modules/promo/promo-code.service';
import { WecomBotAuditService } from './wecom-bot-audit.service';
import { wecomPluginAuditBot, type WecomPluginRuntime } from './wecom-plugin.types';
@@ -38,21 +57,76 @@ function requireQuery(q?: string): string {
return v;
}
function parseDevPlanTaskStatus(raw?: string | null): DevPlanTaskStatusDto | null {
const v = String(raw ?? '').trim().toUpperCase();
if (!v) return null;
if (!(DEV_PLAN_TASK_STATUSES as readonly string[]).includes(v)) {
throw new BadRequestException(
`任务状态须为 ${DEV_PLAN_TASK_STATUSES.join(' | ')}(待开发/开发中/已开发/已上线/已停止)`,
);
}
return v as DevPlanTaskStatusDto;
}
function parseDevPlanVersionStatus(raw?: string | null): DevPlanVersionStatusDto | null {
const v = String(raw ?? '').trim().toUpperCase();
if (!v) return null;
if (!(DEV_PLAN_VERSION_STATUSES as readonly string[]).includes(v)) {
throw new BadRequestException(
`版本状态须为 ${DEV_PLAN_VERSION_STATUSES.join(' | ')}(待启动/开发中/测试/已上线/已停止)`,
);
}
return v as DevPlanVersionStatusDto;
}
function toWecomPluginTaskView(t: DevPlanTaskDto) {
return {
taskNo: t.taskNo,
content: t.content,
type: t.type,
typeLabel: DEV_PLAN_TASK_TYPE_LABELS[t.type] ?? t.type,
status: t.status,
statusLabel: DEV_PLAN_TASK_STATUS_LABELS[t.status] ?? t.status,
creatorName: t.creatorName?.trim() || '—',
supportTicketNo: t.supportTicketNo ?? null,
versions: (t.versions ?? []).map((v) => v.versionNo),
createdAt: t.createdAt,
completedAt: t.completedAt ?? null,
};
}
function toWecomPluginVersionView(v: DevPlanVersionDto) {
return {
versionNo: v.versionNo,
content: v.content ?? null,
status: v.status,
statusLabel: DEV_PLAN_VERSION_STATUS_LABELS[v.status] ?? v.status,
createdAt: v.createdAt,
releasedAt: v.releasedAt ?? null,
};
}
@Injectable()
export class WecomPluginQueryService {
constructor(
private readonly prisma: PrismaService,
private readonly promo: PromoCodeService,
private readonly audit: WecomBotAuditService,
@Inject(DevPlanService) private readonly devPlan: DevPlanService,
) {}
info(plugin: WecomPluginRuntime) {
return {
name: plugin.name,
description: '企微智能机器人只读 API 插件。查询订单、用户、门店经营、核销、推广码、经营指标、审核对照合伙人关联数据。',
description: '企微智能机器人只读插件。查询订单、用户、门店经营、核销、推广码、经营指标、审核对照合伙人关联数据、开发计划版本与任务。',
auth: { header: 'X-Api-Key' },
permissions: plugin.permissions,
tools: plugin.permissions.flatMap((p) => WECOM_PLUGIN_TOOL_PATHS[p]),
mcp: {
path: WECOM_PLUGIN_MCP_PATH,
transport: WECOM_PLUGIN_MCP_TRANSPORT,
tools: allowedWecomPluginMcpTools(plugin.permissions),
},
};
}
@@ -999,6 +1073,106 @@ export class WecomPluginQueryService {
);
}
queryVersions(
plugin: WecomPluginRuntime,
wecomUserId: string,
q?: string,
status?: string,
page?: string,
pageSize?: string,
) {
this.requirePerm(plugin, 'dev_plan.read');
const versionStatus = parseDevPlanVersionStatus(status);
const take = clampWecomPluginPageSize(pageSize);
const pg = clampWecomPluginPage(page);
const keyword = String(q ?? '').trim();
return this.audit.run(
{
bot: wecomPluginAuditBot(plugin),
wecomUserId,
action: 'plugin.dev_plan.versions',
permission: 'dev_plan.read',
inputSummary: `${keyword || '*'} ${versionStatus || ''}`.trim(),
},
async () => {
const data = (await this.devPlan.listVersions({
keyword: keyword || undefined,
status: versionStatus ?? undefined,
page: pg,
pageSize: take,
})) as { items: DevPlanVersionDto[]; total: number };
return {
total: data.total,
items: data.items.map(toWecomPluginVersionView),
};
},
);
}
queryTasks(
plugin: WecomPluginRuntime,
wecomUserId: string,
q?: string,
status?: string,
versionNo?: string,
page?: string,
pageSize?: string,
) {
this.requirePerm(plugin, 'dev_plan.read');
const taskStatus = parseDevPlanTaskStatus(status);
const take = clampWecomPluginPageSize(pageSize);
const pg = clampWecomPluginPage(page);
const keyword = String(q ?? '').trim();
const ver = String(versionNo ?? '').trim();
return this.audit.run(
{
bot: wecomPluginAuditBot(plugin),
wecomUserId,
action: 'plugin.dev_plan.tasks',
permission: 'dev_plan.read',
inputSummary: `${ver || keyword || '*'} ${taskStatus || ''}`.trim(),
},
async () => {
if (ver) {
const listed = (await this.devPlan.listVersions({
keyword: ver,
page: 1,
pageSize: 10,
})) as { items: DevPlanVersionDto[] };
const match = listed.items.find((v) => v.versionNo.toLowerCase() === ver.toLowerCase());
if (!match) throw new NotFoundException(`未找到版本:${ver}`);
const detail = await this.devPlan.getVersion(BigInt(match.id));
let tasks = (detail.tasks ?? []).map(toWecomPluginTaskView);
if (taskStatus) tasks = tasks.filter((t) => t.status === taskStatus);
if (keyword) {
const kw = keyword.toLowerCase();
tasks = tasks.filter(
(t) => t.taskNo.toLowerCase().includes(kw) || t.content.toLowerCase().includes(kw),
);
}
const total = tasks.length;
const start = (pg - 1) * take;
return {
versionNo: match.versionNo,
versionStatus: match.status,
total,
items: tasks.slice(start, start + take),
};
}
const data = (await this.devPlan.listTasks({
status: taskStatus ?? undefined,
keyword: keyword || undefined,
page: pg,
pageSize: take,
})) as { items: DevPlanTaskDto[]; total: number };
return {
total: data.total,
items: data.items.map(toWecomPluginTaskView),
};
},
);
}
private mapStoreInfoAuditRow(
row: {
id: bigint;
@@ -4,6 +4,7 @@ import { WecomPluginGuard } from './wecom-plugin.guard';
import { WecomPluginQueryService } from './wecom-plugin-query.service';
import { CurrentWecomPlugin } from './wecom-plugin.decorators';
import type { WecomPluginRuntime } from './wecom-plugin.types';
import { wecomPluginCaller } from './wecom-plugin.util';
@Controller('wecom/plugin')
@UseGuards(WecomPluginGuard)
@@ -28,7 +29,7 @@ export class WecomPluginController {
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryOrders(plugin, pluginCaller(req), q, page, pageSize);
return this.query.queryOrders(plugin, wecomPluginCaller(req), q, page, pageSize);
}
@Get('users')
@@ -39,7 +40,7 @@ export class WecomPluginController {
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryUsers(plugin, pluginCaller(req), q, page, pageSize);
return this.query.queryUsers(plugin, wecomPluginCaller(req), q, page, pageSize);
}
@Get('stores')
@@ -50,7 +51,7 @@ export class WecomPluginController {
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryStores(plugin, pluginCaller(req), q, page, pageSize);
return this.query.queryStores(plugin, wecomPluginCaller(req), q, page, pageSize);
}
@Get('store-audits')
@@ -62,7 +63,7 @@ export class WecomPluginController {
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryStoreAudits(plugin, pluginCaller(req), q, status, page, pageSize);
return this.query.queryStoreAudits(plugin, wecomPluginCaller(req), q, status, page, pageSize);
}
@Get('store-info-audits')
@@ -73,7 +74,7 @@ export class WecomPluginController {
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryStoreInfoAudits(plugin, pluginCaller(req), status, page, pageSize);
return this.query.queryStoreInfoAudits(plugin, wecomPluginCaller(req), status, page, pageSize);
}
@Get('store-info-audits/:id')
@@ -82,7 +83,7 @@ export class WecomPluginController {
@Req() req: Request,
@Param('id') id: string,
) {
return this.query.queryStoreInfoAuditDetail(plugin, pluginCaller(req), id);
return this.query.queryStoreInfoAuditDetail(plugin, wecomPluginCaller(req), id);
}
@Get('store-package-audits')
@@ -93,7 +94,7 @@ export class WecomPluginController {
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryStorePackageAudits(plugin, pluginCaller(req), status, page, pageSize);
return this.query.queryStorePackageAudits(plugin, wecomPluginCaller(req), status, page, pageSize);
}
@Get('store-package-audits/:id')
@@ -102,7 +103,7 @@ export class WecomPluginController {
@Req() req: Request,
@Param('id') id: string,
) {
return this.query.queryStorePackageAuditDetail(plugin, pluginCaller(req), id);
return this.query.queryStorePackageAuditDetail(plugin, wecomPluginCaller(req), id);
}
@Get('redeems')
@@ -113,7 +114,7 @@ export class WecomPluginController {
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryRedeems(plugin, pluginCaller(req), q, page, pageSize);
return this.query.queryRedeems(plugin, wecomPluginCaller(req), q, page, pageSize);
}
@Get('promo-codes')
@@ -124,7 +125,7 @@ export class WecomPluginController {
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryPromoCodes(plugin, pluginCaller(req), q, page, pageSize);
return this.query.queryPromoCodes(plugin, wecomPluginCaller(req), q, page, pageSize);
}
@Get('promo-codes/:code/stats')
@@ -133,7 +134,7 @@ export class WecomPluginController {
@Req() req: Request,
@Param('code') code: string,
) {
return this.query.queryPromoCodeStats(plugin, pluginCaller(req), code);
return this.query.queryPromoCodeStats(plugin, wecomPluginCaller(req), code);
}
@Get('partners')
@@ -144,7 +145,7 @@ export class WecomPluginController {
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryPartners(plugin, pluginCaller(req), q, page, pageSize);
return this.query.queryPartners(plugin, wecomPluginCaller(req), q, page, pageSize);
}
@Get('partners/:partnerId/users')
@@ -159,7 +160,7 @@ export class WecomPluginController {
) {
return this.query.queryPartnerUsers(
plugin,
pluginCaller(req),
wecomPluginCaller(req),
partnerId,
from,
to,
@@ -180,7 +181,7 @@ export class WecomPluginController {
) {
return this.query.queryPartnerStores(
plugin,
pluginCaller(req),
wecomPluginCaller(req),
partnerId,
from,
to,
@@ -201,7 +202,7 @@ export class WecomPluginController {
) {
return this.query.queryPartnerOrders(
plugin,
pluginCaller(req),
wecomPluginCaller(req),
partnerId,
from,
to,
@@ -216,12 +217,31 @@ export class WecomPluginController {
@Req() req: Request,
@Query('kind') kind?: string,
) {
return this.query.queryMetrics(plugin, pluginCaller(req), kind);
return this.query.queryMetrics(plugin, wecomPluginCaller(req), kind);
}
@Get('versions')
versions(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Query('q') q?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryVersions(plugin, wecomPluginCaller(req), q, status, page, pageSize);
}
@Get('tasks')
tasks(
@CurrentWecomPlugin() plugin: WecomPluginRuntime,
@Req() req: Request,
@Query('q') q?: string,
@Query('status') status?: string,
@Query('versionNo') versionNo?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.query.queryTasks(plugin, wecomPluginCaller(req), q, status, versionNo, page, pageSize);
}
}
function pluginCaller(req: Request): string {
const raw = req.headers['x-wecom-userid'] ?? req.headers['userid'];
const v = Array.isArray(raw) ? raw[0] : raw;
return String(v || 'plugin').trim() || 'plugin';
}
@@ -328,5 +328,52 @@ export const WECOM_PLUGIN_OPENAPI = {
},
},
},
'/versions': {
get: {
summary: '查询开发版本',
description: '按版本号或说明模糊查询;status 为版本状态',
operationId: '查询开发版本',
parameters: [
{ name: 'q', in: 'query', required: false, schema: { type: 'string' }, description: '版本号关键词,如 v3.5.18' },
{
name: 'status',
in: 'query',
required: false,
schema: { type: 'string', enum: ['PENDING', 'IN_PROGRESS', 'TESTING', 'RELEASED', 'STOPPED'] },
},
...pageParams,
],
responses: {
200: { description: '版本列表', content: { 'application/json': { schema: envelope({ type: 'object' }) } } },
401: unauthorized,
},
},
},
'/tasks': {
get: {
summary: '查询开发任务',
description:
'按任务状态筛选。statusTODO 待开发 / IN_PROGRESS 开发中 / DEVELOPED 已开发 / RELEASED 已上线 / STOPPED 已停止。可选 q、versionNo。',
operationId: '查询开发任务',
parameters: [
{ name: 'q', in: 'query', required: false, schema: { type: 'string' }, description: '任务编号或内容' },
{
name: 'status',
in: 'query',
required: false,
schema: {
type: 'string',
enum: ['TODO', 'IN_PROGRESS', 'DEVELOPED', 'RELEASED', 'STOPPED'],
},
},
{ name: 'versionNo', in: 'query', required: false, schema: { type: 'string' }, description: '限定某版本号' },
...pageParams,
],
responses: {
200: { description: '任务列表', content: { 'application/json': { schema: envelope({ type: 'object' }) } } },
401: unauthorized,
},
},
},
},
} as const;
@@ -0,0 +1,26 @@
import { BadRequestException } from '@nestjs/common';
import { describe, expect, it } from 'vitest';
import { wecomPluginCaller, wecomPluginMcpErrorText, wecomPluginQueryArg } from './wecom-plugin.util';
describe('wecomPluginCaller', () => {
it('prefers X-WeCom-User-Id', () => {
expect(wecomPluginCaller({ headers: { 'x-wecom-userid': 'alice', userid: 'bob' } })).toBe('alice');
});
it('falls back to plugin', () => {
expect(wecomPluginCaller({ headers: {} })).toBe('plugin');
});
});
describe('wecomPluginMcpErrorText', () => {
it('unwraps Nest HttpException message', () => {
expect(wecomPluginMcpErrorText(new BadRequestException('请提供查询关键词 q'))).toBe('请提供查询关键词 q');
});
});
describe('wecomPluginQueryArg', () => {
it('stringifies numbers and drops empty', () => {
expect(wecomPluginQueryArg(2)).toBe('2');
expect(wecomPluginQueryArg(' ')).toBeUndefined();
});
});
@@ -0,0 +1,26 @@
import { HttpException } from '@nestjs/common';
export function wecomPluginCaller(req: { headers: Record<string, unknown> }): string {
const raw = req.headers['x-wecom-userid'] ?? req.headers['userid'];
const v = Array.isArray(raw) ? raw[0] : raw;
return String(v || 'plugin').trim() || 'plugin';
}
export function wecomPluginMcpErrorText(e: unknown): string {
if (e instanceof HttpException) {
const res = e.getResponse();
if (typeof res === 'string') return res;
if (res && typeof res === 'object' && 'message' in res) {
const message = (res as { message?: string | string[] }).message;
return Array.isArray(message) ? message.join(', ') : String(message ?? e.message);
}
return e.message;
}
return e instanceof Error ? e.message : String(e);
}
export function wecomPluginQueryArg(v?: string | number): string | undefined {
if (v == null) return undefined;
const s = String(v).trim();
return s ? s : undefined;
}
@@ -14,6 +14,8 @@ import { WecomBotSessionService } from './wecom-bot-session.service';
import { WecomPluginAuthService } from './wecom-plugin-auth.service';
import { WecomPluginController } from './wecom-plugin.controller';
import { WecomPluginGuard } from './wecom-plugin.guard';
import { WecomPluginMcpController } from './wecom-plugin-mcp.controller';
import { WecomPluginMcpFactory } from './wecom-plugin-mcp.factory';
import { WecomPluginQueryService } from './wecom-plugin-query.service';
/** 企微多机器人:依赖 Common(工单)+ Settlement/DevPlan + Promo + Integrations(短信)+ Llm */
@@ -26,7 +28,7 @@ import { WecomPluginQueryService } from './wecom-plugin-query.service';
PromoModule,
LlmModule,
],
controllers: [WecomPluginController],
controllers: [WecomPluginController, WecomPluginMcpController],
providers: [
WecomBotSessionService,
WecomBotAuditService,
@@ -37,6 +39,7 @@ import { WecomPluginQueryService } from './wecom-plugin-query.service';
WecomPluginAuthService,
WecomPluginGuard,
WecomPluginQueryService,
WecomPluginMcpFactory,
],
exports: [WecomAibotService, WecomBotAuditService],
})