Merge branch 'develop' into yongpxu-dev2
This commit is contained in:
@@ -70,24 +70,72 @@ const PlanApi: React.FC<PlanApiProps> = ({
|
||||
|
||||
// 处理webhook URL,确保包含完整的API地址
|
||||
const fullWebhookUrl = useMemo(() => {
|
||||
return buildApiUrl(webhookUrl);
|
||||
return buildApiUrl('');
|
||||
}, [webhookUrl]);
|
||||
|
||||
// 生成测试URL
|
||||
// 快速测试使用的 GET 地址(携带示例查询参数,方便在浏览器中直接访问)
|
||||
const testUrl = useMemo(() => {
|
||||
if (!fullWebhookUrl) return "";
|
||||
return `${fullWebhookUrl}?name=测试客户&phone=13800138000&source=API测试`;
|
||||
}, [fullWebhookUrl]);
|
||||
return buildApiUrl(webhookUrl);
|
||||
}, [webhookUrl]);
|
||||
|
||||
// 检测是否为移动端
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
|
||||
const handleCopy = (text: string, type: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
Toast.show({
|
||||
content: `${type}已复制到剪贴板`,
|
||||
position: "top",
|
||||
});
|
||||
// 先尝试使用 Clipboard API
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
Toast.show({
|
||||
content: `${type}已复制到剪贴板`,
|
||||
position: "top",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// 回退到传统的 textarea 复制方式
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
Toast.show({
|
||||
content: `${type}已复制到剪贴板`,
|
||||
position: "top",
|
||||
});
|
||||
} catch {
|
||||
Toast.show({
|
||||
content: `${type}复制失败,请手动复制`,
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
document.body.removeChild(textarea);
|
||||
});
|
||||
} else {
|
||||
// 不支持 Clipboard API 时直接使用回退方案
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
Toast.show({
|
||||
content: `${type}已复制到剪贴板`,
|
||||
position: "top",
|
||||
});
|
||||
} catch {
|
||||
Toast.show({
|
||||
content: `${type}复制失败,请手动复制`,
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestInBrowser = () => {
|
||||
@@ -96,7 +144,7 @@ const PlanApi: React.FC<PlanApiProps> = ({
|
||||
|
||||
const renderConfigTab = () => (
|
||||
<div className={style["config-content"]}>
|
||||
{/* API密钥配置 */}
|
||||
{/* 鉴权参数配置 */}
|
||||
<div className={style["config-section"]}>
|
||||
<div className={style["section-header"]}>
|
||||
<div className={style["section-title"]}>
|
||||
@@ -122,7 +170,7 @@ const PlanApi: React.FC<PlanApiProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 接口地址配置 */}
|
||||
{/* 接口地址与参数说明 */}
|
||||
<div className={style["config-section"]}>
|
||||
<div className={style["section-header"]}>
|
||||
<div className={style["section-title"]}>
|
||||
@@ -150,27 +198,42 @@ const PlanApi: React.FC<PlanApiProps> = ({
|
||||
{/* 参数说明 */}
|
||||
<div className={style["params-grid"]}>
|
||||
<div className={style["param-section"]}>
|
||||
<h4>必要参数</h4>
|
||||
<h4>鉴权参数(必填)</h4>
|
||||
<div className={style["param-list"]}>
|
||||
<div>
|
||||
<code>name</code> - 客户姓名
|
||||
<code>apiKey</code> - 分配给第三方的接口密钥(每个任务唯一)
|
||||
</div>
|
||||
<div>
|
||||
<code>phone</code> - 手机号码
|
||||
<code>sign</code> - 签名值,按文档的签名规则生成
|
||||
</div>
|
||||
<div>
|
||||
<code>timestamp</code> - 秒级时间戳(与服务器时间差不超过 5 分钟)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["param-section"]}>
|
||||
<h4>可选参数</h4>
|
||||
<h4>业务参数</h4>
|
||||
<div className={style["param-list"]}>
|
||||
<div>
|
||||
<code>source</code> - 来源标识
|
||||
<code>wechatId</code> - 微信号,存在时优先作为主标识
|
||||
</div>
|
||||
<div>
|
||||
<code>phone</code> - 手机号,当 <code>wechatId</code> 为空时用作主标识
|
||||
</div>
|
||||
<div>
|
||||
<code>name</code> - 客户姓名
|
||||
</div>
|
||||
<div>
|
||||
<code>source</code> - 线索来源描述,如“百度推广”、“抖音直播间”
|
||||
</div>
|
||||
<div>
|
||||
<code>remark</code> - 备注信息
|
||||
</div>
|
||||
<div>
|
||||
<code>tags</code> - 客户标签
|
||||
<code>tags</code> - 微信标签,逗号分隔,如 <code>"高意向,电商,女装"</code>
|
||||
</div>
|
||||
<div>
|
||||
<code>siteTags</code> - 站内标签,逗号分隔,用于站内进一步细分
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,93 +242,131 @@ const PlanApi: React.FC<PlanApiProps> = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderQuickTestTab = () => (
|
||||
<div className={style["test-content"]}>
|
||||
<div className={style["test-section"]}>
|
||||
<h3>快速测试URL</h3>
|
||||
<div className={style["input-group"]}>
|
||||
<Input value={testUrl} disabled className={style["test-input"]} />
|
||||
</div>
|
||||
<div className={style["test-buttons"]}>
|
||||
<Button
|
||||
onClick={() => handleCopy(testUrl, "测试URL")}
|
||||
className={style["test-btn"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制测试URL
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleTestInBrowser}
|
||||
className={style["test-btn"]}
|
||||
>
|
||||
在浏览器中测试
|
||||
</Button>
|
||||
const renderQuickTestTab = () => {
|
||||
return (
|
||||
<div className={style["test-content"]}>
|
||||
<div className={style["test-section"]}>
|
||||
<h3>快速测试 URL(GET 示例)</h3>
|
||||
<div className={style["input-group"]}>
|
||||
<Input value={testUrl} disabled className={style["test-input"]} />
|
||||
</div>
|
||||
<div className={style["test-buttons"]}>
|
||||
<Button
|
||||
onClick={() => handleCopy(testUrl, "测试URL")}
|
||||
className={style["test-btn"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制测试URL
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={handleTestInBrowser}
|
||||
className={style["test-btn"]}
|
||||
>
|
||||
在浏览器中测试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
const renderDocsTab = () => (
|
||||
<div className={style["docs-content"]}>
|
||||
<div className={style["docs-grid"]}>
|
||||
<Card className={style["doc-card"]}>
|
||||
<div className={style["doc-icon"]}>
|
||||
<BookOutlined />
|
||||
</div>
|
||||
<h4>完整API文档</h4>
|
||||
<p>详细的接口说明和参数文档</p>
|
||||
</Card>
|
||||
<Card className={style["doc-card"]}>
|
||||
<div className={style["doc-icon"]}>
|
||||
<LinkOutlined />
|
||||
</div>
|
||||
<h4>集成指南</h4>
|
||||
<p>第三方平台集成教程</p>
|
||||
</Card>
|
||||
const renderDocsTab = () => {
|
||||
const docUrl = `${import.meta.env.VITE_API_BASE_URL}/doc/api_v1.md`;
|
||||
|
||||
return (
|
||||
<div className={style["docs-content"]}>
|
||||
<div className={style["docs-grid"]}>
|
||||
<Card className={style["doc-card"]}>
|
||||
<div className={style["doc-icon"]}>
|
||||
<BookOutlined />
|
||||
</div>
|
||||
<h4>对外获客线索上报接口文档(V1)</h4>
|
||||
<p>点击下方按钮可直接在浏览器中打开最新的远程文档。</p>
|
||||
<div className={style["doc-actions"]}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
window.open(docUrl, "_blank");
|
||||
}}
|
||||
className={style["doc-open-btn"]}
|
||||
>
|
||||
在浏览器中打开文档
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleCopy(docUrl, "文档链接")}
|
||||
className={style["doc-copy-btn"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制文档链接
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
const renderCodeTab = () => {
|
||||
const codeExamples = {
|
||||
javascript: `fetch('${fullWebhookUrl}', {
|
||||
javascript: `// 参考 api_v1 文档示例,使用 JSON 方式 POST
|
||||
fetch('${fullWebhookUrl}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ${apiKey}'
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: '张三',
|
||||
apiKey: '${apiKey}',
|
||||
timestamp: 1710000000, // 秒级时间戳
|
||||
phone: '13800138000',
|
||||
name: '张三',
|
||||
source: '官网表单',
|
||||
remark: '通过H5表单提交',
|
||||
tags: '高意向,电商',
|
||||
siteTags: '新客,女装',
|
||||
// sign 需要根据签名规则生成
|
||||
sign: '根据签名规则生成的MD5字符串'
|
||||
})
|
||||
})`,
|
||||
python: `import requests
|
||||
|
||||
url = '${fullWebhookUrl}'
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ${apiKey}'
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
data = {
|
||||
'name': '张三',
|
||||
'phone': '13800138000',
|
||||
'source': '官网表单'
|
||||
"apiKey": "${apiKey}",
|
||||
"timestamp": 1710000000,
|
||||
"phone": "13800138000",
|
||||
"name": "张三",
|
||||
"source": "官网表单",
|
||||
"remark": "通过H5表单提交",
|
||||
"tags": "高意向,电商",
|
||||
"siteTags": "新客,女装",
|
||||
# sign 需要根据签名规则生成
|
||||
"sign": "根据签名规则生成的MD5字符串"
|
||||
}
|
||||
|
||||
response = requests.post(url, json=data, headers=headers)`,
|
||||
php: `<?php
|
||||
$url = '${fullWebhookUrl}';
|
||||
$data = array(
|
||||
'name' => '张三',
|
||||
'apiKey' => '${apiKey}',
|
||||
'timestamp' => 1710000000,
|
||||
'phone' => '13800138000',
|
||||
'source' => '官网表单'
|
||||
'name' => '张三',
|
||||
'source' => '官网表单',
|
||||
'remark' => '通过H5表单提交',
|
||||
'tags' => '高意向,电商',
|
||||
'siteTags' => '新客,女装',
|
||||
// sign 需要根据签名规则生成
|
||||
'sign' => '根据签名规则生成的MD5字符串'
|
||||
);
|
||||
|
||||
$options = array(
|
||||
'http' => array(
|
||||
'header' => "Content-type: application/json\\r\\nAuthorization: Bearer ${apiKey}\\r\\n",
|
||||
'header' => "Content-type: application/json\\r\\n",
|
||||
'method' => 'POST',
|
||||
'content' => json_encode($data)
|
||||
)
|
||||
@@ -279,12 +380,11 @@ import java.net.http.HttpResponse;
|
||||
import java.net.URI;
|
||||
|
||||
HttpClient client = HttpClient.newHttpClient();
|
||||
String json = "{\\"name\\":\\"张三\\",\\"phone\\":\\"13800138000\\",\\"source\\":\\"官网表单\\"}";
|
||||
String json = "{\\"apiKey\\":\\"${apiKey}\\",\\"timestamp\\":1710000000,\\"phone\\":\\"13800138000\\",\\"name\\":\\"张三\\",\\"source\\":\\"官网表单\\",\\"remark\\":\\"通过H5表单提交\\",\\"tags\\":\\"高意向,电商\\",\\"siteTags\\":\\"新客,女装\\",\\"sign\\":\\"根据签名规则生成的MD5字符串\\"}";
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create("${fullWebhookUrl}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", "Bearer ${apiKey}")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(json))
|
||||
.build();
|
||||
|
||||
@@ -394,11 +494,7 @@ HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.o
|
||||
<SafetyOutlined />
|
||||
所有数据传输均采用HTTPS加密
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={onClose}
|
||||
className={style["complete-btn"]}
|
||||
>
|
||||
<Button color="primary" onClick={onClose} className={style["complete-btn"]}>
|
||||
完成配置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -33,14 +33,15 @@ export const getFullApiPath = (): string => {
|
||||
* - buildApiUrl('https://api.example.com/webhook/123') → 'https://api.example.com/webhook/123'
|
||||
*/
|
||||
export const buildApiUrl = (path: string): string => {
|
||||
if (!path) return "";
|
||||
const fullApiPath = getFullApiPath();
|
||||
|
||||
if (!path) return `${fullApiPath}`;
|
||||
|
||||
// 如果已经是完整的URL(包含http或https),直接返回
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const fullApiPath = getFullApiPath();
|
||||
|
||||
// 如果是相对路径,拼接完整API路径
|
||||
if (path.startsWith("/")) {
|
||||
|
||||
@@ -42,7 +42,13 @@ Route::group('v1/', function () {
|
||||
});
|
||||
|
||||
//微信分组
|
||||
Route::get('wechatGroup/list', 'app\chukebao\controller\WechatGroupController@getList'); // 微信分组
|
||||
Route::group('wechatGroup/', function () {
|
||||
Route::get('list', 'app\chukebao\controller\WechatGroupController@getList'); // 获取分组列表
|
||||
Route::post('add', 'app\chukebao\controller\WechatGroupController@create'); // 新增分组
|
||||
Route::post('update', 'app\chukebao\controller\WechatGroupController@update'); // 更新分组
|
||||
Route::delete('delete', 'app\chukebao\controller\WechatGroupController@delete'); // 删除分组(假删除)
|
||||
Route::post('move', 'app\chukebao\controller\WechatGroupController@move'); // 移动分组(好友/群移动到指定分组)
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ class DataProcessing extends BaseController
|
||||
'CmdChatroomOperate', //修改群信息 {chatroomName(群名)、announce(公告)、extra(公告)、wechatAccountId、wechatChatroomId}
|
||||
'CmdNewMessage', //接收消息
|
||||
'CmdSendMessageResult', //更新消息状态
|
||||
'CmdPinToTop', //置顶
|
||||
];
|
||||
|
||||
if (empty($type) || empty($wechatAccountId)) {
|
||||
@@ -164,6 +165,41 @@ class DataProcessing extends BaseController
|
||||
|
||||
$msg = '更新消息状态成功';
|
||||
break;
|
||||
case 'CmdPinToTop': //置顶
|
||||
$wechatFriendId = $this->request->param('wechatFriendId', 0);
|
||||
$wechatChatroomId = $this->request->param('wechatChatroomId', 0);
|
||||
$isTop = $this->request->param('isTop', null);
|
||||
|
||||
if ($isTop === null) {
|
||||
return ResponseHelper::error('isTop不能为空');
|
||||
}
|
||||
|
||||
if (empty($wechatFriendId) && empty($wechatChatroomId)) {
|
||||
return ResponseHelper::error('wechatFriendId或chatroomId至少提供一个');
|
||||
}
|
||||
|
||||
|
||||
if (!empty($wechatFriendId)){
|
||||
$data = WechatFriendModel::where(['id' => $wechatFriendId,'wechatAccountId' => $wechatAccountId])->find();
|
||||
$msg = $isTop == 1 ? '已置顶' : '取消置顶';
|
||||
if(empty($data)){
|
||||
return ResponseHelper::error('好友不存在');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!empty($wechatChatroomId)){
|
||||
$data = WechatChatroomModel::where(['id' => $wechatChatroomId,'wechatAccountId' => $wechatAccountId])->find();
|
||||
$msg = $isTop == 1 ? '已置顶' : '取消置顶';
|
||||
if(empty($data)){
|
||||
return ResponseHelper::error('群聊不存在');
|
||||
}
|
||||
}
|
||||
|
||||
$data->updateTime = time();
|
||||
$data->isTop = $isTop;
|
||||
$data->save();
|
||||
break;
|
||||
}
|
||||
return ResponseHelper::success('',$msg,$codee);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,24 @@
|
||||
|
||||
namespace app\chukebao\controller;
|
||||
|
||||
use app\api\model\WechatMessageModel;
|
||||
use app\chukebao\model\FriendSettings;
|
||||
use library\ResponseHelper;
|
||||
use think\Db;
|
||||
use think\facade\Env;
|
||||
use app\common\service\AuthService;
|
||||
|
||||
class MessageController extends BaseController
|
||||
{
|
||||
protected $baseUrl;
|
||||
protected $authorization;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->baseUrl = Env::get('api.wechat_url');
|
||||
$this->authorization = AuthService::getSystemAuthorization();
|
||||
}
|
||||
|
||||
public function getList()
|
||||
{
|
||||
@@ -20,7 +32,7 @@ class MessageController extends BaseController
|
||||
|
||||
$friends = Db::table('s2_wechat_friend')
|
||||
->where(['accountId' => $accountId, 'isDeleted' => 0])
|
||||
->column('id,nickname,avatar,conRemark,labels,groupId,wechatAccountId,wechatId,extendFields,phone,region');
|
||||
->column('id,nickname,avatar,conRemark,labels,groupId,wechatAccountId,wechatId,extendFields,phone,region,isTop');
|
||||
|
||||
|
||||
// 构建好友子查询
|
||||
@@ -31,7 +43,7 @@ class MessageController extends BaseController
|
||||
|
||||
// 优化后的查询:使用MySQL兼容的查询方式
|
||||
$unionQuery = "
|
||||
(SELECT m.id, m.content, m.wechatFriendId, m.wechatChatroomId, m.createTime, m.wechatTime,m.wechatAccountId, 2 as msgType, wc.nickname, wc.chatroomAvatar as avatar, wc.chatroomId
|
||||
(SELECT m.id, m.content, m.wechatFriendId, m.wechatChatroomId, m.createTime, m.wechatTime,m.wechatAccountId, 2 as msgType, wc.nickname, wc.chatroomAvatar as avatar, wc.chatroomId, wc.isTop
|
||||
FROM s2_wechat_chatroom wc
|
||||
INNER JOIN s2_wechat_message m ON wc.id = m.wechatChatroomId AND m.type = 2
|
||||
INNER JOIN (
|
||||
@@ -43,7 +55,7 @@ class MessageController extends BaseController
|
||||
WHERE wc.accountId = {$accountId} AND wc.isDeleted = 0
|
||||
)
|
||||
UNION ALL
|
||||
(SELECT m.id, m.content, m.wechatFriendId, m.wechatChatroomId, m.createTime, m.wechatTime, 1 as msgType, 1 as nickname, 1 as avatar, 1 as chatroomId, 1 as wechatAccountId
|
||||
(SELECT m.id, m.content, m.wechatFriendId, m.wechatChatroomId, m.createTime, m.wechatTime, 1 as msgType, 1 as nickname, 1 as avatar, 1 as chatroomId, 1 as wechatAccountId, 0 as isTop
|
||||
FROM s2_wechat_message m
|
||||
INNER JOIN (
|
||||
SELECT wechatFriendId, MAX(wechatTime) as maxTime, MAX(id) as maxId
|
||||
@@ -64,7 +76,6 @@ class MessageController extends BaseController
|
||||
return $b['wechatTime'] <=> $a['wechatTime'];
|
||||
});
|
||||
|
||||
|
||||
// 批量统计未读数量(isRead=0),按好友/群聊分别聚合
|
||||
$friendIds = [];
|
||||
$chatroomIds = [];
|
||||
@@ -122,6 +133,7 @@ class MessageController extends BaseController
|
||||
$v['extendFields'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['extendFields'] : [];
|
||||
$v['region'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['region'] : '';
|
||||
$v['phone'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['phone'] : '';
|
||||
$v['isTop'] = !empty($friends[$v['wechatFriendId']]) ? $friends[$v['wechatFriendId']]['isTop'] : 0;
|
||||
$v['labels'] = !empty($friends[$v['wechatFriendId']]) ? json_decode($friends[$v['wechatFriendId']]['labels'], true) : [];
|
||||
|
||||
$unreadCount = isset($friendUnreadMap[$v['wechatFriendId']]) ? (int)$friendUnreadMap[$v['wechatFriendId']] : 0;
|
||||
@@ -136,7 +148,7 @@ class MessageController extends BaseController
|
||||
|
||||
$v['id'] = !empty($v['wechatFriendId']) ? $v['wechatFriendId'] : $v['wechatChatroomId'];
|
||||
$v['config'] = [
|
||||
'top' => false,
|
||||
'top' => !empty($v['isTop']) ? true : false,
|
||||
'unreadCount' => $unreadCount,
|
||||
'chat' => true,
|
||||
'msgTime' => $v['wechatTime'],
|
||||
@@ -150,7 +162,7 @@ class MessageController extends BaseController
|
||||
'wechatTime' => $wechatTime
|
||||
];
|
||||
|
||||
unset($v['wechatFriendId'], $v['wechatChatroomId']);
|
||||
unset($v['wechatFriendId'], $v['wechatChatroomId'],$v['isTop']);
|
||||
|
||||
}
|
||||
unset($v);
|
||||
@@ -218,14 +230,137 @@ class MessageController extends BaseController
|
||||
$total = Db::table('s2_wechat_message')->where($where)->count();
|
||||
$list = Db::table('s2_wechat_message')->where($where)->page($page, $limit)->order('id DESC')->select();
|
||||
|
||||
|
||||
foreach ($list as $k => &$v) {
|
||||
$v['wechatTime'] = !empty($v['wechatTime']) ? date('Y-m-d H:i:s', $v['wechatTime']) : '';
|
||||
// 检查消息是否有sendStatus字段,如果有且不为0,则请求线上最新接口
|
||||
foreach ($list as $k => &$item) {
|
||||
// 检查是否存在sendStatus字段且不为0(0表示已发送成功)
|
||||
if (isset($item['sendStatus']) && $item['sendStatus'] != 0) {
|
||||
// 需要请求新的数据
|
||||
$messageRequest = [
|
||||
'id' => $item['id'],
|
||||
'wechatAccountId' => $wechatAccountId,
|
||||
'wechatFriendId' => $wechatFriendId,
|
||||
'wechatChatroomId' => $wechatChatroomId,
|
||||
'from' => '',
|
||||
'to' => '',
|
||||
];
|
||||
$newData = $this->fetchLatestMessageFromApi($messageRequest);
|
||||
if (!empty($newData)){
|
||||
$item['sendStatus'] = 0;
|
||||
}
|
||||
}
|
||||
// 格式化时间
|
||||
$item['wechatTime'] = !empty($item['wechatTime']) ? date('Y-m-d H:i:s', $item['wechatTime']) : '';
|
||||
}
|
||||
unset($item);
|
||||
|
||||
|
||||
return ResponseHelper::success(['total' => $total, 'list' => $list]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从线上接口获取最新消息
|
||||
* @param array $messageRequest 消息项(包含wechatAccountId、wechatFriendId或wechatChatroomId、id等)
|
||||
* @return array|null 最新消息数据,失败返回null
|
||||
*/
|
||||
private function fetchLatestMessageFromApi($messageRequest)
|
||||
{
|
||||
if (empty($this->baseUrl) || empty($this->authorization)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 设置请求头
|
||||
$headerData = ['client:system'];
|
||||
$header = setHeader($headerData, $this->authorization, 'json');
|
||||
|
||||
// 判断是好友消息还是群聊消息
|
||||
if (!empty($messageRequest['wechatFriendId'])) {
|
||||
// 好友消息接口
|
||||
$params = [
|
||||
'keyword' => '',
|
||||
'msgType' => '',
|
||||
'accountId' => '',
|
||||
'count' => 20, // 获取多条消息以便找到对应的消息
|
||||
'messageId' => isset($messageRequest['id']) ? $messageRequest['id'] : '',
|
||||
'olderData' => true,
|
||||
'wechatAccountId' => $messageRequest['wechatAccountId'],
|
||||
'wechatFriendId' => $messageRequest['wechatFriendId'],
|
||||
'from' => $messageRequest['from'],
|
||||
'to' => $messageRequest['to'],
|
||||
'searchFrom' => 'admin'
|
||||
];
|
||||
$result = requestCurl($this->baseUrl . 'api/FriendMessage/searchMessage', $params, 'GET', $header, 'json');
|
||||
$response = handleApiResponse($result);
|
||||
// 查找对应的消息
|
||||
if (!empty($response) && is_array($response)) {
|
||||
$data = $response[0];
|
||||
if ($data['sendStatus'] == 0){
|
||||
WechatMessageModel::where(['id' => $data['id']])->update(['sendStatus' => 0]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} elseif (!empty($messageRequest['wechatChatroomId'])) {
|
||||
// 群聊消息接口
|
||||
$params = [
|
||||
'keyword' => '',
|
||||
'msgType' => '',
|
||||
'accountId' => '',
|
||||
'count' => 20, // 获取多条消息以便找到对应的消息
|
||||
'messageId' => isset($messageRequest['id']) ? $messageRequest['id'] : '',
|
||||
'olderData' => true,
|
||||
'wechatId' => '',
|
||||
'wechatAccountId' => $messageRequest['wechatAccountId'],
|
||||
'wechatChatroomId' => $messageRequest['wechatChatroomId'],
|
||||
'from' => $messageRequest['from'],
|
||||
'to' => $messageRequest['to'],
|
||||
'searchFrom' => 'admin'
|
||||
];
|
||||
|
||||
$result = requestCurl($this->baseUrl . 'api/ChatroomMessage/searchMessage', $params, 'GET', $header, 'json');
|
||||
$response = handleApiResponse($result);
|
||||
|
||||
// 查找对应的消息
|
||||
if (!empty($response) && is_array($response)) {
|
||||
$data = $response[0];
|
||||
if ($data['sendStatus'] == 0){
|
||||
WechatMessageModel::where(['id' => $data['id']])->update(['sendStatus' => 0]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 记录错误日志,但不影响主流程
|
||||
\think\facade\Log::error('获取线上最新消息失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新数据库中的消息
|
||||
* @param array $latestMessage 线上获取的最新消息
|
||||
* @param array $oldMessage 旧消息数据
|
||||
*/
|
||||
private function updateMessageInDatabase($latestMessage, $oldMessage)
|
||||
{
|
||||
try {
|
||||
// 使用API模块的MessageController来保存消息
|
||||
$apiMessageController = new \app\api\controller\MessageController();
|
||||
|
||||
// 判断是好友消息还是群聊消息
|
||||
if (!empty($oldMessage['wechatFriendId'])) {
|
||||
// 保存好友消息
|
||||
$apiMessageController->saveMessage($latestMessage);
|
||||
} elseif (!empty($oldMessage['wechatChatroomId'])) {
|
||||
// 保存群聊消息
|
||||
$apiMessageController->saveChatroomMessage($latestMessage);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 记录错误日志,但不影响主流程
|
||||
\think\facade\Log::error('更新数据库消息失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,27 +4,32 @@ namespace app\chukebao\controller;
|
||||
|
||||
use library\ResponseHelper;
|
||||
use think\Db;
|
||||
use app\chukebao\model\ChatGroups;
|
||||
|
||||
class WechatGroupController extends BaseController
|
||||
{
|
||||
|
||||
public function getList(){
|
||||
|
||||
$accountId = $this->getUserInfo('s2_accountId');
|
||||
$userId = $this->getUserInfo('id');
|
||||
/**
|
||||
* 获取分组列表
|
||||
* @return \think\response\Json
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getList()
|
||||
{
|
||||
// 公司维度分组,不强制校验 userId
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
|
||||
$query = Db::table('s2_wechat_group')
|
||||
->where(function ($query) use ($accountId,$companyId) {
|
||||
$query->where('accountId', $accountId)->whereOr('departmentId', $companyId);
|
||||
})
|
||||
->whereIn('groupType',[1,2])
|
||||
->order('groupType desc,sortIndex desc,id desc');
|
||||
$list = $query->select();
|
||||
$query = ChatGroups::where([
|
||||
'companyId' => $companyId,
|
||||
'isDel' => 0,
|
||||
])
|
||||
->order('groupType desc,sort desc,id desc');
|
||||
|
||||
$total = $query->count();
|
||||
$list = $query->select();
|
||||
|
||||
|
||||
// 处理每个好友的数据
|
||||
// 处理每个分组的数据
|
||||
$list = is_array($list) ? $list : $list->toArray();
|
||||
foreach ($list as $k => &$v) {
|
||||
$v['createTime'] = !empty($v['createTime']) ? date('Y-m-d H:i:s', $v['createTime']) : '';
|
||||
}
|
||||
@@ -32,4 +37,237 @@ class WechatGroupController extends BaseController
|
||||
|
||||
return ResponseHelper::success(['list'=>$list,'total'=>$total]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增分组
|
||||
* @return \think\response\Json
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
$groupName = $this->request->param('groupName', '');
|
||||
$groupMemo = $this->request->param('groupMemo', '');
|
||||
$groupType = $this->request->param('groupType', 1);
|
||||
$sort = $this->request->param('sort', 0);
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
|
||||
// 只校验公司维度
|
||||
if (empty($companyId)) {
|
||||
return ResponseHelper::error('请先登录');
|
||||
}
|
||||
|
||||
if (empty($groupName)) {
|
||||
return ResponseHelper::error('分组名称不能为空');
|
||||
}
|
||||
|
||||
// 验证分组类型
|
||||
if (!in_array($groupType, [1, 2])) {
|
||||
return ResponseHelper::error('无效的分组类型');
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$chatGroup = new ChatGroups();
|
||||
$chatGroup->groupName = $groupName;
|
||||
$chatGroup->groupMemo = $groupMemo;
|
||||
$chatGroup->groupType = $groupType;
|
||||
$chatGroup->sort = $sort;
|
||||
$chatGroup->userId = $this->getUserInfo('id');
|
||||
$chatGroup->companyId = $companyId;
|
||||
$chatGroup->createTime = time();
|
||||
$chatGroup->isDel = 0;
|
||||
$chatGroup->save();
|
||||
|
||||
Db::commit();
|
||||
return ResponseHelper::success(['id' => $chatGroup->id], '创建成功');
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
return ResponseHelper::error('创建失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新分组
|
||||
* @return \think\response\Json
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function update()
|
||||
{
|
||||
$id = $this->request->param('id', 0);
|
||||
$groupName = $this->request->param('groupName', '');
|
||||
$groupMemo = $this->request->param('groupMemo', '');
|
||||
$groupType = $this->request->param('groupType', 1);
|
||||
$sort = $this->request->param('sort', 0);
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
|
||||
if (empty($companyId)) {
|
||||
return ResponseHelper::error('请先登录');
|
||||
}
|
||||
|
||||
if (empty($id)) {
|
||||
return ResponseHelper::error('参数缺失');
|
||||
}
|
||||
|
||||
if (empty($groupName)) {
|
||||
return ResponseHelper::error('分组名称不能为空');
|
||||
}
|
||||
|
||||
// 验证分组类型
|
||||
if (!in_array($groupType, [1, 2])) {
|
||||
return ResponseHelper::error('无效的分组类型');
|
||||
}
|
||||
|
||||
// 检查分组是否存在
|
||||
$chatGroup = ChatGroups::where([
|
||||
'id' => $id,
|
||||
'companyId' => $companyId,
|
||||
'isDel' => 0,
|
||||
])->find();
|
||||
|
||||
if (empty($chatGroup)) {
|
||||
return ResponseHelper::error('该分组不存在或已删除');
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$chatGroup->groupName = $groupName;
|
||||
$chatGroup->groupMemo = $groupMemo;
|
||||
$chatGroup->groupType = $groupType;
|
||||
$chatGroup->sort = $sort;
|
||||
$chatGroup->save();
|
||||
|
||||
Db::commit();
|
||||
return ResponseHelper::success('', '更新成功');
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
return ResponseHelper::error('更新失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除分组(假删除)
|
||||
* @return \think\response\Json
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function delete()
|
||||
{
|
||||
$id = $this->request->param('id', 0);
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
|
||||
if (empty($companyId)) {
|
||||
return ResponseHelper::error('请先登录');
|
||||
}
|
||||
|
||||
if (empty($id)) {
|
||||
return ResponseHelper::error('参数缺失');
|
||||
}
|
||||
|
||||
// 检查分组是否存在
|
||||
$chatGroup = ChatGroups::where([
|
||||
'id' => $id,
|
||||
'companyId' => $companyId,
|
||||
'isDel' => 0,
|
||||
])->find();
|
||||
|
||||
if (empty($chatGroup)) {
|
||||
return ResponseHelper::error('该分组不存在或已删除');
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
// 1. 假删除当前分组
|
||||
$chatGroup->isDel = 1;
|
||||
$chatGroup->deleteTime = time();
|
||||
$chatGroup->save();
|
||||
|
||||
// 2. 重置该分组下所有好友的分组ID(s2_wechat_friend.groupIds -> 0)
|
||||
Db::table('s2_wechat_friend')
|
||||
->where('groupIds', $id)
|
||||
->update(['groupIds' => 0]);
|
||||
|
||||
// 3. 重置该分组下所有微信群的分组ID(s2_wechat_chatroom.groupIds -> 0)
|
||||
Db::table('s2_wechat_chatroom')
|
||||
->where('groupIds', $id)
|
||||
->update(['groupIds' => 0]);
|
||||
|
||||
Db::commit();
|
||||
return ResponseHelper::success('', '删除成功');
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
return ResponseHelper::error('删除失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动分组(将好友或群移动到指定分组)
|
||||
* @return \think\response\Json
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function move()
|
||||
{
|
||||
// type: friend 好友, chatroom 群
|
||||
$type = $this->request->param('type', 'friend');
|
||||
$targetId = (int)$this->request->param('groupId', 0);
|
||||
// 仅支持单个ID移动
|
||||
$idParam = $this->request->param('id', 0);
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
|
||||
if (empty($companyId)) {
|
||||
return ResponseHelper::error('请先登录');
|
||||
}
|
||||
|
||||
if (empty($targetId)) {
|
||||
return ResponseHelper::error('目标分组ID不能为空');
|
||||
}
|
||||
|
||||
// 仅允许单个 ID,禁止批量
|
||||
$moveId = (int)$idParam;
|
||||
if (empty($moveId)) {
|
||||
return ResponseHelper::error('需要移动的ID不能为空');
|
||||
}
|
||||
|
||||
// 校验目标分组是否存在且属于当前公司
|
||||
$targetGroup = ChatGroups::where([
|
||||
'id' => $targetId,
|
||||
'companyId' => $companyId,
|
||||
'isDel' => 0,
|
||||
])->find();
|
||||
|
||||
if (empty($targetGroup)) {
|
||||
return ResponseHelper::error('目标分组不存在或已删除');
|
||||
}
|
||||
|
||||
// 校验分组类型与移动对象类型是否匹配
|
||||
// groupType: 1=好友分组, 2=群分组
|
||||
if ($type === 'friend' && (int)$targetGroup->groupType !== 1) {
|
||||
return ResponseHelper::error('目标分组类型错误(需要好友分组)');
|
||||
}
|
||||
if ($type === 'chatroom' && (int)$targetGroup->groupType !== 2) {
|
||||
return ResponseHelper::error('目标分组类型错误(需要群分组)');
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
if ($type === 'friend') {
|
||||
// 移动单个好友到指定分组:更新 s2_wechat_friend.groupIds
|
||||
Db::table('s2_wechat_friend')
|
||||
->where('id', $moveId)
|
||||
->update(['groupIds' => $targetId]);
|
||||
} elseif ($type === 'chatroom') {
|
||||
// 移动单个群到指定分组:更新 s2_wechat_chatroom.groupIds
|
||||
Db::table('s2_wechat_chatroom')
|
||||
->where('id', $moveId)
|
||||
->update(['groupIds' => $targetId]);
|
||||
} else {
|
||||
Db::rollback();
|
||||
return ResponseHelper::error('无效的类型参数');
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
return ResponseHelper::success('', '移动成功');
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
return ResponseHelper::error('移动失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
16
Server/application/chukebao/model/ChatGroups.php
Normal file
16
Server/application/chukebao/model/ChatGroups.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace app\chukebao\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
class ChatGroups extends Model
|
||||
{
|
||||
protected $pk = 'id';
|
||||
protected $name = 'chat_groups';
|
||||
|
||||
// 不开启自动时间戳,手动维护 createTime / deleteTime
|
||||
protected $autoWriteTimestamp = false;
|
||||
}
|
||||
|
||||
|
||||
413
Server/public/doc/api_v1.md
Normal file
413
Server/public/doc/api_v1.md
Normal file
@@ -0,0 +1,413 @@
|
||||
# 对外获客线索上报接口文档(V1)
|
||||
|
||||
## 一、接口概述
|
||||
|
||||
- **接口名称**:对外获客线索上报接口
|
||||
- **接口用途**:供第三方系统向【存客宝】上报客户线索(手机号 / 微信号等),用于后续的跟进、标签管理和画像分析。
|
||||
- **接口协议**:HTTP
|
||||
- **请求方式**:`POST`
|
||||
- **请求地址**: `http://ckbapi.quwanzhi.com/v1/api/scenarios`
|
||||
|
||||
> 具体 URL 以实际环境配置为准。
|
||||
|
||||
- **数据格式**:
|
||||
- 推荐:`application/json`
|
||||
- 兼容:`application/x-www-form-urlencoded`
|
||||
- **字符编码**:`UTF-8`
|
||||
|
||||
---
|
||||
|
||||
## 二、鉴权与签名
|
||||
|
||||
### 2.1 必填鉴权字段
|
||||
|
||||
| 字段名 | 类型 | 必填 | 说明 |
|
||||
|-------------|--------|------|---------------------------------------|
|
||||
| `apiKey` | string | 是 | 分配给第三方的接口密钥(每个任务唯一)|
|
||||
| `sign` | string | 是 | 签名值 |
|
||||
| `timestamp` | int | 是 | 秒级时间戳(与服务器时间差不超过 5 分钟) |
|
||||
|
||||
### 2.2 时间戳校验
|
||||
|
||||
服务器会校验 `timestamp` 是否在当前时间前后 **5 分钟** 内:
|
||||
|
||||
- 通过条件:`|server_time - timestamp| <= 300`
|
||||
- 超出范围则返回:`请求已过期`
|
||||
|
||||
### 2.3 签名生成规则
|
||||
|
||||
接口采用自定义签名机制。**签名字段为 `sign`,生成步骤如下:**
|
||||
|
||||
假设本次请求的所有参数为 `params`,其中包括业务参数 + `apiKey` + `timestamp` + `sign` + 可能存在的 `portrait` 对象。
|
||||
|
||||
#### 第一步:移除特定字段
|
||||
|
||||
从 `params` 中移除以下字段:
|
||||
|
||||
- `sign` —— 自身不参与签名
|
||||
- `apiKey` —— 不参与参数拼接,仅在最后一步参与二次 MD5
|
||||
- `portrait` —— 整个画像对象不参与签名(即使内部还有子字段)
|
||||
|
||||
> 说明:`portrait` 通常是一个 JSON 对象,字段较多,为避免签名实现复杂且双方难以对齐,统一不参与签名。
|
||||
|
||||
#### 第二步:移除空值字段
|
||||
|
||||
从剩余参数中,移除值为:
|
||||
|
||||
- `null`
|
||||
- 空字符串 `''`
|
||||
|
||||
的字段,这些字段不参与签名。
|
||||
|
||||
#### 第三步:按参数名升序排序
|
||||
|
||||
对剩余参数按**参数名(键名)升序排序**,排序规则为标准的 ASCII 升序:
|
||||
|
||||
```text
|
||||
例如: name, phone, source, timestamp
|
||||
```
|
||||
|
||||
#### 第四步:拼接参数值
|
||||
|
||||
将排序后的参数 **只取“值”**,按顺序直接拼接为一个字符串,中间不加任何分隔符:
|
||||
|
||||
- 示例:
|
||||
排序后参数为:
|
||||
|
||||
```text
|
||||
name = 张三
|
||||
phone = 13800000000
|
||||
source = 微信广告
|
||||
timestamp = 1710000000
|
||||
```
|
||||
|
||||
则拼接:
|
||||
|
||||
```text
|
||||
stringToSign = "张三13800000000微信广告1710000000"
|
||||
```
|
||||
|
||||
#### 第五步:第一次 MD5
|
||||
|
||||
对上一步拼接得到的字符串做一次 MD5:
|
||||
|
||||
\[
|
||||
\text{firstMd5} = \text{MD5}(\text{stringToSign})
|
||||
\]
|
||||
|
||||
#### 第六步:拼接 apiKey 再次 MD5
|
||||
|
||||
将第一步的结果与 `apiKey` 直接拼接,再做一次 MD5,得到最终签名值:
|
||||
|
||||
\[
|
||||
\text{sign} = \text{MD5}(\text{firstMd5} + \text{apiKey})
|
||||
\]
|
||||
|
||||
#### 第七步:放入请求
|
||||
|
||||
将第六步得到的 `sign` 填入请求参数中的 `sign` 字段即可。
|
||||
|
||||
> 建议:
|
||||
> - 使用小写 MD5 字符串(双方约定统一即可)。
|
||||
> - 请确保参与签名的参数与最终请求发送的参数一致(包括是否传空值)。
|
||||
|
||||
### 2.4 签名示例(PHP 伪代码)
|
||||
|
||||
```php
|
||||
$params = [
|
||||
'apiKey' => 'YOUR_API_KEY',
|
||||
'timestamp' => '1710000000',
|
||||
'phone' => '13800000000',
|
||||
'name' => '张三',
|
||||
'source' => '微信广告',
|
||||
'remark' => '通过H5落地页留资',
|
||||
// 'portrait' => [...], // 如有画像,这里会存在,但不参与签名
|
||||
// 'sign' => '待生成',
|
||||
];
|
||||
|
||||
// 1. 去掉 sign、apiKey、portrait
|
||||
unset($params['sign'], $params['apiKey'], $params['portrait']);
|
||||
|
||||
// 2. 去掉空值
|
||||
$params = array_filter($params, function($value) {
|
||||
return !is_null($value) && $value !== '';
|
||||
});
|
||||
|
||||
// 3. 按键名升序排序
|
||||
ksort($params);
|
||||
|
||||
// 4. 拼接参数值
|
||||
$stringToSign = implode('', array_values($params));
|
||||
|
||||
// 5. 第一次 MD5
|
||||
$firstMd5 = md5($stringToSign);
|
||||
|
||||
// 6. 第二次 MD5(拼接 apiKey)
|
||||
$apiKey = 'YOUR_API_KEY';
|
||||
$sign = md5($firstMd5 . $apiKey);
|
||||
|
||||
// 将 $sign 作为字段发送
|
||||
$params['sign'] = $sign;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、请求参数说明
|
||||
|
||||
### 3.1 主标识字段(至少传一个)
|
||||
|
||||
| 字段名 | 类型 | 必填 | 说明 |
|
||||
|-----------|--------|------|-------------------------------------------|
|
||||
| `wechatId`| string | 否 | 微信号,存在时优先作为主标识 |
|
||||
| `phone` | string | 否 | 手机号,当 `wechatId` 为空时用作主标识 |
|
||||
|
||||
### 3.2 基础信息字段
|
||||
|
||||
| 字段名 | 类型 | 必填 | 说明 |
|
||||
|------------|--------|------|-------------------------|
|
||||
| `name` | string | 否 | 客户姓名 |
|
||||
| `source` | string | 否 | 线索来源描述,如“百度推广”、“抖音直播间” |
|
||||
| `remark` | string | 否 | 备注信息 |
|
||||
| `tags` | string | 否 | 逗号分隔的“微信标签”,如:`"高意向,电商,女装"` |
|
||||
| `siteTags` | string | 否 | 逗号分隔的“站内标签”,用于站内进一步细分 |
|
||||
|
||||
|
||||
### 3.3 用户画像字段 `portrait`(可选)
|
||||
|
||||
`portrait` 为一个对象(JSON),用于记录用户的行为画像数据。
|
||||
|
||||
#### 3.3.1 基本示例
|
||||
|
||||
```json
|
||||
"portrait": {
|
||||
"type": 1,
|
||||
"source": 1,
|
||||
"sourceData": {
|
||||
"age": 28,
|
||||
"gender": "female",
|
||||
"city": "上海",
|
||||
"productId": "P12345",
|
||||
"pageUrl": "https://example.com/product/123"
|
||||
},
|
||||
"remark": "画像-基础属性",
|
||||
"uniqueId": "user_13800000000_20250301_001"
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3.2 字段详细说明
|
||||
|
||||
| 字段名 | 类型 | 必填 | 说明 |
|
||||
|-----------------------|--------|------|----------------------------------------|
|
||||
| `portrait.type` | int | 否 | 画像类型,枚举值:<br>0-浏览<br>1-点击<br>2-下单/购买<br>3-注册<br>4-互动<br>默认值:0 |
|
||||
| `portrait.source` | int | 否 | 画像来源,枚举值:<br>0-本站<br>1-老油条<br>2-老坑爹<br>默认值:0 |
|
||||
| `portrait.sourceData` | object | 否 | 画像明细数据(键值对,会存储为 JSON 格式)<br>可包含任意业务相关的键值对,如:年龄、性别、城市、商品ID、页面URL等 |
|
||||
| `portrait.remark` | string | 否 | 画像备注信息,最大长度100字符 |
|
||||
| `portrait.uniqueId` | string | 否 | 画像去重用唯一 ID<br>用于防止重复记录,相同 `uniqueId` 的画像数据在半小时内会被合并统计(count字段累加)<br>建议格式:`{来源标识}_{用户标识}_{时间戳}_{序号}` |
|
||||
|
||||
#### 3.3.3 画像类型(type)说明
|
||||
|
||||
| 值 | 类型 | 说明 | 适用场景 |
|
||||
|---|------|------|---------|
|
||||
| 0 | 浏览 | 用户浏览了页面或内容 | 页面访问、商品浏览、文章阅读等 |
|
||||
| 1 | 点击 | 用户点击了某个元素 | 按钮点击、链接点击、广告点击等 |
|
||||
| 2 | 下单/购买 | 用户完成了购买行为 | 订单提交、支付完成等 |
|
||||
| 3 | 注册 | 用户完成了注册 | 账号注册、会员注册等 |
|
||||
| 4 | 互动 | 用户进行了互动行为 | 点赞、评论、分享、咨询等 |
|
||||
|
||||
#### 3.3.4 画像来源(source)说明
|
||||
|
||||
| 值 | 来源 | 说明 |
|
||||
|---|------|------|
|
||||
| 0 | 本站 | 来自本站的数据 |
|
||||
| 1 | 老油条 | 来自"老油条"系统的数据 |
|
||||
| 2 | 老坑爹 | 来自"老坑爹"系统的数据 |
|
||||
|
||||
#### 3.3.5 sourceData 数据格式说明
|
||||
|
||||
`sourceData` 是一个 JSON 对象,可以包含任意业务相关的键值对。常见字段示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"age": 28,
|
||||
"gender": "female",
|
||||
"city": "上海",
|
||||
"province": "上海市",
|
||||
"productId": "P12345",
|
||||
"productName": "商品名称",
|
||||
"category": "女装",
|
||||
"price": 299.00,
|
||||
"pageUrl": "https://example.com/product/123",
|
||||
"referrer": "https://www.baidu.com",
|
||||
"device": "mobile",
|
||||
"browser": "WeChat"
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:
|
||||
> - `sourceData` 中的数据类型可以是字符串、数字、布尔值等
|
||||
> - 嵌套对象会被序列化为 JSON 字符串存储
|
||||
> - 建议根据实际业务需求定义字段结构
|
||||
|
||||
#### 3.3.6 uniqueId 去重机制说明
|
||||
|
||||
- **作用**:防止重复记录相同的画像数据
|
||||
- **规则**:相同 `uniqueId` 的画像数据在 **半小时内** 会被合并统计,`count` 字段会自动累加
|
||||
- **建议格式**:`{来源标识}_{用户标识}_{时间戳}_{序号}`
|
||||
- 示例:`site_13800000000_1710000000_001`
|
||||
- 示例:`wechat_wxid_abc123_1710000000_001`
|
||||
- **注意事项**:
|
||||
- 如果不传 `uniqueId`,系统会为每条画像数据创建新记录
|
||||
- 如果需要在半小时内多次统计同一行为,应使用相同的 `uniqueId`
|
||||
- 如果需要在半小时后重新统计,应使用不同的 `uniqueId`(建议修改时间戳部分)
|
||||
|
||||
> **重要提示**:`portrait` **整体不参与签名计算**,但会参与业务处理。系统会根据 `uniqueId` 自动处理去重和统计。
|
||||
|
||||
---
|
||||
|
||||
## 四、请求示例
|
||||
|
||||
### 4.1 JSON 请求示例(无画像)
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": "YOUR_API_KEY",
|
||||
"timestamp": 1710000000,
|
||||
"phone": "13800000000",
|
||||
"name": "张三",
|
||||
"source": "微信广告",
|
||||
"remark": "通过H5落地页留资",
|
||||
"tags": "高意向,电商",
|
||||
"siteTags": "新客,女装",
|
||||
"sign": "根据签名规则生成的MD5字符串"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 JSON 请求示例(带微信号与画像)
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": "YOUR_API_KEY",
|
||||
"timestamp": 1710000000,
|
||||
"wechatId": "wxid_abcdefg123",
|
||||
"phone": "13800000001",
|
||||
"name": "李四",
|
||||
"source": "小程序落地页",
|
||||
"remark": "点击【立即咨询】按钮",
|
||||
"tags": "中意向,直播",
|
||||
"siteTags": "复购,高客单",
|
||||
"portrait": {
|
||||
"type": 1,
|
||||
"source": 0,
|
||||
"sourceData": {
|
||||
"age": 28,
|
||||
"gender": "female",
|
||||
"city": "上海",
|
||||
"pageUrl": "https://example.com/product/123",
|
||||
"productId": "P12345"
|
||||
},
|
||||
"remark": "画像-点击行为",
|
||||
"uniqueId": "site_13800000001_1710000000_001"
|
||||
},
|
||||
"sign": "根据签名规则生成的MD5字符串"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 JSON 请求示例(多种画像类型)
|
||||
|
||||
#### 4.3.1 浏览行为画像
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": "YOUR_API_KEY",
|
||||
"timestamp": 1710000000,
|
||||
"phone": "13800000002",
|
||||
"name": "王五",
|
||||
"source": "百度推广",
|
||||
"portrait": {
|
||||
"type": 0,
|
||||
"source": 0,
|
||||
"sourceData": {
|
||||
"pageUrl": "https://example.com/product/456",
|
||||
"productName": "商品名称",
|
||||
"category": "女装",
|
||||
"stayTime": 120,
|
||||
"device": "mobile"
|
||||
},
|
||||
"remark": "商品浏览",
|
||||
"uniqueId": "site_13800000002_1710000000_001"
|
||||
},
|
||||
"sign": "根据签名规则生成的MD5字符串"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、响应说明
|
||||
|
||||
### 5.1 成功响应
|
||||
|
||||
**1)新增线索成功**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "新增成功",
|
||||
"data": "13800000000"
|
||||
}
|
||||
```
|
||||
|
||||
**2)线索已存在**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "已存在",
|
||||
"data": "13800000000"
|
||||
}
|
||||
```
|
||||
|
||||
> `data` 字段返回本次线索的主标识 `wechatId` 或 `phone`。
|
||||
|
||||
### 5.2 常见错误响应
|
||||
|
||||
```json
|
||||
{ "code": 400, "message": "apiKey不能为空", "data": null }
|
||||
{ "code": 400, "message": "sign不能为空", "data": null }
|
||||
{ "code": 400, "message": "timestamp不能为空", "data": null }
|
||||
{ "code": 400, "message": "请求已过期", "data": null }
|
||||
|
||||
{ "code": 401, "message": "无效的apiKey", "data": null }
|
||||
{ "code": 401, "message": "签名验证失败", "data": null }
|
||||
|
||||
{ "code": 500, "message": "系统错误: 具体错误信息", "data": null }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 六、常见问题(FAQ)
|
||||
|
||||
### Q1: 如果同一个用户多次上报相同的行为,会如何处理?
|
||||
|
||||
**A**: 如果使用相同的 `uniqueId`,系统会在半小时内合并统计,`count` 字段会累加。如果使用不同的 `uniqueId`,会创建多条记录。
|
||||
|
||||
### Q2: portrait 字段是否必须传递?
|
||||
|
||||
**A**: 不是必须的。`portrait` 字段是可选的,只有在需要记录用户画像数据时才传递。
|
||||
|
||||
### Q3: sourceData 中可以存储哪些类型的数据?
|
||||
|
||||
**A**: `sourceData` 是一个 JSON 对象,可以存储任意键值对。支持字符串、数字、布尔值等基本类型,嵌套对象会被序列化为 JSON 字符串。
|
||||
|
||||
### Q4: uniqueId 的作用是什么?
|
||||
|
||||
**A**: `uniqueId` 用于防止重复记录。相同 `uniqueId` 的画像数据在半小时内会被合并统计,避免重复数据。
|
||||
|
||||
### Q5: 画像数据如何与用户关联?
|
||||
|
||||
**A**: 系统会根据请求中的 `wechatId` 或 `phone` 自动匹配 `traffic_pool` 表中的用户,并将画像数据关联到对应的 `trafficPoolId`。
|
||||
|
||||
---
|
||||
Reference in New Issue
Block a user