Files
cunkebao_v3/Server/application/chukebao/controller/QuickReplyExtController.php
Manus AI 5517457929 sync: 以本地为准同步全量变更至 GitHub
含四端需求文档、前端/后端/超管/触客宝迭代及部署脚本更新;未拉取远程。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 04:24:56 +08:00

262 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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);
}
}
}