feat(wecom): 企微 API 插件增加 MCP Streamable HTTP 端点
与 REST/OpenAPI 并存,按实例权限自动 tools/list,免手填工具。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 { 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 type { 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(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,46 @@
|
||||
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' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
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'),
|
||||
),
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -13,7 +13,10 @@ 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,
|
||||
type StoreInfoChangeableField,
|
||||
@@ -49,10 +52,15 @@ export class WecomPluginQueryService {
|
||||
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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user