262 lines
10 KiB
PHP
262 lines
10 KiB
PHP
<?php
|
||
|
||
namespace app\chukebao\controller;
|
||
|
||
use library\ResponseHelper;
|
||
use think\Db;
|
||
use think\facade\Env;
|
||
|
||
/**
|
||
* 模块 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], '已更新');
|
||
}
|
||
|
||
/**
|
||
* 导出快捷语(个人/公司)
|
||
* 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);
|
||
}
|
||
$apiUrl = Env::get('doubaoAi.api_url');
|
||
$apiKey = Env::get('doubaoAi.api_key');
|
||
if (empty($apiUrl) || empty($apiKey)) {
|
||
return ResponseHelper::error('AI 服务未配置(doubaoAi),请联系管理员', 500);
|
||
}
|
||
$companyId = $this->getUserInfo('companyId');
|
||
$userId = $this->getUserInfo('id');
|
||
|
||
$sys = "你是私域客服话术专家。根据【产品描述】生成一套微信客服快捷语目录,"
|
||
. "输出严格 JSON:{\"groups\":[{\"groupName\":\"分组名\",\"replies\":[{\"title\":\"标题\",\"content\":\"话术正文\"}]}]},"
|
||
. "不要输出 JSON 以外任何内容。分组 3-5 个,每组 3-6 条。";
|
||
$params = [
|
||
'model' => 'doubao-seed-1-8-251215',
|
||
'messages' => [
|
||
['role' => 'system', 'content' => $sys],
|
||
['role' => 'user', 'content' => '【产品描述】' . $productDesc],
|
||
],
|
||
];
|
||
$headers = ['Content-Type: application/json', 'Authorization: Bearer ' . $apiKey];
|
||
try {
|
||
$raw = requestCurl($apiUrl . '/api/v3/chat/completions', $params, 'POST', $headers, 'json');
|
||
$res = json_decode($raw, true);
|
||
if (isset($res['error'])) {
|
||
return ResponseHelper::error('AI 生成失败:' . ($res['error']['message'] ?? ''), 500);
|
||
}
|
||
$content = $res['choices'][0]['message']['content'] ?? '';
|
||
// 提取 JSON
|
||
if (preg_match('/\{[\s\S]*\}/', $content, $m)) {
|
||
$parsed = json_decode($m[0], true);
|
||
} else {
|
||
$parsed = null;
|
||
}
|
||
// 扣算力:统一走 TokensService 规则(AI 改写 action=12)
|
||
$cost = 0;
|
||
try {
|
||
$svc = new \app\cunkebao\service\TokensService((int)$companyId, (int)$userId);
|
||
$res3 = $svc->consume(\app\cunkebao\service\TokensService::ACTION_AI_REWRITE, 'AI生成快捷语', 1);
|
||
$cost = (int)($res3['deducted'] ?? 0);
|
||
} catch (\Exception $e) {
|
||
}
|
||
return ResponseHelper::success([
|
||
'groups' => $parsed['groups'] ?? [],
|
||
'raw' => $parsed ? null : $content,
|
||
'cost' => $cost,
|
||
], '已生成');
|
||
} catch (\Exception $e) {
|
||
return ResponseHelper::error('AI 调用异常:' . $e->getMessage(), 500);
|
||
}
|
||
}
|
||
}
|