微信企业机器人创建

This commit is contained in:
2026-07-26 08:16:56 +08:00
parent f053908acb
commit 34532dc9bf
26 changed files with 1963 additions and 4 deletions
@@ -0,0 +1,459 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import {
SUPPORT_TICKET_STATUS_LABELS,
SUPPORT_TICKET_TYPE_LABELS,
TICKET_TYPE_LABELS,
type AfterSaleTicketType,
type SupportTicketTypeDto,
} 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 { SMS_PROVIDER } from '../integrations.constants';
import type { ISmsProvider } from '../sms/sms.interface';
import type { WecomBotRuntimeConfig } from './wecom-bot.types';
import { wecomBotHasPermission } from './wecom-bot.types';
import { WecomBotSessionService } from './wecom-bot-session.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 WecomBotActionsService {
private readonly logger = new Logger(WecomBotActionsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly ticketService: TicketService,
private readonly supportTicketService: SupportTicketService,
private readonly session: WecomBotSessionService,
@Inject(SMS_PROVIDER) private readonly sms: ISmsProvider,
) {}
buildHelp(bot: WecomBotRuntimeConfig): string {
const lines = [`**${bot.name}**`, '', '通用:`帮助` · `状态`', ''];
if (wecomBotHasPermission(bot, 'ticket.create')) {
lines.push(
'**售后工单**',
'`工单 <订单号> <类型> [备注]`',
'类型:仅退款 / 破损补发 / 破损退货 / 退货退款',
'例:`工单 DK123 仅退款 用户要求退款`',
'',
);
}
if (wecomBotHasPermission(bot, 'user.view_sms')) {
lines.push(
'**查用户(需短信验证)**',
'`查用户 <手机号>` → 向该手机发验证码',
'`验证 <验证码>` → 验证通过后展示用户摘要',
'',
);
}
if (wecomBotHasPermission(bot, 'delivery.view')) {
lines.push('**查快递**', '`快递 <订单号|运单号>`', '');
}
if (wecomBotHasPermission(bot, 'support_ticket.create')) {
lines.push(
'**技术支持提单**',
'`提单 <BUG|建议|其他> <标题> [| 详情]`',
'例:`提单 BUG 支付回调偶发失败 | 订单号xxx`',
'',
);
}
if (wecomBotHasPermission(bot, 'support_ticket.progress')) {
lines.push('**开发进度**', '`进度` 最近工单 · `进度 <工单号>` 详情', '');
}
if (wecomBotHasPermission(bot, 'handbook.query')) {
lines.push(
'**使用手册**',
'`手册` 目录 · `手册 <关键词>` 如:开城、核销、订单、财务',
'',
);
}
lines.push(`当前权限:${bot.permissions.join(', ') || '无'}`);
return lines.join('\n');
}
async handleCommand(
bot: WecomBotRuntimeConfig,
wecomUserId: string,
text: string,
): Promise<string> {
const content = text.trim();
if (!content) return this.buildHelp(bot);
// 查用户 / 验证
if (/^(查用户|用户)\s+/i.test(content)) {
this.requirePerm(bot, 'user.view_sms');
const phone = content.replace(/^(查用户|用户)\s+/i, '').trim();
return this.startUserView(bot, wecomUserId, phone);
}
if (/^(验证|verify)\s+/i.test(content)) {
this.requirePerm(bot, 'user.view_sms');
const code = content.replace(/^(验证|verify)\s+/i, '').trim();
return this.verifyUserView(bot, wecomUserId, code);
}
// 快递
if (/^(快递|配送|物流)\s+/i.test(content)) {
this.requirePerm(bot, 'delivery.view');
const q = content.replace(/^(快递|配送|物流)\s+/i, '').trim();
return this.lookupDelivery(q);
}
// 售后工单
if (/^(工单|创建工单)\s+/i.test(content)) {
this.requirePerm(bot, 'ticket.create');
return this.createAfterSaleTicket(content.replace(/^(工单|创建工单)\s+/i, '').trim(), wecomUserId);
}
// 技术支持提单
if (/^(提单|技术支持)\s+/i.test(content)) {
this.requirePerm(bot, 'support_ticket.create');
return this.createSupportTicket(content.replace(/^(提单|技术支持)\s+/i, '').trim(), wecomUserId);
}
// 进度
if (/^(进度|开发进度)/i.test(content)) {
this.requirePerm(bot, 'support_ticket.progress');
const rest = content.replace(/^(进度|开发进度)\s*/i, '').trim();
return this.supportProgress(rest);
}
// 手册
if (/^(手册|帮助文档|文档)/i.test(content)) {
this.requirePerm(bot, 'handbook.query');
const q = content.replace(/^(手册|帮助文档|文档)\s*/i, '').trim();
return this.queryHandbook(q);
}
// 自然语言手册(仅团队助手有 handbook 权限时)
if (wecomBotHasPermission(bot, 'handbook.query') && content.length >= 2) {
const hit = searchHandbook(content, 1);
if (hit.length) {
return formatHandbook(hit);
}
}
return `未识别指令。\n\n${this.buildHelp(bot)}`;
}
private requirePerm(bot: WecomBotRuntimeConfig, perm: Parameters<typeof wecomBotHasPermission>[1]) {
if (!wecomBotHasPermission(bot, perm)) {
throw new Error(`当前机器人无权限:${perm}`);
}
}
private async startUserView(bot: WecomBotRuntimeConfig, wecomUserId: string, phone: string) {
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`',
'验证码约 3 分钟有效。',
].join('\n');
}
private async verifyUserView(bot: WecomBotRuntimeConfig, wecomUserId: string, code: string) {
const sess = await this.session.get(bot.key, wecomUserId);
if (!sess?.phone) return '请先发送:`查用户 <手机号>`';
if (!code) return '请提供验证码,例如:`验证 123456`';
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.phoneVerifiedAt ? '是' : '否'}`,
`- 状态:${user.status}`,
`- 订单数:${user._count.orders}`,
`- 权益余额:¥${Number(coupons._sum.balance ?? 0).toFixed(2)}`,
`- 注册:${user.createdAt.toISOString().slice(0, 19).replace('T', ' ')}`,
].join('\n');
}
private async lookupDelivery(q: string) {
if (!q) return '请提供订单号或运单号,例如:`快递 DK123`';
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,
deliveryType: true,
productName: true,
},
},
},
});
const byTrack =
byOrder.length > 0
? []
: 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,
deliveryType: true,
productName: true,
},
},
},
});
const rows = byOrder.length ? byOrder : byTrack;
if (!rows.length) return `未找到与「${q}」匹配的配送单`;
return rows
.map((d, i) => {
const o = d.order;
return [
`**配送 ${i + 1}**`,
`- 订单:${o.orderNo}${o.status}`,
`- 商品:${o.productName}`,
`- 类型:${o.deliveryType}`,
`- 承运:${d.provider}`,
`- 运单:${d.trackingNo || '—'}`,
`- 第三方单号:${d.providerOrderNo || '—'}`,
`- 收货:${o.receiverName} ${maskPhone(o.receiverPhone || '')}`,
].join('\n');
})
.join('\n\n');
}
private async createAfterSaleTicket(rest: string, wecomUserId: string) {
// 订单号 类型 备注
const parts = rest.split(/\s+/).filter(Boolean);
if (parts.length < 2) {
return '格式:`工单 <订单号> <类型> [备注]`\n类型:仅退款 / 破损补发 / 破损退货 / 退货退款';
}
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}`;
if (order.status === 'PENDING_PAY' || order.status === 'CANCELLED') {
return '当前订单状态不可创建售后工单';
}
const pending = await this.prisma.commonTicket.findFirst({
where: {
ticketType: ticketType as never,
refType: 'ORDER',
refId: order.id,
status: { in: ['PENDING', 'OPEN'] },
},
});
if (pending) return `该类型工单已在处理中:${pending.ticketNo}`;
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}`,
`- 状态:${ticket.status}`,
].join('\n');
}
private async createSupportTicket(rest: string, wecomUserId: string) {
const m = rest.match(/^(\S+)\s+(.+)$/);
if (!m) return '格式:`提单 <BUG|建议|其他> <标题> [| 详情]`';
const typeRaw = m[1];
const restTitle = m[2];
const ticketType =
SUPPORT_TYPE_ALIASES[typeRaw] || SUPPORT_TYPE_ALIASES[typeRaw.toLowerCase()];
if (!ticketType) return '类型请使用:BUG / 建议 / 其他';
const [titlePart, ...contentParts] = restTitle.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]}`,
`- 状态:${SUPPORT_TICKET_STATUS_LABELS[ticket.status as keyof typeof SUPPORT_TICKET_STATUS_LABELS] || ticket.status}`,
`- 标题:${title}`,
'等待最高管理员评审。',
].join('\n');
}
private async supportProgress(ticketNo: string) {
if (ticketNo) {
const ticket = await this.prisma.commonSupportTicket.findFirst({
where: { ticketNo: { contains: ticketNo } },
});
if (!ticket) return `未找到工单:${ticketNo}`;
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}`,
`- 评审人:${ticket.reviewerName || '—'}`,
ticket.rejectReason ? `- 驳回原因:${ticket.rejectReason}` : '',
`- 更新:${ticket.updatedAt.toISOString().slice(0, 19).replace('T', ' ')}`,
]
.filter(Boolean)
.join('\n');
}
const items = await this.prisma.commonSupportTicket.findMany({
orderBy: { updatedAt: 'desc' },
take: 8,
});
if (!items.length) return '暂无技术支持工单';
const byStatus = await this.prisma.commonSupportTicket.groupBy({
by: ['status'],
_count: { _all: true },
});
const summary = byStatus
.map(
(s) =>
`${SUPPORT_TICKET_STATUS_LABELS[s.status as keyof typeof SUPPORT_TICKET_STATUS_LABELS] || s.status}:${s._count._all}`,
)
.join(' · ');
const list = items
.map(
(t) =>
`- ${t.ticketNo} [${SUPPORT_TICKET_STATUS_LABELS[t.status as keyof typeof SUPPORT_TICKET_STATUS_LABELS] || t.status}] ${t.title}`,
)
.join('\n');
return [`**开发进度概览**`, summary, '', '**最近工单**', list, '', '详情:`进度 <工单号>`'].join(
'\n',
);
}
private queryHandbook(q: string) {
if (!q) {
const catalog = searchHandbook('', 20)
.map((e) => `- ${e.title}(关键词:${e.keywords.slice(0, 4).join('、')}`)
.join('\n');
return `**手册目录**\n${catalog}\n\n查询:\`手册 <关键词>\``;
}
const hits = searchHandbook(q, 3);
if (!hits.length) return `未找到与「${q}」相关的手册内容。可试:开城、核销、订单、财务、工单`;
return formatHandbook(hits);
}
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) {
this.logger.warn('no hq account for wecom support ticket creator');
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)}`;
}
function formatHandbook(entries: ReturnType<typeof searchHandbook>): string {
return entries.map((e) => `**${e.title}**\n${e.body}`).join('\n\n---\n\n');
}