feat(wecom): 企微 API 插件增加 MCP Streamable HTTP 端点

与 REST/OpenAPI 并存,按实例权限自动 tools/list,免手填工具。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-08 16:57:42 +08:00
parent cae426fbc2
commit 976bde37cc
23 changed files with 1212 additions and 63 deletions
@@ -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],
})