1400 lines
55 KiB
TypeScript
1400 lines
55 KiB
TypeScript
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');
|
||
}
|