## GitHub 同步说明 | 项 | 内容 | |:---|:---| | 仓库 | https://github.com/fnvtk/cunkebao_v3 | | 分支 | develop | | 方向 | 本地 → GitHub(单向 push) | | 是否拉取远程 | 否(未 fetch / pull / merge) | | 是否改本地源文件 | 否(仅 git add/commit,未编辑任何业务/文档正文) | | 远程基准 | origin/develop @2753265e| | 本批工作区变更 | 563 files, +30324 / -7013 lines | ## 一并推送的本地已有 commit(此前未 push) 1.f7a7c56cchore(触客宝): 全量构建部署 kr-kf 生产并通过线上 smoke 2.8801f5fffeat(触客宝): TKB-120 快捷语整组互拷与 AI 五步整理 3.c396ed35docs(触客宝): 0603 已完成项归档并交付 TKB-117-B 超管存储 4.e4ed53bcfix(部署): SSH 主机密钥变更兼容并完成五端全量上线 ## 本 commit 目录统计 | 目录 | 文件数 | 说明 | |:---|---:|:---| | 开发文档/ | 251 | 触客宝 0603~0608 归档、未完成项、五端账号速查 | | Touchkebao/ | 109 | 微信客服 CustomerList、快捷语、AI 获客等 | | Server/ | 108 | BFF/API、TaskCustomerList 等 | | Cunkebao/ | 53 | 存客宝移动端迭代 | | SuperAdmin/ | 24 | 超管后台 | | 官网/ | 7 | 官网小改 | | 其他 | 21 | .cursor、Moncter、docker-compose 等 | ## 重点文件(本地为准) - Touchkebao/.../CustomerList/index.tsx(微信客户列表) - 开发文档/1、需求/修改/触客宝_进行中_20260608.md - 开发文档/1、需求/修改/未完成项与跨仓验收汇总.md - 开发文档/10、项目管理/线上五端网址与账号密码速查.md - 开发文档/10、项目管理/已完成/触客宝_0607* 归档 - 开发文档/5、接口/02-存客宝对接/触客宝PC接口对接索引.md ## 刻意未上传 - node_modules/、.DS_Store、.smart-env/ - .开发文档_nested_git_backup/ - **/__pycache__/ ## 验收 - 本提交说明: https://github.com/fnvtk/cunkebao_v3/commit/HEAD - 分支: https://github.com/fnvtk/cunkebao_v3/tree/develop Co-authored-by: Cursor <cursoragent@cursor.com>
724 lines
27 KiB
PHP
724 lines
27 KiB
PHP
<?php
|
||
|
||
namespace app\chukebao\controller;
|
||
|
||
use library\ResponseHelper;
|
||
use think\Db;
|
||
|
||
/**
|
||
* 模块 E · 快捷语扩展(需求六 §6.5)
|
||
* 置顶 / 导出 / 导出为知识库 / AI 生成话术目录。
|
||
* 置顶表:ck_kf_reply_pin(懒建)。
|
||
*/
|
||
class QuickReplyExtController extends BaseController
|
||
{
|
||
private function ensureKbType(int $companyId, int $userId): int
|
||
{
|
||
$typeName = '触客宝快捷语导入';
|
||
$exist = Db::name('ai_knowledge_base_type')
|
||
->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);
|
||
}
|
||
}
|
||
}
|