where('companyId', $companyId) ->where('name', $typeName) ->where('isDel', 0) ->find(); if ($exist) { return (int)$exist['id']; } return (int)Db::name('ai_knowledge_base_type')->insertGetId([ 'type' => 1, 'name' => $typeName, 'description' => '触客宝快捷语导入生成', 'label' => json_encode([], 256), 'prompt' => '', 'status' => 1, 'companyId' => $companyId, 'userId' => $userId, 'createTime' => time(), 'updateTime' => time(), 'isDel' => 0, ]); } private function writeKbMarkdown(int $companyId, string $kbName, string $content): string { $dir = root_path() . 'public/uploads/knowledge/' . $companyId . '/'; if (!is_dir($dir)) { @mkdir($dir, 0777, true); } $safe = preg_replace('/[^a-zA-Z0-9_\x{4e00}-\x{9fa5}-]+/u', '_', $kbName); $filename = date('Ymd_His') . '_' . $safe . '.md'; $path = $dir . $filename; file_put_contents($path, $content); return '/uploads/knowledge/' . $companyId . '/' . $filename; } private function ensureTable() { Db::execute("CREATE TABLE IF NOT EXISTS `ck_kf_reply_pin` ( `id` int(11) NOT NULL AUTO_INCREMENT, `companyId` int(11) NOT NULL DEFAULT 0, `userId` int(11) NOT NULL DEFAULT 0, `type` varchar(16) NOT NULL DEFAULT 'reply', `targetId` int(11) NOT NULL DEFAULT 0, `pinned` tinyint(1) NOT NULL DEFAULT 1, `updateTime` int(11) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `uniq` (`userId`,`type`,`targetId`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); } /** * 置顶 / 取消置顶(目录或条目) * POST /v1/kefu/reply/pin body: { id, type: group|reply, pinned } */ public function pin() { $this->ensureTable(); $companyId = $this->getUserInfo('companyId'); $userId = $this->getUserInfo('id'); $id = (int)$this->request->param('id', 0); $type = (string)$this->request->param('type', 'reply'); $pinned = (bool)$this->request->param('pinned', true); if (!$id || !in_array($type, ['group', 'reply'])) { return ResponseHelper::error('参数错误', 400); } $exist = Db::name('kf_reply_pin') ->where(['userId' => $userId, 'type' => $type, 'targetId' => $id]) ->find(); if ($pinned) { if ($exist) { Db::name('kf_reply_pin')->where('id', $exist['id']) ->update(['pinned' => 1, 'updateTime' => time()]); } else { Db::name('kf_reply_pin')->insert([ 'companyId' => $companyId, 'userId' => $userId, 'type' => $type, 'targetId' => $id, 'pinned' => 1, 'updateTime' => time(), ]); } } elseif ($exist) { Db::name('kf_reply_pin')->where('id', $exist['id'])->delete(); } return ResponseHelper::success(['id' => $id, 'type' => $type, 'pinned' => $pinned], '已更新'); } /** * 当前客服的置顶列表(REQ-TKB-93:进页同步服务端,避免只信 localStorage) * GET /v1/kefu/reply/pins * @return \think\response\Json */ public function pins() { $this->ensureTable(); $userId = (int)$this->getUserInfo('id'); $rows = Db::name('kf_reply_pin') ->where('userId', $userId) ->where('pinned', 1) ->field('type,targetId') ->select(); $list = []; $map = []; foreach ($rows ?: [] as $r) { $type = (string)$r['type']; $tid = (int)$r['targetId']; $key = $type . '-' . $tid; $list[] = ['type' => $type, 'targetId' => $tid, 'key' => $key]; $map[$key] = true; } return ResponseHelper::success(['list' => $list, 'map' => $map], 'ok'); } /** * 个人快捷语 → 公司快捷语(REQ-TKB-93) * POST /v1/kefu/quick-reply/promote-to-company * body: { ids:[reply...], targetGroupId?, deleteSource? } * @return \think\response\Json */ public function promoteToCompany() { $companyId = (int)$this->getUserInfo('companyId'); $userId = (int)$this->getUserInfo('id'); $idsParam = $this->request->param('ids', []); if (is_string($idsParam)) { $idsParam = array_filter(array_map('trim', explode(',', $idsParam))); } $ids = array_values(array_unique(array_map('intval', (array)$idsParam))); $ids = array_filter($ids, function ($v) { return $v > 0; }); if (empty($ids)) { return ResponseHelper::error('请选择要转移的快捷语', 400); } $targetGroupId = (int)$this->request->param('targetGroupId', 0); $deleteSource = (bool)$this->request->param('deleteSource', false); return $this->doPromoteToCompany($companyId, $userId, $ids, $targetGroupId, $deleteSource); } /** * @param int[] $ids * @return \think\response\Json */ private function doPromoteToCompany(int $companyId, int $userId, array $ids, int $targetGroupId, bool $deleteSource) { if ($targetGroupId > 0) { $tg = Db::name('kf_reply_group') ->where('id', $targetGroupId) ->where('companyId', $companyId) ->where('replyType', 2) ->where('isDel', 0) ->find(); if (!$tg) { return ResponseHelper::error('目标公司分组不存在', 404); } } else { $targetGroupId = $this->ensureCompanyInboxGroup($companyId, $userId); } $now = time(); $okCount = 0; $skipped = 0; foreach ($ids as $rid) { $reply = Db::name('kf_reply')->where('id', $rid)->where('isDel', 0)->find(); if (!$reply) { $skipped++; continue; } // 仅允许转移本人个人快捷语(其所在分组 replyType=1 且 userId 一致) $srcGroup = Db::name('kf_reply_group') ->where('id', (int)$reply['groupId']) ->where('isDel', 0) ->find(); $isPersonal = $srcGroup && (int)$srcGroup['replyType'] === 1 && (int)$srcGroup['userId'] === $userId && (int)$srcGroup['companyId'] === $companyId; if (!$isPersonal) { $skipped++; continue; } // 仅用 ck_kf_reply 实际存在的列(兼容生产库:无 tenantId/accountId) $insertRow = [ 'groupId' => $targetGroupId, 'title' => $reply['title'], 'msgType' => (int)$reply['msgType'], 'content' => $reply['content'], 'sortIndex' => $reply['sortIndex'] ?? 50, 'createTime' => $now, 'lastUpdateTime' => $now, 'userId' => $userId, 'isDel' => 0, ]; Db::name('kf_reply')->insert($insertRow); if ($deleteSource) { Db::name('kf_reply')->where('id', $rid)->update([ 'isDel' => 1, 'delTime' => $now, ]); } $okCount++; } if ($okCount === 0) { return ResponseHelper::error('没有可转移的个人快捷语(仅支持转移本人个人话术)', 400); } return ResponseHelper::success([ 'promoted' => $okCount, 'skipped' => $skipped, 'targetGroupId' => $targetGroupId, 'deleteSource' => $deleteSource, ], "已转移 {$okCount} 条到公司快捷语"); } /** * 整组快捷语互拷(REQ-TKB-120) * POST /v1/kefu/quick-reply/copy-group * body: { groupId, direction: to-company|to-personal, targetGroupId?, deleteSource? } */ public function copyGroup() { $companyId = (int)$this->getUserInfo('companyId'); $userId = (int)$this->getUserInfo('id'); $groupId = (int)$this->request->param('groupId', 0); $direction = (string)$this->request->param('direction', ''); $targetGroupId = (int)$this->request->param('targetGroupId', 0); $deleteSource = (bool)$this->request->param('deleteSource', false); if ($groupId <= 0 || !in_array($direction, ['to-company', 'to-personal'], true)) { return ResponseHelper::error('参数错误', 400); } $srcGroup = Db::name('kf_reply_group') ->where('id', $groupId) ->where('companyId', $companyId) ->where('isDel', 0) ->find(); if (!$srcGroup) { return ResponseHelper::error('源分组不存在', 404); } $replies = Db::name('kf_reply') ->where('groupId', $groupId) ->where('isDel', 0) ->order('sortIndex asc,id asc') ->select(); if (empty($replies)) { return ResponseHelper::error('该分组下没有可复制的快捷语', 400); } $replyIds = array_map(function ($r) { return (int)$r['id']; }, $replies ?: []); if ($direction === 'to-company') { if ((int)$srcGroup['replyType'] !== 1 || (int)$srcGroup['userId'] !== $userId) { return ResponseHelper::error('仅支持将本人个人分组复制到公司', 400); } if ($targetGroupId <= 0) { $targetGroupId = $this->ensureCompanyMirrorGroup($companyId, $userId, (string)$srcGroup['groupName']); } $resp = $this->doPromoteToCompany($companyId, $userId, $replyIds, $targetGroupId, $deleteSource); if ($deleteSource) { $this->softDeleteReplyGroup($groupId, time()); } return $resp; } // to-personal:公司 → 个人 if ((int)$srcGroup['replyType'] !== 2) { return ResponseHelper::error('仅支持将公司分组复制到个人', 400); } if ($targetGroupId <= 0) { $targetGroupId = $this->ensurePersonalMirrorGroup($companyId, $userId, (string)$srcGroup['groupName']); } else { $tg = Db::name('kf_reply_group') ->where('id', $targetGroupId) ->where('companyId', $companyId) ->where('replyType', 1) ->where('userId', $userId) ->where('isDel', 0) ->find(); if (!$tg) { return ResponseHelper::error('目标个人分组不存在', 404); } } $now = time(); $okCount = 0; foreach ($replies as $reply) { Db::name('kf_reply')->insert([ 'groupId' => $targetGroupId, 'title' => $reply['title'], 'msgType' => (int)$reply['msgType'], 'content' => $reply['content'], 'sortIndex' => $reply['sortIndex'] ?? 50, 'createTime' => $now, 'lastUpdateTime' => $now, 'userId' => $userId, 'isDel' => 0, ]); $okCount++; if ($deleteSource) { Db::name('kf_reply')->where('id', (int)$reply['id'])->update([ 'isDel' => 1, 'delTime' => $now, ]); } } if ($deleteSource) { $this->softDeleteReplyGroup($groupId, $now); } return ResponseHelper::success([ 'copied' => $okCount, 'targetGroupId' => $targetGroupId, 'deleteSource' => $deleteSource, 'direction' => $direction, ], "已复制 {$okCount} 条到个人快捷语"); } private function softDeleteReplyGroup(int $groupId, int $now): void { Db::name('kf_reply_group')->where('id', $groupId)->update([ 'isDel' => 1, 'delTime' => $now, ]); Db::name('kf_reply')->where('groupId', $groupId)->update([ 'isDel' => 1, 'delTime' => $now, ]); } /** * AI 整理分组话术(五步漏斗 + 补充归类,REQ-TKB-120) * POST /v1/kefu/quick-reply/ai-organize-group body: { groupId } */ public function aiOrganizeGroup() { $companyId = (int)$this->getUserInfo('companyId'); $userId = (int)$this->getUserInfo('id'); $groupId = (int)$this->request->param('groupId', 0); if ($groupId <= 0) { return ResponseHelper::error('请选择分组', 400); } $group = Db::name('kf_reply_group') ->where('id', $groupId) ->where('companyId', $companyId) ->where('isDel', 0) ->find(); if (!$group) { return ResponseHelper::error('分组不存在', 404); } if ((int)$group['replyType'] === 1 && (int)$group['userId'] !== $userId) { return ResponseHelper::error('无权整理该个人分组', 403); } $replies = Db::name('kf_reply') ->where('groupId', $groupId) ->where('isDel', 0) ->order('sortIndex asc,id asc') ->select(); if (empty($replies)) { return ResponseHelper::error('分组内无话术可整理', 400); } $payload = []; foreach ($replies as $r) { $payload[] = [ 'id' => (int)$r['id'], 'title' => (string)$r['title'], 'msgType' => (int)$r['msgType'], 'content' => (string)$r['content'], ]; } $sys = "你是私域销售话术整理专家。参考苹果 Apple Store 在线客服/iMessage 导购话术风格:" . "短句、利益清晰、结尾用轻问句促回复。" . "\n将用户提供的快捷语重组为【五步销售漏斗】+【补充话术】。" . "\n输出严格 JSON(不要 markdown):" . "{\"replies\":[{\"id\":数字,\"title\":\"标题\",\"content\":\"正文\",\"sortIndex\":数字}]}" . "\n规则:" . "\n1) 必须产出 5 条核心话术,标题分别为:「1. 打招呼」「2. 产品介绍」「3. 报价说明」「4. 异议处理·觉得贵」「5. 沉默跟进·B单唤醒」" . "\n2) 第2条须有明确行动号召(CTA),引导对方回复" . "\n3) 第3条写清价格/套餐" . "\n4) 第4条处理嫌贵" . "\n5) 第5条久未回复跟进" . "\n6) 原有多余文本条目标题用「6. 补充-」前缀,sortIndex 按 10,20,30… 递增" . "\n7) 仅改写 msgType=1 的 content/title;msgType 非 1 的保持 title/content 不变,排在列表末尾" . "\n8) 每条 id 必须来自输入,不可编造新 id"; $aiRes = $this->callQuickReplyAi($sys, json_encode([ 'groupName' => $group['groupName'], 'replies' => $payload, ], 256), $companyId, $userId); if ($aiRes['error'] !== '') { return ResponseHelper::error($aiRes['error'], 500); } $parsed = $aiRes['parsed']; $items = $parsed['replies'] ?? []; if (!is_array($items) || empty($items)) { return ResponseHelper::error('AI 未返回有效结构,请重试', 500); } $idMap = []; foreach ($replies as $r) { $idMap[(int)$r['id']] = true; } $updated = 0; $now = time(); foreach ($items as $item) { $rid = (int)($item['id'] ?? 0); if ($rid <= 0 || !isset($idMap[$rid])) { continue; } $row = []; if (!empty($item['title'])) { $row['title'] = mb_substr((string)$item['title'], 0, 120); } if (isset($item['content'])) { $row['content'] = (string)$item['content']; } if (isset($item['sortIndex'])) { $row['sortIndex'] = (string)(int)$item['sortIndex']; } if (empty($row)) { continue; } $row['lastUpdateTime'] = $now; Db::name('kf_reply')->where('id', $rid)->update($row); $updated++; } return ResponseHelper::success([ 'updated' => $updated, 'groupId' => $groupId, 'preview' => array_slice($items, 0, 12), 'cost' => (int)($aiRes['deducted'] ?? 0), ], "已整理 {$updated} 条话术"); } /** @return array{error:string,parsed:?array,deducted?:int} */ private function callQuickReplyAi(string $system, string $userJson, int $companyId, int $userId, bool $allowPlainText = false): array { $gw = \app\common\service\AiGatewayService::complete( 'ai_rewrite', [ ['role' => 'system', 'content' => $system], ['role' => 'user', 'content' => $userJson], ], $companyId, $userId, '', ['maxTokens' => 8192] ); if (empty($gw['ok'])) { return [ 'error' => (string)($gw['error'] ?? 'AI 网关调用失败'), 'parsed' => null, 'deducted' => (int)($gw['deducted'] ?? 0), ]; } $content = (string)($gw['content'] ?? ''); $parsed = null; if (preg_match('/\{[\s\S]*\}/', $content, $m)) { $parsed = json_decode($m[0], true); } if (!is_array($parsed) && $allowPlainText) { $parsed = $this->plainTextQuickReplyGroups($content); } if (!is_array($parsed)) { return ['error' => 'AI 返回无法解析为 JSON', 'parsed' => null, 'deducted' => (int)($gw['deducted'] ?? 0)]; } return ['error' => '', 'parsed' => $parsed, 'deducted' => (int)($gw['deducted'] ?? 0)]; } private function plainTextQuickReplyGroups(string $content): ?array { $replies = []; $lines = preg_split('/\R/u', trim($content)) ?: []; foreach ($lines as $line) { $line = trim((string)$line); $line = preg_replace('/^(?:[-*]\s*|\d+[.、)]\s*)/u', '', $line); $line = trim((string)$line, " \t\n\r\0\x0B*#"); if ($line === '' || mb_strlen($line) < 4 || strpos($line, '结论') === 0) { continue; } $replies[] = [ 'title' => '话术' . (count($replies) + 1), 'content' => mb_substr($line, 0, 500), ]; if (count($replies) >= 12) { break; } } if (empty($replies)) { return null; } return ['groups' => [['groupName' => 'AI 生成话术', 'replies' => $replies]]]; } private function ensureCompanyMirrorGroup(int $companyId, int $userId, string $srcName): int { $name = '来自个人·' . $srcName; $exist = Db::name('kf_reply_group') ->where('companyId', $companyId) ->where('replyType', 2) ->where('groupName', $name) ->where('isDel', 0) ->find(); if ($exist) { return (int)$exist['id']; } return (int)Db::name('kf_reply_group')->insertGetId([ 'groupName' => $name, 'parentId' => 0, 'replyType' => 2, 'sortIndex' => 50, 'companyId' => $companyId, 'userId' => $userId, 'isDel' => 0, ]); } private function ensurePersonalMirrorGroup(int $companyId, int $userId, string $srcName): int { $name = '来自公司·' . $srcName; $exist = Db::name('kf_reply_group') ->where('companyId', $companyId) ->where('replyType', 1) ->where('userId', $userId) ->where('groupName', $name) ->where('isDel', 0) ->find(); if ($exist) { return (int)$exist['id']; } return (int)Db::name('kf_reply_group')->insertGetId([ 'groupName' => $name, 'parentId' => 0, 'replyType' => 1, 'sortIndex' => 50, 'companyId' => $companyId, 'userId' => $userId, 'isDel' => 0, ]); } /** 确保存在公司侧「个人转入」默认分组(replyType=2),返回分组 id */ private function ensureCompanyInboxGroup(int $companyId, int $userId): int { $name = '个人转入'; $exist = Db::name('kf_reply_group') ->where('companyId', $companyId) ->where('replyType', 2) ->where('groupName', $name) ->where('isDel', 0) ->find(); if ($exist) { return (int)$exist['id']; } return (int)Db::name('kf_reply_group')->insertGetId([ 'groupName' => $name, 'parentId' => 0, 'replyType' => 2, 'sortIndex' => 50, 'companyId' => $companyId, 'userId' => $userId, 'isDel' => 0, ]); } /** * 导出快捷语(个人/公司) * GET /v1/kefu/reply/export?scope=personal|company&format=json|markdown */ public function export() { $companyId = $this->getUserInfo('companyId'); $userId = $this->getUserInfo('id'); $scope = (string)$this->request->param('scope', 'personal'); $format = (string)$this->request->param('format', 'markdown'); // 公司分组 or 个人分组 $groupWhere = ['isDel' => 0, 'companyId' => $companyId]; if ($scope === 'personal') { $groupWhere['userId'] = $userId; } $groups = Db::name('kf_reply_group')->where($groupWhere)->order('sortIndex desc,id asc')->select(); $tree = []; foreach ($groups as $g) { $replies = Db::name('kf_reply') ->where(['groupId' => $g['id'], 'isDel' => 0]) ->field('title,content,msgType') ->order('sortIndex desc,id asc') ->select(); $tree[] = ['group' => $g['groupName'], 'replies' => $replies]; } if ($format === 'json') { return ResponseHelper::success([ 'format' => 'json', 'content' => json_encode($tree, 256), ]); } // markdown $md = "# 快捷语导出(" . ($scope === 'company' ? '公司' : '个人') . ")\n\n"; foreach ($tree as $node) { $md .= "## " . $node['group'] . "\n\n"; foreach ($node['replies'] as $r) { $md .= "- **" . $r['title'] . "**"; if ($r['msgType'] == 1 && !empty($r['content'])) { $md .= ":" . str_replace("\n", " ", $r['content']); } $md .= "\n"; } $md .= "\n"; } return ResponseHelper::success(['format' => 'markdown', 'content' => $md]); } /** * 快捷语 → 生成知识库(记录意图;KB 真源在存客宝,待联调) * POST /v1/kefu/quick-reply/export-kb body: { scope, kbName? } */ public function exportKb() { $scope = (string)$this->request->param('scope', 'personal'); $kbName = (string)$this->request->param('kbName', ''); $companyId = (int)$this->getUserInfo('companyId'); $userId = (int)$this->getUserInfo('id'); // 复用 export 产出 markdown,并直接写入存客宝 ai_knowledge_base $exportResp = $this->export(); $data = json_decode($exportResp->getContent(), true); $content = $data['data']['content'] ?? ''; if ($content === '') { return ResponseHelper::error('导出内容为空,无法入库', 400); } $finalKbName = $kbName ?: ('客服知识库-' . date('md')); $typeId = $this->ensureKbType($companyId, $userId); $fileUrl = $this->writeKbMarkdown($companyId, $finalKbName, $content); $kbId = (int)Db::name('ai_knowledge_base')->insertGetId([ 'typeId' => $typeId, 'name' => $finalKbName, 'label' => json_encode([], 256), 'fileUrl' => $fileUrl, 'companyId' => $companyId, 'userId' => $userId, 'createTime' => time(), 'updateTime' => time(), 'isDel' => 0, ]); return ResponseHelper::success([ 'scope' => $scope, 'kbId' => $kbId, 'kbName' => $finalKbName, 'typeId' => $typeId, 'fileUrl' => $fileUrl, 'content' => $content, 'pending' => false, 'tip' => '已写入存客宝知识库(若需对触客宝可见,请在存客宝开启协同分配)', ], '已生成并写入知识库'); } /** * AI 生成整套话术目录+条目 * POST /v1/kefu/quick-reply/ai-generate body: { productDesc, kbId? } */ public function aiGenerate() { $productDesc = trim((string)$this->request->param('productDesc', '')); if ($productDesc === '') { return ResponseHelper::error('请输入产品描述', 400); } $companyId = $this->getUserInfo('companyId'); $userId = $this->getUserInfo('id'); $sys = "你是私域客服话术专家。根据【产品描述】生成一套微信客服快捷语目录," . "输出严格 JSON:{\"groups\":[{\"groupName\":\"分组名\",\"replies\":[{\"title\":\"标题\",\"content\":\"话术正文\"}]}]}," . "不要输出 JSON 以外任何内容。分组 3-5 个,每组 3-6 条。"; try { $aiRes = $this->callQuickReplyAi($sys, '【产品描述】' . $productDesc, (int)$companyId, (int)$userId, true); if ($aiRes['error'] !== '') { return ResponseHelper::error('AI 生成失败:' . $aiRes['error'], 500); } $parsed = $aiRes['parsed']; return ResponseHelper::success([ 'groups' => $parsed['groups'] ?? [], 'raw' => null, 'cost' => (int)($aiRes['deducted'] ?? 0), ], '已生成'); } catch (\Exception $e) { return ResponseHelper::error('AI 调用异常:' . $e->getMessage(), 500); } } }