feat(wecom): 企微 MCP 增加开发计划版本与任务查询

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-08 17:37:41 +08:00
parent d0d5c9ac56
commit 5d0beb5733
13 changed files with 386 additions and 4 deletions
@@ -43,4 +43,33 @@ describe('WECOM_PLUGIN_MCP_TOOL_DEFS', () => {
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',
);
});
});
@@ -201,4 +201,47 @@ export const WECOM_PLUGIN_MCP_TOOL_DEFS: ToolDef[] = [
),
),
},
{
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,
@@ -19,11 +25,21 @@ import {
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';
@@ -41,18 +57,68 @@ 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: '企微智能机器人只读插件。查询订单、用户、门店经营、核销、推广码、经营指标、审核对照合伙人关联数据。',
description: '企微智能机器人只读插件。查询订单、用户、门店经营、核销、推广码、经营指标、审核对照合伙人关联数据、开发计划版本与任务。',
auth: { header: 'X-Api-Key' },
permissions: plugin.permissions,
tools: plugin.permissions.flatMap((p) => WECOM_PLUGIN_TOOL_PATHS[p]),
@@ -1007,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;
@@ -219,4 +219,29 @@ export class WecomPluginController {
) {
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);
}
}
@@ -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;