Files
dukang/server/dukang-api/src/integrations/wecom/wecom-bot-capability.service.ts
T
2026-08-04 21:38:49 +08:00

1400 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Inject, Injectable } from '@nestjs/common';
import {
SUPPORT_TICKET_STATUS_LABELS,
SUPPORT_TICKET_TYPE_LABELS,
TICKET_TYPE_LABELS,
WECOM_BOT_PERMISSION_LABELS,
mapSupportTicketTypeToDevPlanTask,
type AfterSaleTicketType,
type SupportTicketTypeDto,
type WecomBotPermission,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { TicketService } from '../../modules/common/ticket.service';
import { SupportTicketService } from '../../modules/common/support-ticket.service';
import { DevPlanService } from '../../modules/dev-plan/dev-plan.service';
import { SettlementService } from '../../modules/settlement/settlement.service';
import { SMS_PROVIDER } from '../integrations.constants';
import type { ISmsProvider } from '../sms/sms.interface';
import type { WecomBotRuntimeConfig } from './wecom-bot.types';
import { wecomBotCanReview, wecomBotHasPermission } from './wecom-bot.types';
import { WecomBotSessionService } from './wecom-bot-session.service';
import { WecomBotAuditService } from './wecom-bot-audit.service';
import { searchHandbook } from './wecom-handbook';
const SMS_SCENE = 'WECOM_USER_VIEW';
const PHONE_RE = /^1\d{10}$/;
const TICKET_TYPE_ALIASES: Record<string, AfterSaleTicketType> = {
仅退款: 'REFUND',
退款: 'REFUND',
refund: 'REFUND',
破损补发: 'RESHIPMENT',
补发: 'RESHIPMENT',
reshipment: 'RESHIPMENT',
破损退货: 'DAMAGE_RETURN',
退货: 'DAMAGE_RETURN',
damage_return: 'DAMAGE_RETURN',
退货退款: 'RETURN_REFUND',
return_refund: 'RETURN_REFUND',
};
const SUPPORT_TYPE_ALIASES: Record<string, SupportTicketTypeDto> = {
bug: 'BUG',
BUG: 'BUG',
缺陷: 'BUG',
建议: 'SUGGESTION',
suggestion: 'SUGGESTION',
其他: 'OTHER',
other: 'OTHER',
};
@Injectable()
export class WecomBotCapabilityService {
constructor(
private readonly prisma: PrismaService,
private readonly ticketService: TicketService,
private readonly supportTicketService: SupportTicketService,
private readonly devPlan: DevPlanService,
private readonly settlement: SettlementService,
private readonly session: WecomBotSessionService,
private readonly audit: WecomBotAuditService,
@Inject(SMS_PROVIDER) private readonly sms: ISmsProvider,
) {}
buildHelp(bot: WecomBotRuntimeConfig): string {
const lines = [`**${bot.name}**`, '', '通用:`帮助` · `状态`', ''];
if (wecomBotHasPermission(bot, 'ticket.create')) {
lines.push(
'**售后工单**',
'`工单 <订单号> <类型> [备注]`',
'类型:仅退款 / 破损补发 / 破损退货 / 退货退款',
'',
);
}
if (wecomBotHasPermission(bot, 'user.read_sms')) {
lines.push(
'**查用户(需短信验证)**',
'`查用户 <手机号>` → 发验证码 · `验证 <验证码>`',
'',
);
}
if (wecomBotHasPermission(bot, 'user.read')) {
lines.push('**查用户**', '`用户号 <用户号>`', '');
}
if (wecomBotHasPermission(bot, 'order.read')) {
lines.push('**订单**', '`查订单 <订单号>` · `订单 <订单号>`', '');
}
if (wecomBotHasPermission(bot, 'delivery.read')) {
lines.push('**配送**', '`快递 <订单号|运单号>`', '');
}
if (wecomBotHasPermission(bot, 'store.read')) {
lines.push('**门店**', '`查门店 <关键词>`', '');
}
if (wecomBotHasPermission(bot, 'redeem.read')) {
lines.push('**核销**', '`核销 <订单号|门店名>`', '');
}
if (wecomBotHasPermission(bot, 'ticket.read')) {
lines.push('**售后工单查询**', '`售后工单 <工单号>`', '');
}
if (wecomBotHasPermission(bot, 'finance.store_bill.read')) {
lines.push('**财务**', '`门店账单 [门店名]` · `合伙人账单` · `酒厂账单` · `物流账单`', '');
lines.push('', '`门店打款` · `门店提现`', '');
}
if (wecomBotHasPermission(bot, 'support_ticket.create')) {
lines.push('**技术支持提单**', '`提单 <BUG|建议|其他> <标题> [| 详情]`', '');
}
if (wecomBotHasPermission(bot, 'support_ticket.read')) {
lines.push(
'**技术支持工单**',
'`工单 ST-001` · `未完成技术支持工单` · `开发任务` · `版本 v3.4.11`',
'',
);
}
if (
wecomBotHasPermission(bot, 'dev_plan.task.create') ||
wecomBotHasPermission(bot, 'dev_plan.task.update_status')
) {
const cmds: string[] = [];
if (wecomBotHasPermission(bot, 'dev_plan.task.create')) cmds.push('`新建任务 BUG|需求|优化 <描述>`');
if (wecomBotHasPermission(bot, 'dev_plan.task.update_status')) cmds.push('`修改任务 <编号> TODO|DEVELOPED|RELEASED`');
lines.push('**开发任务管理**', cmds.join(' · '), '');
}
if (
wecomBotHasPermission(bot, 'dev_plan.version.create') ||
wecomBotHasPermission(bot, 'dev_plan.version.update_status') ||
wecomBotHasPermission(bot, 'dev_plan.version.link_tasks')
) {
const cmds: string[] = [];
if (wecomBotHasPermission(bot, 'dev_plan.version.create')) cmds.push('`新建版本 <版本号>`');
if (wecomBotHasPermission(bot, 'dev_plan.version.update_status')) cmds.push('`修改版本 <版本号> <状态>`');
if (wecomBotHasPermission(bot, 'dev_plan.version.link_tasks')) cmds.push('`关联任务 <版本号> <任务1,任务2>`');
lines.push('**开发版本管理**', cmds.join(' · '), '');
}
if (
wecomBotHasPermission(bot, 'support_ticket.review') ||
wecomBotHasPermission(bot, 'support_ticket.approve') ||
wecomBotHasPermission(bot, 'support_ticket.reject')
) {
lines.push(
'**审批(SuperAdmin 白名单)**',
'`审批 驳回 <工单号> 理由:…` · `驳回 <工单号> 理由…` · `审批 通过 <工单号>`',
wecomBotHasPermission(bot, 'support_ticket.approve')
? '(可审批通过)'
: '',
wecomBotHasPermission(bot, 'support_ticket.reject')
? '(可驳回)'
: '',
'',
);
}
if (wecomBotHasPermission(bot, 'handbook.read')) {
lines.push('**手册**', '`手册 [关键词]`', '');
}
if (wecomBotHasPermission(bot, 'server_log.read')) {
lines.push('**日志**', '`日志 [关键词]` · `三方日志 [provider]`', '');
}
if (bot.aiEnabled && bot.llmConfigId) {
lines.push('**智能问答**:未匹配指令时由 AI 理解意图并查询(结果不含 JSON)', '');
}
lines.push(`当前权限:${bot.permissions.map((p) => WECOM_BOT_PERMISSION_LABELS[p] || p).join('、') || '无'}`);
return lines.join('\n');
}
async dispatch(
bot: WecomBotRuntimeConfig,
wecomUserId: string,
text: string,
opts?: { skipNaturalFallback?: boolean },
): Promise<string | null> {
const content = text.trim();
if (!content) return this.buildHelp(bot);
const reviewCmd = parseReviewCommand(content);
if (reviewCmd) {
if (reviewCmd.decision === 'APPROVE') {
return this.audit.run(
{
bot,
wecomUserId,
action: 'support_ticket.review.approve',
permission: 'support_ticket.approve',
inputSummary: reviewCmd.ticketNo,
},
() => this.approveSupportTicket(bot, wecomUserId, reviewCmd.ticketNo),
);
}
return this.audit.run(
{
bot,
wecomUserId,
action: 'support_ticket.review.reject',
permission: 'support_ticket.reject',
inputSummary: `${reviewCmd.ticketNo} ${reviewCmd.reason}`.trim(),
},
() => this.rejectSupportTicket(bot, wecomUserId, reviewCmd.ticketNo, reviewCmd.reason),
);
}
if (
(wecomBotHasPermission(bot, 'support_ticket.review') ||
wecomBotHasPermission(bot, 'support_ticket.approve') ||
wecomBotHasPermission(bot, 'support_ticket.reject')) &&
/审批/.test(content) &&
/(通过|驳回|approve|reject)/i.test(content)
) {
return '审批指令格式:`审批 驳回 <工单号> 理由:…` 或 `驳回 <工单号> 理由…`';
}
if (/^(查用户|用户)\s+/i.test(content) && !/^用户号/i.test(content)) {
const phone = content.replace(/^(查用户|用户)\s+/i, '').trim();
return this.audit.run(
{ bot, wecomUserId, action: 'user.read_sms.start', permission: 'user.read_sms', inputSummary: maskPhone(phone) },
() => this.startUserView(bot, wecomUserId, phone),
);
}
if (/^(验证|verify)\s+/i.test(content)) {
const code = content.replace(/^(验证|verify)\s+/i, '').trim();
return this.audit.run(
{ bot, wecomUserId, action: 'user.read_sms.verify', permission: 'user.read_sms' },
() => this.verifyUserView(bot, wecomUserId, code),
);
}
if (/^(快递|配送|物流)\s+/i.test(content)) {
const q = content.replace(/^(快递|配送|物流)\s+/i, '').trim();
return this.audit.run(
{ bot, wecomUserId, action: 'delivery.read', permission: 'delivery.read', inputSummary: q },
() => this.lookupDelivery(bot, q),
);
}
if (/^(工单|创建工单)\s+/i.test(content)) {
const rest = content.replace(/^(工单|创建工单)\s+/i, '').trim();
if (/^ST/i.test(rest)) {
return this.audit.run(
{ bot, wecomUserId, action: 'support_ticket.read', permission: 'support_ticket.read', inputSummary: rest },
() => this.supportTicketDetail(bot, rest),
);
}
return this.audit.run(
{ bot, wecomUserId, action: 'ticket.create', permission: 'ticket.create', inputSummary: rest.slice(0, 120) },
() => this.createAfterSaleTicket(bot, rest, wecomUserId),
);
}
if (/^(提单|技术支持)\s+/i.test(content)) {
const rest = content.replace(/^(提单|技术支持)\s+/i, '').trim();
return this.audit.run(
{ bot, wecomUserId, action: 'support_ticket.create', permission: 'support_ticket.create', inputSummary: rest.slice(0, 120) },
() => this.createSupportTicket(bot, rest, wecomUserId),
);
}
if (/^(进度|开发进度|技术支持工单|未完成技术支持工单)/i.test(content)) {
const rest = content.replace(/^(进度|开发进度|技术支持工单|未完成技术支持工单)\s*/i, '').trim();
if (!rest && /未完成/.test(content)) {
return this.audit.run(
{ bot, wecomUserId, action: 'support_ticket.read.open', permission: 'support_ticket.read' },
() => this.listOpenSupportTickets(bot),
);
}
return this.audit.run(
{ bot, wecomUserId, action: 'support_ticket.read', permission: 'support_ticket.read', inputSummary: rest || undefined },
() => this.supportProgress(bot, rest),
);
}
if (/^(手册|帮助文档|文档)/i.test(content)) {
const q = content.replace(/^(手册|帮助文档|文档)\s*/i, '').trim();
return this.audit.run(
{ bot, wecomUserId, action: 'handbook.read', permission: 'handbook.read', inputSummary: q || undefined },
() => this.queryHandbook(bot, q),
);
}
if (/^(日志|错误日志|服务端日志)/i.test(content)) {
const q = content.replace(/^(日志|错误日志|服务端日志)\s*/i, '').trim();
return this.audit.run(
{ bot, wecomUserId, action: 'server_log.read', permission: 'server_log.read', inputSummary: q || undefined },
() => this.queryServerLogs(bot, q),
);
}
if (/^(三方日志|第三方日志)/i.test(content)) {
const q = content.replace(/^(三方日志|第三方日志)\s*/i, '').trim();
return this.audit.run(
{ bot, wecomUserId, action: 'server_log.read.third_party', permission: 'server_log.read', inputSummary: q || undefined },
() => this.queryThirdPartyLogs(bot, q),
);
}
if (/^(查订单|订单查询|订单)\s+/i.test(content)) {
const orderNo = content.replace(/^(查订单|订单查询|订单)\s+/i, '').trim();
return this.audit.run(
{ bot, wecomUserId, action: 'order.read', permission: 'order.read', inputSummary: orderNo },
() => this.queryOrder(bot, orderNo),
);
}
if (/^(用户号|查用户号)\s+/i.test(content)) {
const userNo = content.replace(/^(用户号|查用户号)\s+/i, '').trim();
return this.audit.run(
{ bot, wecomUserId, action: 'user.read', permission: 'user.read', inputSummary: userNo },
() => this.queryUserByNo(bot, userNo),
);
}
if (/^(查门店|门店)\s+/i.test(content)) {
const q = content.replace(/^(查门店|门店)\s+/i, '').trim();
return this.audit.run(
{ bot, wecomUserId, action: 'store.read', permission: 'store.read', inputSummary: q },
() => this.queryStore(bot, q),
);
}
if (/^(核销|核销记录)\s+/i.test(content)) {
const q = content.replace(/^(核销|核销记录)\s+/i, '').trim();
return this.audit.run(
{ bot, wecomUserId, action: 'redeem.read', permission: 'redeem.read', inputSummary: q },
() => this.queryRedeem(bot, q),
);
}
if (/^(售后工单)\s+/i.test(content)) {
const q = content.replace(/^(售后工单)\s+/i, '').trim();
return this.audit.run(
{ bot, wecomUserId, action: 'ticket.read', permission: 'ticket.read', inputSummary: q },
() => this.queryAfterSaleTicket(bot, q),
);
}
if (/^(门店账单|账单)\s*/i.test(content)) {
const q = content.replace(/^(门店账单|账单)\s*/i, '').trim();
return this.audit.run(
{ bot, wecomUserId, action: 'finance.store_bill.read', permission: 'finance.store_bill.read', inputSummary: q || undefined },
() => this.queryStoreBills(bot, q),
);
}
if (/^合伙人账单/i.test(content)) {
return this.audit.run(
{ bot, wecomUserId, action: 'finance.partner_bill.read', permission: 'finance.partner_bill.read' },
() => this.queryPartnerBills(bot),
);
}
if (/^酒厂账单/i.test(content)) {
return this.audit.run(
{ bot, wecomUserId, action: 'finance.winery_bill.read', permission: 'finance.winery_bill.read' },
() => this.queryWineryBills(bot),
);
}
if (/^物流账单/i.test(content)) {
return this.audit.run(
{ bot, wecomUserId, action: 'finance.logistics_bill.read', permission: 'finance.logistics_bill.read' },
() => this.queryLogisticsBills(bot),
);
}
if (/^门店打款/i.test(content)) {
return this.audit.run(
{ bot, wecomUserId, action: 'finance.payout.read', permission: 'finance.payout.read' },
() => this.queryStorePayouts(bot),
);
}
if (/^门店提现/i.test(content)) {
return this.audit.run(
{ bot, wecomUserId, action: 'finance.withdrawal.read', permission: 'finance.withdrawal.read' },
() => this.queryStoreWithdrawals(bot),
);
}
if (/^(开发任务|任务池)/i.test(content)) {
const status = content.replace(/^(开发任务|任务池)\s*/i, '').trim();
return this.audit.run(
{ bot, wecomUserId, action: 'dev_plan.task.read', permission: 'dev_plan.task.read', inputSummary: status || undefined },
() => this.queryDevPlanTasks(bot, status),
);
}
if (/^(开发版本|版本)/i.test(content)) {
const versionNo = content.replace(/^(开发版本|版本池|版本)\s*/i, '').trim();
return this.audit.run(
{ bot, wecomUserId, action: 'dev_plan.version.read', permission: 'dev_plan.version.read', inputSummary: versionNo || undefined },
() => this.queryDevPlanVersions(bot, versionNo),
);
}
if (/^(新建任务|创建任务)\s+/i.test(content)) {
const rest = content.replace(/^(新建任务|创建任务)\s+/i, '').trim();
return this.audit.run(
{ bot, wecomUserId, action: 'dev_plan.task.create', permission: 'dev_plan.task.create', inputSummary: rest.slice(0, 120) },
() => this.createDevPlanTask(bot, wecomUserId, rest),
);
}
if (/^(任务状态|修改任务)\s+/i.test(content)) {
const rest = content.replace(/^(任务状态|修改任务)\s+/i, '').trim();
return this.audit.run(
{ bot, wecomUserId, action: 'dev_plan.task.update_status', permission: 'dev_plan.task.update_status', inputSummary: rest.slice(0, 120) },
() => this.updateDevPlanTaskStatus(bot, wecomUserId, rest),
);
}
if (/^(新建版本|创建版本)\s+/i.test(content)) {
const rest = content.replace(/^(新建版本|创建版本)\s+/i, '').trim();
return this.audit.run(
{ bot, wecomUserId, action: 'dev_plan.version.create', permission: 'dev_plan.version.create', inputSummary: rest.slice(0, 120) },
() => this.createDevPlanVersion(bot, wecomUserId, rest),
);
}
if (/^(版本状态|修改版本)\s+/i.test(content)) {
const rest = content.replace(/^(版本状态|修改版本)\s+/i, '').trim();
return this.audit.run(
{ bot, wecomUserId, action: 'dev_plan.version.update_status', permission: 'dev_plan.version.update_status', inputSummary: rest.slice(0, 120) },
() => this.updateDevPlanVersionStatus(bot, wecomUserId, rest),
);
}
if (/^(关联任务|版本关联|关联到版本)\s+/i.test(content)) {
const rest = content.replace(/^(关联任务|版本关联|关联到版本)\s+/i, '').trim();
return this.audit.run(
{ bot, wecomUserId, action: 'dev_plan.version.link_tasks', permission: 'dev_plan.version.link_tasks', inputSummary: rest.slice(0, 120) },
() => this.linkTasksToVersion(bot, wecomUserId, rest),
);
}
const nlReply = await this.tryNaturalLanguageQuery(bot, wecomUserId, content);
if (nlReply) return nlReply;
if (
!opts?.skipNaturalFallback &&
wecomBotHasPermission(bot, 'handbook.read') &&
content.length >= 2
) {
const hit = searchHandbook(content, 1);
if (hit.length) return formatHandbook(hit);
}
if (opts?.skipNaturalFallback) return null;
return `未识别指令。\n\n${this.buildHelp(bot)}`;
}
async runTool(
bot: WecomBotRuntimeConfig,
wecomUserId: string,
toolName: string,
args: string,
): Promise<string> {
const name = toolName.toLowerCase().replace(/-/g, '_');
const ctx = { bot, wecomUserId, action: `tool:${name}`, inputSummary: args?.slice(0, 120) || undefined };
switch (name) {
case 'support_tickets_open':
return this.audit.run({ ...ctx, permission: 'support_ticket.read' }, () =>
this.listOpenSupportTickets(bot),
);
case 'support_ticket_approve':
return this.audit.run({ ...ctx, permission: 'support_ticket.approve' }, () =>
this.approveSupportTicket(bot, wecomUserId, args),
);
case 'support_ticket_reject': {
const [ticketNo, ...reasonParts] = (args ?? '').split(/\s+/).filter(Boolean);
return this.audit.run({ ...ctx, permission: 'support_ticket.reject' }, () =>
this.rejectSupportTicket(bot, wecomUserId, ticketNo ?? '', reasonParts.join(' ')),
);
}
case 'support_ticket_read':
case 'support_progress':
return this.audit.run({ ...ctx, permission: 'support_ticket.read' }, () =>
this.supportProgress(bot, args),
);
case 'order_read':
case 'order_lookup':
return this.audit.run({ ...ctx, permission: 'order.read' }, () => this.queryOrder(bot, args));
case 'user_read':
case 'user_lookup':
return this.audit.run({ ...ctx, permission: 'user.read' }, () => this.queryUserByNo(bot, args));
case 'store_read':
case 'store_lookup':
return this.audit.run({ ...ctx, permission: 'store.read' }, () => this.queryStore(bot, args));
case 'redeem_read':
return this.audit.run({ ...ctx, permission: 'redeem.read' }, () => this.queryRedeem(bot, args));
case 'delivery_read':
case 'delivery_lookup':
return this.audit.run({ ...ctx, permission: 'delivery.read' }, () => this.lookupDelivery(bot, args));
case 'dev_plan_tasks':
case 'dev_tasks':
return this.audit.run({ ...ctx, permission: 'dev_plan.task.read' }, () =>
this.queryDevPlanTasks(bot, args),
);
case 'dev_plan_versions':
case 'dev_versions':
return this.audit.run({ ...ctx, permission: 'dev_plan.version.read' }, () =>
this.queryDevPlanVersions(bot, args),
);
case 'dev_plan_task_create':
return this.audit.run({ ...ctx, permission: 'dev_plan.task.create' }, () =>
this.createDevPlanTask(bot, wecomUserId, args),
);
case 'dev_plan_task_update_status':
return this.audit.run({ ...ctx, permission: 'dev_plan.task.update_status' }, () =>
this.updateDevPlanTaskStatus(bot, wecomUserId, args),
);
case 'dev_plan_version_create':
return this.audit.run({ ...ctx, permission: 'dev_plan.version.create' }, () =>
this.createDevPlanVersion(bot, wecomUserId, args),
);
case 'dev_plan_version_update_status':
return this.audit.run({ ...ctx, permission: 'dev_plan.version.update_status' }, () =>
this.updateDevPlanVersionStatus(bot, wecomUserId, args),
);
case 'dev_plan_version_link_tasks':
return this.audit.run({ ...ctx, permission: 'dev_plan.version.link_tasks' }, () =>
this.linkTasksToVersion(bot, wecomUserId, args),
);
case 'finance_store_bill':
return this.audit.run({ ...ctx, permission: 'finance.store_bill.read' }, () =>
this.queryStoreBills(bot, args),
);
case 'finance_partner_bill':
return this.audit.run({ ...ctx, permission: 'finance.partner_bill.read' }, () =>
this.queryPartnerBills(bot),
);
case 'finance_winery_bill':
return this.audit.run({ ...ctx, permission: 'finance.winery_bill.read' }, () =>
this.queryWineryBills(bot),
);
case 'finance_logistics_bill':
return this.audit.run({ ...ctx, permission: 'finance.logistics_bill.read' }, () =>
this.queryLogisticsBills(bot),
);
case 'finance_payout':
return this.audit.run({ ...ctx, permission: 'finance.payout.read' }, () =>
this.queryStorePayouts(bot),
);
case 'finance_withdrawal':
return this.audit.run({ ...ctx, permission: 'finance.withdrawal.read' }, () =>
this.queryStoreWithdrawals(bot),
);
case 'server_logs':
return this.audit.run({ ...ctx, permission: 'server_log.read' }, () =>
this.queryServerLogs(bot, args),
);
case 'handbook_read':
return this.audit.run({ ...ctx, permission: 'handbook.read' }, () =>
this.queryHandbook(bot, args),
);
default:
throw new Error(`未知工具:${toolName}`);
}
}
async tryNaturalLanguageQuery(
bot: WecomBotRuntimeConfig,
wecomUserId: string,
content: string,
): Promise<string | null> {
if (
wecomBotHasPermission(bot, 'support_ticket.read') &&
/(技术支持|支持工单)/.test(content) &&
/工单/.test(content) &&
/(未完成|进行中|待处理|未关闭|所有|全部|列出|查询|查看|有哪些|现在)/.test(content)
) {
return this.audit.run(
{ bot, wecomUserId, action: 'nl:support_tickets_open', permission: 'support_ticket.read' },
() => this.listOpenSupportTickets(bot),
);
}
if (
wecomBotHasPermission(bot, 'support_ticket.read') &&
/(开发进度|工单进度|技术支持进度)/.test(content)
) {
return this.audit.run(
{ bot, wecomUserId, action: 'nl:support_progress', permission: 'support_ticket.read' },
() => this.supportProgress(bot, ''),
);
}
const orderMatch =
content.match(/(?:订单|order)[号#]?\s*[:]?\s*([A-Za-z0-9-]+)/i) ||
content.match(/\b(DK[A-Za-z0-9-]+)\b/);
if (orderMatch && wecomBotHasPermission(bot, 'order.read')) {
return this.audit.run(
{
bot,
wecomUserId,
action: 'nl:order.read',
permission: 'order.read',
inputSummary: orderMatch[1],
},
() => this.queryOrder(bot, orderMatch[1]),
);
}
return null;
}
private requirePerm(bot: WecomBotRuntimeConfig, permission: WecomBotPermission) {
if (!wecomBotHasPermission(bot, permission)) {
throw new Error(`当前机器人无权限:${permission}`);
}
}
private requireReview(
bot: WecomBotRuntimeConfig,
wecomUserId: string,
specificPermission?: WecomBotPermission,
) {
// 支持细粒度权限(approve/reject),回退到通用的 review
const effectivePerm = specificPermission ?? 'support_ticket.review';
const hasReview = wecomBotHasPermission(bot, 'support_ticket.review');
const hasSpecific = hasReview || wecomBotHasPermission(bot, effectivePerm);
if (!hasSpecific) {
throw new Error(`当前机器人无权限:${effectivePerm}`);
}
if (!wecomBotCanReview(bot, wecomUserId)) {
const ids = bot.reviewSuperAdminWecomUserIds.filter(Boolean);
throw new Error(
[
'您不在 SuperAdmin 审批白名单内,无法执行审批。',
`当前企微 userid${wecomUserId}`,
ids.length
? `白名单已配置 ${ids.length} 项(须为企微 userid,非姓名):${ids.join('、')}`
: '白名单为空,请在 HQ「企微机器人」填写审批 userid',
].join('\n'),
);
}
}
private async approveSupportTicket(
bot: WecomBotRuntimeConfig,
wecomUserId: string,
ticketNo: string,
) {
this.requireReview(bot, wecomUserId, 'support_ticket.approve');
if (!ticketNo) return '请提供工单号,例如:`通过 ST1234567890123`';
const ticket = await this.findSupportTicketByNo(ticketNo);
if (!ticket) return `未找到工单:${ticketNo}`;
const reviewer = await this.resolveReviewer(wecomUserId);
const result = await this.supportTicketService.review(ticket.id, reviewer, {
decision: 'APPROVE',
tasks: [
{
content: ticket.title,
type: mapSupportTicketTypeToDevPlanTask(ticket.ticketType as SupportTicketTypeDto),
},
],
});
const linked = (result as { linkedTasks?: Array<{ taskNo: string }> }).linkedTasks ?? [];
return [
'**工单已通过评审**',
`- 工单号:${ticket.ticketNo}`,
`- 状态:开发中`,
linked.length ? `- 已创建开发任务:${linked.map((t) => t.taskNo).join('、')}` : '',
]
.filter(Boolean)
.join('\n');
}
private async rejectSupportTicket(
bot: WecomBotRuntimeConfig,
wecomUserId: string,
ticketNo: string,
reason: string,
) {
this.requireReview(bot, wecomUserId, 'support_ticket.reject');
if (!ticketNo) return '请提供工单号,例如:`驳回 ST123 不符合需求`';
if (!reason?.trim()) return '请提供驳回理由,例如:`驳回 ST123 重复提单` 或 `审批 驳回 ST123 理由:…`';
const rejectReason = normalizeRejectReason(reason);
if (!rejectReason) return '请提供驳回理由';
const ticket = await this.findSupportTicketByNo(ticketNo);
if (!ticket) return `未找到工单:${ticketNo}`;
const reviewer = await this.resolveReviewer(wecomUserId);
await this.supportTicketService.review(ticket.id, reviewer, {
decision: 'REJECT',
rejectReason,
});
return [
'**工单已驳回**',
`- 工单号:${ticket.ticketNo}`,
`- 理由:${rejectReason}`,
].join('\n');
}
private async findSupportTicketByNo(ticketNo: string) {
return this.prisma.commonSupportTicket.findFirst({
where: { ticketNo: { contains: ticketNo.trim() } },
});
}
private async resolveReviewer(wecomUserId: string) {
const admin = await this.prisma.hqAccount.findFirst({
where: { adminRole: 'SUPER_ADMIN', status: 'ACTIVE' },
orderBy: { id: 'asc' },
select: { id: true, name: true },
});
if (!admin) throw new Error('系统未配置 SuperAdmin HQ 账号');
return { id: admin.id, name: `${admin.name}(企微:${wecomUserId})` };
}
private async startUserView(bot: WecomBotRuntimeConfig, wecomUserId: string, phone: string) {
this.requirePerm(bot, 'user.read_sms');
if (!PHONE_RE.test(phone)) return '请输入 11 位手机号,例如:`查用户 13800138000`';
const user = await this.prisma.user.findFirst({
where: { phone },
select: { id: true, userNo: true, phone: true },
});
if (!user) return `未找到手机号 ${phone} 对应的用户`;
await this.session.setPendingPhone(bot.key, wecomUserId, phone);
await this.sms.send(phone, SMS_SCENE);
return [
`已向 **${maskPhone(phone)}** 发送验证码(用户 ${user.userNo})。`,
'请回复:`验证 123456`',
].join('\n');
}
private async verifyUserView(bot: WecomBotRuntimeConfig, wecomUserId: string, code: string) {
this.requirePerm(bot, 'user.read_sms');
const sess = await this.session.get(bot.key, wecomUserId);
if (!sess?.phone) return '请先发送:`查用户 <手机号>`';
if (!code) return '请提供验证码';
try {
await this.sms.verify(sess.phone, code, SMS_SCENE);
} catch {
return '验证码错误或已过期,请重新 `查用户`';
}
const user = await this.prisma.user.findFirst({
where: { phone: sess.phone },
select: {
id: true,
userNo: true,
phone: true,
nickname: true,
status: true,
phoneVerifiedAt: true,
createdAt: true,
_count: { select: { orders: true } },
},
});
if (!user) return '用户不存在';
await this.session.markVerified(bot.key, wecomUserId, sess.phone, user.id.toString());
const coupons = await this.prisma.benefitCoupon.aggregate({
where: { userId: user.id, status: 'ACTIVE' },
_sum: { balance: true },
});
return [
'**用户摘要**(短信验证已通过)',
`- 用户号:${user.userNo}`,
`- 昵称:${user.nickname || '—'}`,
`- 手机:${user.phone}`,
`- 订单数:${user._count.orders}`,
`- 权益余额:¥${Number(coupons._sum.balance ?? 0).toFixed(2)}`,
].join('\n');
}
private async lookupDelivery(bot: WecomBotRuntimeConfig, q: string) {
this.requirePerm(bot, 'delivery.read');
if (!q) return '请提供订单号或运单号';
const byOrder = await this.prisma.orderDelivery.findMany({
where: { order: { orderNo: { contains: q } } },
take: 5,
orderBy: { updatedAt: 'desc' },
include: {
order: {
select: {
orderNo: true,
status: true,
receiverName: true,
receiverPhone: true,
productName: true,
},
},
},
});
const rows =
byOrder.length > 0
? byOrder
: await this.prisma.orderDelivery.findMany({
where: { trackingNo: { contains: q } },
take: 5,
orderBy: { updatedAt: 'desc' },
include: {
order: {
select: {
orderNo: true,
status: true,
receiverName: true,
receiverPhone: true,
productName: true,
},
},
},
});
if (!rows.length) return `未找到与「${q}」匹配的配送单`;
return rows
.map((d, i) => {
const o = d.order;
return [
`**配送 ${i + 1}**`,
`- 订单:${o.orderNo}${o.status}`,
`- 商品:${o.productName}`,
`- 运单:${d.trackingNo || '—'}`,
`- 收货:${o.receiverName} ${maskPhone(o.receiverPhone || '')}`,
].join('\n');
})
.join('\n\n');
}
private async createAfterSaleTicket(
bot: WecomBotRuntimeConfig,
rest: string,
wecomUserId: string,
) {
this.requirePerm(bot, 'ticket.create');
const parts = rest.split(/\s+/).filter(Boolean);
if (parts.length < 2) {
return '格式:`工单 <订单号> <类型> [备注]`';
}
const orderNo = parts[0];
const typeRaw = parts[1];
const remark = parts.slice(2).join(' ') || `企微客服创建 by ${wecomUserId}`;
const ticketType = TICKET_TYPE_ALIASES[typeRaw] || TICKET_TYPE_ALIASES[typeRaw.toLowerCase()];
if (!ticketType) return `未知类型「${typeRaw}」`;
const order = await this.prisma.order.findFirst({ where: { orderNo } });
if (!order) return `订单不存在:${orderNo}`;
const ticket = await this.ticketService.create({
ticketType,
refType: 'ORDER',
refId: order.id.toString(),
remark: `${remark} [wecom:${wecomUserId}]`,
});
return [
'**售后工单已创建**',
`- 工单号:${ticket.ticketNo}`,
`- 类型:${TICKET_TYPE_LABELS[ticketType]}`,
`- 订单:${orderNo}`,
].join('\n');
}
private async createSupportTicket(
bot: WecomBotRuntimeConfig,
rest: string,
wecomUserId: string,
) {
this.requirePerm(bot, 'support_ticket.create');
const m = rest.match(/^(\S+)\s+(.+)$/);
if (!m) return '格式:`提单 <BUG|建议|其他> <标题> [| 详情]`';
const typeRaw = m[1];
const ticketType =
SUPPORT_TYPE_ALIASES[typeRaw] || SUPPORT_TYPE_ALIASES[typeRaw.toLowerCase()];
if (!ticketType) return '类型请使用:BUG / 建议 / 其他';
const [titlePart, ...contentParts] = m[2].split('|');
const title = titlePart.trim();
const content = contentParts.join('|').trim();
if (!title) return '请填写标题';
const creator = await this.resolveCreator(wecomUserId);
const ticket = await this.supportTicketService.create(
{ ticketType, title, content: content || undefined, remark: '企微技术支持机器人' },
creator,
);
return [
'**技术支持工单已创建**',
`- 工单号:${ticket.ticketNo}`,
`- 类型:${SUPPORT_TICKET_TYPE_LABELS[ticketType]}`,
`- 标题:${title}`,
].join('\n');
}
private async supportTicketDetail(bot: WecomBotRuntimeConfig, ticketNo: string) {
this.requirePerm(bot, 'support_ticket.read');
return this.supportProgress(bot, ticketNo);
}
private async supportProgress(bot: WecomBotRuntimeConfig, ticketNo: string) {
this.requirePerm(bot, 'support_ticket.read');
if (ticketNo) {
const ticket = await this.findSupportTicketByNo(ticketNo);
if (!ticket) return `未找到工单:${ticketNo}`;
const detail = await this.supportTicketService.detail(ticket.id);
const linked = (detail as { linkedTasks?: Array<{ taskNo: string; status: string }> }).linkedTasks ?? [];
return [
`**${ticket.ticketNo}**`,
`- 类型:${SUPPORT_TICKET_TYPE_LABELS[ticket.ticketType as SupportTicketTypeDto] || ticket.ticketType}`,
`- 状态:${SUPPORT_TICKET_STATUS_LABELS[ticket.status as keyof typeof SUPPORT_TICKET_STATUS_LABELS] || ticket.status}`,
`- 标题:${ticket.title}`,
`- 创建人:${ticket.creatorName}`,
linked.length ? `- 关联任务:${linked.map((t) => `${t.taskNo}(${t.status})`).join('、')}` : '',
ticket.rejectReason ? `- 驳回原因:${ticket.rejectReason}` : '',
]
.filter(Boolean)
.join('\n');
}
const { items } = await this.supportTicketService.list({ page: 1, pageSize: 8 });
if (!items.length) return '暂无技术支持工单';
const list = (items as Array<{ ticketNo: string; status: string; title: string }>)
.map(
(t) =>
`- ${t.ticketNo} [${SUPPORT_TICKET_STATUS_LABELS[t.status as keyof typeof SUPPORT_TICKET_STATUS_LABELS] || t.status}] ${t.title}`,
)
.join('\n');
return [`**最近技术支持工单**`, list, '', '详情:`工单 <工单号>`'].join('\n');
}
private async listOpenSupportTickets(bot: WecomBotRuntimeConfig) {
this.requirePerm(bot, 'support_ticket.read');
const { items } = await this.supportTicketService.list({ page: 1, pageSize: 20 });
const open = (items as Array<{ ticketNo: string; status: string; title: string }>).filter((t) =>
['PENDING_REVIEW', 'DEVELOPING', 'TESTING'].includes(t.status),
);
if (!open.length) return '当前没有未完成的技术支持工单。';
const list = open
.map(
(t, i) =>
`${i + 1}. **${t.ticketNo}** [${SUPPORT_TICKET_STATUS_LABELS[t.status as keyof typeof SUPPORT_TICKET_STATUS_LABELS] || t.status}] ${t.title}`,
)
.join('\n');
return [`**未完成技术支持工单**${open.length} 条)`, list].join('\n');
}
private async queryHandbook(bot: WecomBotRuntimeConfig, q: string): Promise<string> {
this.requirePerm(bot, 'handbook.read');
if (!q) {
const catalog = searchHandbook('', 20)
.map((e) => `- ${e.title}`)
.join('\n');
return `**手册目录**\n${catalog}\n\n查询:\`手册 <关键词>\``;
}
const hits = searchHandbook(q, 3);
if (!hits.length) return `未找到与「${q}」相关的手册内容`;
return formatHandbook(hits);
}
private async queryServerLogs(bot: WecomBotRuntimeConfig, keyword: string) {
this.requirePerm(bot, 'server_log.read');
const rows = await this.prisma.logUserAnalytics.findMany({
where: keyword
? {
eventName: 'client_error',
OR: [
{ pagePath: { contains: keyword } },
{ extraJson: { string_contains: keyword } },
],
}
: { eventName: 'client_error' },
orderBy: { createdAt: 'desc' },
take: 8,
});
if (!rows.length) return keyword ? `未找到相关客户端报错` : '暂无近期客户端报错';
return [
`**最近客户端报错**`,
...rows.map((row, i) => formatClientErrorLog(row, i + 1)),
].join('\n\n');
}
private async queryThirdPartyLogs(bot: WecomBotRuntimeConfig, provider?: string) {
this.requirePerm(bot, 'server_log.read');
const where = provider ? { provider: provider.toUpperCase() as never } : {};
const rows = await this.prisma.logThirdParty.findMany({
where,
orderBy: { createdAt: 'desc' },
take: 8,
});
if (!rows.length) return '暂无近期第三方调用日志';
return rows
.map(
(row, i) =>
`${i + 1}. **${row.provider}/${row.scene}** ${row.status} · ${row.createdAt.toISOString().slice(0, 19).replace('T', ' ')}`,
)
.join('\n');
}
private async queryOrder(bot: WecomBotRuntimeConfig, orderNo: string) {
this.requirePerm(bot, 'order.read');
if (!orderNo) return '请提供订单号';
const order = await this.prisma.order.findFirst({
where: { orderNo: { contains: orderNo } },
include: {
user: { select: { userNo: true, phone: true, nickname: true } },
delivery: true,
},
});
if (!order) return `未找到订单:${orderNo}`;
return [
'**订单摘要**',
`- 订单号:${order.orderNo}`,
`- 状态:${order.status}`,
`- 商品:${order.productName}`,
`- 实付:¥${Number(order.payAmount).toFixed(2)}`,
`- 用户:${order.user?.nickname || '—'} / ${order.user?.userNo || '—'}`,
order.delivery
? `- 配送:${order.delivery.provider} ${order.delivery.trackingNo || '—'}`
: '- 配送:暂无',
].join('\n');
}
private async queryUserByNo(bot: WecomBotRuntimeConfig, userNo: string) {
this.requirePerm(bot, 'user.read');
if (!userNo) return '请提供用户号';
const user = await this.prisma.user.findFirst({
where: { userNo: { contains: userNo } },
select: {
userNo: true,
phone: true,
nickname: true,
status: true,
_count: { select: { orders: true } },
},
});
if (!user) return `未找到用户号:${userNo}`;
return [
'**用户摘要**',
`- 用户号:${user.userNo}`,
`- 昵称:${user.nickname || '—'}`,
`- 手机:${maskPhone(user.phone || '')}`,
`- 订单数:${user._count.orders}`,
].join('\n');
}
private async queryStore(bot: WecomBotRuntimeConfig, keyword: string) {
this.requirePerm(bot, 'store.read');
if (!keyword) return '请提供门店关键词';
const rows = await this.prisma.store.findMany({
where: { name: { contains: keyword } },
take: 5,
orderBy: { updatedAt: 'desc' },
select: { id: true, name: true, status: true, cityName: true, address: true },
});
if (!rows.length) return `未找到门店:${keyword}`;
return rows
.map((s, i) => `${i + 1}. **${s.name}** · ${s.status} · ${s.cityName || '—'}`)
.join('\n');
}
private async queryRedeem(bot: WecomBotRuntimeConfig, q: string) {
this.requirePerm(bot, 'redeem.read');
if (!q) return '请提供核销单号或门店名';
const byNo = await this.prisma.redeemRecord.findMany({
where: { redeemNo: { contains: q } },
take: 5,
orderBy: { createdAt: 'desc' },
include: { store: { select: { name: true } } },
});
const rows =
byNo.length > 0
? byNo
: await this.prisma.redeemRecord.findMany({
where: { store: { name: { contains: q } } },
take: 5,
orderBy: { createdAt: 'desc' },
include: { store: { select: { name: true } } },
});
if (!rows.length) return `未找到核销记录:${q}`;
return rows
.map(
(r, i) =>
`${i + 1}. **${r.redeemNo}** · 门店 ${r.store?.name || '—'} · ¥${Number(r.amount).toFixed(2)}`,
)
.join('\n');
}
private async queryAfterSaleTicket(bot: WecomBotRuntimeConfig, ticketNo: string) {
this.requirePerm(bot, 'ticket.read');
if (!ticketNo) return '请提供售后工单号';
const ticket = await this.prisma.commonTicket.findFirst({
where: { ticketNo: { contains: ticketNo } },
});
if (!ticket) return `未找到售后工单:${ticketNo}`;
return [
`**${ticket.ticketNo}**`,
`- 类型:${ticket.ticketType}`,
`- 状态:${ticket.status}`,
`- 备注:${ticket.remark || '—'}`,
].join('\n');
}
private async queryStoreBills(bot: WecomBotRuntimeConfig, q?: string) {
this.requirePerm(bot, 'finance.store_bill.read');
let storeId: string | undefined;
if (q?.trim()) {
const store = await this.prisma.store.findFirst({
where: { name: { contains: q.trim() } },
select: { id: true },
});
if (store) storeId = store.id.toString();
}
const res = await this.settlement.listAdminStoreBills({ page: 1, pageSize: 8, storeId });
const items = (res as { items: Array<{ billNo: string; status: string; store?: { name: string }; payoutAmount: unknown }> }).items;
if (!items.length) return '暂无门店账单';
return items
.map(
(b, i) =>
`${i + 1}. **${b.billNo}** · ${b.store?.name || '—'} · ${b.status} · ¥${Number(b.payoutAmount).toFixed(2)}`,
)
.join('\n');
}
private async queryPartnerBills(bot: WecomBotRuntimeConfig) {
this.requirePerm(bot, 'finance.partner_bill.read');
const res = await this.settlement.listAdminPartnerBills({ page: 1, pageSize: 8 });
const items = (res as { items: Array<{ billNo: string; status: string; totalAmount: unknown; partner?: { companyName: string } }> }).items;
if (!items.length) return '暂无合伙人账单';
return items
.map(
(b, i) =>
`${i + 1}. **${b.billNo}** · ${b.partner?.companyName || '—'} · ¥${Number(b.totalAmount).toFixed(2)}`,
)
.join('\n');
}
private async queryWineryBills(bot: WecomBotRuntimeConfig) {
this.requirePerm(bot, 'finance.winery_bill.read');
const res = await this.settlement.listAdminWineryBills({ page: 1, pageSize: 8 });
const items = (res as { items: Array<{ billNo: string; status: string; wineryAmount: unknown }> }).items;
if (!items.length) return '暂无酒厂账单';
return items
.map((b, i) => `${i + 1}. **${b.billNo}** · ${b.status} · ¥${Number(b.wineryAmount).toFixed(2)}`)
.join('\n');
}
private async queryLogisticsBills(bot: WecomBotRuntimeConfig) {
this.requirePerm(bot, 'finance.logistics_bill.read');
const res = await this.settlement.listAdminLogisticsBills({ page: 1, pageSize: 8 });
const items = (res as { items: Array<{ billNo: string; status: string; logisticsAmount: unknown; providerName?: string }> }).items;
if (!items.length) return '暂无物流账单';
return items
.map(
(b, i) =>
`${i + 1}. **${b.billNo}** · ${b.providerName || '—'} · ${b.status} · ¥${Number(b.logisticsAmount).toFixed(2)}`,
)
.join('\n');
}
private async queryStorePayouts(bot: WecomBotRuntimeConfig) {
this.requirePerm(bot, 'finance.payout.read');
const res = await this.settlement.listAdminStorePayouts({ page: 1, pageSize: 8 });
const items = (res as { items: Array<{ id: bigint | string; status: string; payoutAmount: unknown; store?: { name: string } }> }).items;
if (!items.length) return '暂无门店打款记录';
return items
.map(
(p, i) =>
`${i + 1}. **#${p.id}** · ${p.store?.name || '—'} · ¥${Number(p.payoutAmount).toFixed(2)} · ${p.status}`,
)
.join('\n');
}
private async queryStoreWithdrawals(bot: WecomBotRuntimeConfig) {
this.requirePerm(bot, 'finance.withdrawal.read');
const res = await this.settlement.listAdminStoreWithdrawals({ page: 1, pageSize: 8 });
const items = (res as { items: Array<{ withdrawNo: string; status: string; amount: unknown; store?: { name: string } }> }).items;
if (!items.length) return '暂无门店提现记录';
return items
.map(
(w, i) =>
`${i + 1}. **${w.withdrawNo}** · ${w.store?.name || '—'} · ¥${Number(w.amount).toFixed(2)} · ${w.status}`,
)
.join('\n');
}
private async queryDevPlanTasks(bot: WecomBotRuntimeConfig, status?: string) {
this.requirePerm(bot, 'dev_plan.task.read');
const res = await this.devPlan.listTasks({
page: 1,
pageSize: 10,
status: status?.trim() || undefined,
});
const items = (res as { items: Array<{ taskNo: string; type: string; status: string; content: string }> }).items;
if (!items.length) return '暂无开发任务';
return items
.map((t, i) => `${i + 1}. **${t.taskNo}** [${t.type}/${t.status}] ${t.content.slice(0, 80)}`)
.join('\n');
}
private async queryDevPlanVersions(bot: WecomBotRuntimeConfig, versionNo?: string) {
this.requirePerm(bot, 'dev_plan.version.read');
const res = await this.devPlan.listVersions({
page: 1,
pageSize: 10,
});
let items = (res as { items: Array<{ versionNo: string; status: string; durationMinutes: number | null }> }).items;
if (versionNo?.trim()) {
items = items.filter((v) => v.versionNo.includes(versionNo.trim()));
}
if (!items.length) return '暂无开发版本';
return items
.map(
(v, i) =>
`${i + 1}. **${v.versionNo}** · ${v.status}${v.durationMinutes != null ? ` · ${v.durationMinutes}min` : ''}`,
)
.join('\n');
}
// ─── 开发计划 · 写操作 ───
private async createDevPlanTask(bot: WecomBotRuntimeConfig, wecomUserId: string, input: string) {
this.requirePerm(bot, 'dev_plan.task.create');
const trimmed = input.trim();
if (!trimmed) return '请提供任务描述,格式:`新建任务 BUG|需求|优化 <描述>`';
const TYPE_MAP: Record<string, string> = { BUG: 'BUG', bug: 'BUG', 缺陷: 'BUG', REQUIREMENT: 'REQUIREMENT', requirement: 'REQUIREMENT', 需求: 'REQUIREMENT', OPTIMIZATION: 'OPTIMIZATION', optimization: 'OPTIMIZATION', 优化: 'OPTIMIZATION' };
const firstWord = trimmed.split(/\s+/)[0];
const type = TYPE_MAP[firstWord];
if (!type) return `未知任务类型「${firstWord}」。可用:BUG / 需求 / 优化\n例如:\`新建任务 BUG 登录页报错\``;
const content = trimmed.slice(firstWord.length).trim();
if (!content) return '请提供任务描述内容';
const creator = await this.resolveCreator(wecomUserId);
const task = await this.devPlan.createTask({ content, type }, creator.id);
return `✅ 已创建任务 **${(task as { taskNo: string }).taskNo}** [${type}] ${content.slice(0, 60)}`;
}
private async updateDevPlanTaskStatus(bot: WecomBotRuntimeConfig, wecomUserId: string, input: string) {
this.requirePerm(bot, 'dev_plan.task.update_status');
const trimmed = input.trim();
if (!trimmed) return '请提供任务编号和状态,格式:`修改任务 STxxx TODO|DEVELOPED|RELEASED`';
const parts = trimmed.split(/\s+/).filter(Boolean);
if (parts.length < 2) return '格式:`修改任务 <任务编号> <状态>`\n状态:TODO(待开发)· DEVELOPED(已开发)· RELEASED(已发布)';
const [taskNo, rawStatus] = parts;
const STATUS_ALIAS: Record<string, string> = {
TODO: 'TODO', todo: 'TODO', 待开发: 'TODO',
DEVELOPED: 'DEVELOPED', developed: 'DEVELOPED', 已开发: 'DEVELOPED',
RELEASED: 'RELEASED', released: 'RELEASED', 已发布: 'RELEASED',
};
const status = STATUS_ALIAS[rawStatus];
if (!status) return `未知状态「${rawStatus}」。可用:TODO(待开发)/ DEVELOPED(已开发)/ RELEASED(已发布)`;
const row = await this.prisma.devPlanTask.findFirst({ where: { taskNo } });
if (!row) return `未找到任务「${taskNo}」`;
const updated = await this.devPlan.updateTask(row.id, { status });
return `✅ 任务 **${taskNo}** → ${status}`;
}
private async createDevPlanVersion(bot: WecomBotRuntimeConfig, wecomUserId: string, input: string) {
this.requirePerm(bot, 'dev_plan.version.create');
const versionNo = input.trim();
if (!versionNo) return '请提供版本号,格式:`新建版本 v3.6.0`';
const version = await this.devPlan.createVersion({ versionNo });
return `✅ 已创建版本 **${(version as { versionNo: string }).versionNo}**`;
}
private async updateDevPlanVersionStatus(bot: WecomBotRuntimeConfig, wecomUserId: string, input: string) {
this.requirePerm(bot, 'dev_plan.version.update_status');
const trimmed = input.trim();
if (!trimmed) return '请提供版本号和状态,格式:`修改版本 v3.6.0 <状态>`';
const parts = trimmed.split(/\s+/).filter(Boolean);
if (parts.length < 2) return '格式:`修改版本 <版本号> <状态>`\n状态:PENDING · IN_PROGRESS · TESTING · RELEASED';
const [versionNo, rawStatus] = parts;
const STATUS_ALIAS: Record<string, string> = {
PENDING: 'PENDING', pending: 'PENDING', 计划中: 'PENDING',
IN_PROGRESS: 'IN_PROGRESS', in_progress: 'IN_PROGRESS', 开发中: 'IN_PROGRESS',
TESTING: 'TESTING', testing: 'TESTING', 测试中: 'TESTING',
RELEASED: 'RELEASED', released: 'RELEASED', 已发布: 'RELEASED',
};
const status = STATUS_ALIAS[rawStatus];
if (!status) return `未知状态「${rawStatus}」。可用:PENDING / IN_PROGRESS / TESTING / RELEASED`;
const row = await this.prisma.devPlanVersion.findUnique({ where: { versionNo } });
if (!row) return `未找到版本「${versionNo}」`;
const updated = await this.devPlan.updateVersion(row.id, { status });
return `✅ 版本 **${(updated as { versionNo: string }).versionNo}** → ${(updated as { status: string }).status}`;
}
private async linkTasksToVersion(bot: WecomBotRuntimeConfig, wecomUserId: string, input: string) {
this.requirePerm(bot, 'dev_plan.version.link_tasks');
const trimmed = input.trim();
if (!trimmed) return '请提供版本号和任务编号,格式:`关联任务 v3.6.0 DPT-001,DPT-002`';
const parts = trimmed.split(/\s+/).filter(Boolean);
if (parts.length < 2) return '格式:`关联任务 <版本号> <任务编号1,任务编号2>`';
const versionNo = parts[0];
const taskNos = parts[1].split(/[,]+/).map((s) => s.trim()).filter(Boolean);
if (!taskNos.length) return '请提供至少 1 个任务编号';
const version = await this.prisma.devPlanVersion.findUnique({ where: { versionNo } });
if (!version) return `未找到版本「${versionNo}」`;
const tasks = await this.prisma.devPlanTask.findMany({
where: { taskNo: { in: taskNos } },
select: { id: true, taskNo: true },
});
if (!tasks.length) return `未找到任务「${taskNos.join(', ')}」`;
const foundNos = tasks.map((t) => t.taskNo);
const missing = taskNos.filter((n) => !foundNos.includes(n));
const taskIds = tasks.map((t) => String(t.id));
await this.devPlan.addVersionTasksApi(version.id, taskIds);
const summary = `已关联 ${foundNos.length} 个任务到版本 **${versionNo}**${foundNos.join(', ')}`;
if (missing.length) return `⚠️ ${summary}\n未找到:${missing.join(', ')}`;
return `✅ ${summary}`;
}
private async resolveCreator(wecomUserId: string) {
const admin = await this.prisma.hqAccount.findFirst({
where: { status: 'ACTIVE' },
orderBy: [{ id: 'asc' }],
select: { id: true, name: true },
});
if (!admin) throw new Error('系统未配置 HQ 账号');
return { id: admin.id, name: `${admin.name || 'HQ'}(企微:${wecomUserId})` };
}
}
function maskPhone(phone: string): string {
if (!phone || phone.length < 7) return phone || '—';
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
}
type ReviewCommand =
| { decision: 'APPROVE'; ticketNo: string }
| { decision: 'REJECT'; ticketNo: string; reason: string };
/** 解析审批指令:支持「审批 驳回 STxxx 理由:…」等自然说法 */
function parseReviewCommand(content: string): ReviewCommand | null {
const text = content.trim().replace(/\s+/g, ' ');
const approve = text.match(/^(?:审批\s*)?(?:通过|approve)\s+(\S+)/i);
if (approve) {
return { decision: 'APPROVE', ticketNo: stripTicketNo(approve[1]) };
}
const reject = text.match(/^(?:审批\s*)?(?:驳回|reject)\s+(\S+)(?:\s+(.*))?$/i);
if (reject) {
return {
decision: 'REJECT',
ticketNo: stripTicketNo(reject[1]),
reason: normalizeRejectReason(reject[2] ?? ''),
};
}
return null;
}
function stripTicketNo(raw: string): string {
return raw.replace(/^#/, '').trim();
}
function normalizeRejectReason(raw: string): string {
return raw.trim().replace(/^理由[:]?\s*/u, '').trim();
}
function formatHandbook(entries: ReturnType<typeof searchHandbook>): string {
return entries.map((e) => `**${e.title}**\n${e.body}`).join('\n\n---\n\n');
}
type ClientErrorLogRow = {
clientApp: string | null;
pagePath: string | null;
extraJson: unknown;
createdAt: Date;
};
function formatClientErrorLog(row: ClientErrorLogRow, index: number): string {
const extra =
row.extraJson && typeof row.extraJson === 'object'
? (row.extraJson as Record<string, unknown>)
: {};
const message = typeof extra.message === 'string' ? extra.message.slice(0, 160) : '—';
return [
`**${index}.**`,
`- 端:${row.clientApp || '—'}`,
row.pagePath ? `- 页面:${row.pagePath}` : null,
`- 消息:${message}`,
`- 时间:${row.createdAt.toISOString().slice(0, 19).replace('T', ' ')}`,
]
.filter(Boolean)
.join('\n');
}