feat(wecom): 企微 MCP 增加开发计划版本与任务查询
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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:
|
||||
'按任务状态筛选。status:TODO 待开发 / 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;
|
||||
|
||||
Reference in New Issue
Block a user