From 242065d2980d17fcd1e363b76e57774da4df5d3e Mon Sep 17 00:00:00 2001 From: wong <106998207@qq.com> Date: Tue, 2 Dec 2025 17:47:22 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E5=88=86=E7=BB=84?= =?UTF-8?q?=E5=8F=8A=E6=B6=88=E6=81=AF=E7=BD=AE=E9=A1=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Server/application/chukebao/config/route.php | 8 +- .../chukebao/controller/DataProcessing.php | 36 +++ .../chukebao/controller/MessageController.php | 11 +- .../controller/WechatGroupController.php | 264 +++++++++++++++++- .../application/chukebao/model/ChatGroups.php | 16 ++ 5 files changed, 316 insertions(+), 19 deletions(-) create mode 100644 Server/application/chukebao/model/ChatGroups.php diff --git a/Server/application/chukebao/config/route.php b/Server/application/chukebao/config/route.php index 56e17f51..ce1a477b 100644 --- a/Server/application/chukebao/config/route.php +++ b/Server/application/chukebao/config/route.php @@ -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'); // 移动分组(好友/群移动到指定分组) + }); diff --git a/Server/application/chukebao/controller/DataProcessing.php b/Server/application/chukebao/controller/DataProcessing.php index fdde86b7..af8070ca 100644 --- a/Server/application/chukebao/controller/DataProcessing.php +++ b/Server/application/chukebao/controller/DataProcessing.php @@ -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); } diff --git a/Server/application/chukebao/controller/MessageController.php b/Server/application/chukebao/controller/MessageController.php index 93f491a9..5f59e1e0 100644 --- a/Server/application/chukebao/controller/MessageController.php +++ b/Server/application/chukebao/controller/MessageController.php @@ -20,7 +20,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 +31,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 +43,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 @@ -122,6 +122,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 +137,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 +151,7 @@ class MessageController extends BaseController 'wechatTime' => $wechatTime ]; - unset($v['wechatFriendId'], $v['wechatChatroomId']); + unset($v['wechatFriendId'], $v['wechatChatroomId'],$v['isTop']); } unset($v); diff --git a/Server/application/chukebao/controller/WechatGroupController.php b/Server/application/chukebao/controller/WechatGroupController.php index 0ee08b91..8ff0b75f 100644 --- a/Server/application/chukebao/controller/WechatGroupController.php +++ b/Server/application/chukebao/controller/WechatGroupController.php @@ -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()); + } + } } \ No newline at end of file diff --git a/Server/application/chukebao/model/ChatGroups.php b/Server/application/chukebao/model/ChatGroups.php new file mode 100644 index 00000000..b9a84fba --- /dev/null +++ b/Server/application/chukebao/model/ChatGroups.php @@ -0,0 +1,16 @@ + Date: Wed, 3 Dec 2025 14:06:07 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E6=B6=88=E6=81=AF=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chukebao/controller/MessageController.php | 142 +++++++++++++++++- 1 file changed, 138 insertions(+), 4 deletions(-) diff --git a/Server/application/chukebao/controller/MessageController.php b/Server/application/chukebao/controller/MessageController.php index 5f59e1e0..9008fb00 100644 --- a/Server/application/chukebao/controller/MessageController.php +++ b/Server/application/chukebao/controller/MessageController.php @@ -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() { @@ -64,7 +76,6 @@ class MessageController extends BaseController return $b['wechatTime'] <=> $a['wechatTime']; }); - // 批量统计未读数量(isRead=0),按好友/群聊分别聚合 $friendIds = []; $chatroomIds = []; @@ -219,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()); + } + } } \ No newline at end of file From 5bb5aa3d5a53365e8951861a3a6928af1bef81b3 Mon Sep 17 00:00:00 2001 From: wong <106998207@qq.com> Date: Wed, 3 Dec 2025 16:18:10 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/scenarios/plan/list/planApi.tsx | 213 ++++++--- Cunkebao/src/utils/apiUrl.ts | 5 +- Server/public/doc/api_v1.md | 413 ++++++++++++++++++ 3 files changed, 559 insertions(+), 72 deletions(-) create mode 100644 Server/public/doc/api_v1.md diff --git a/Cunkebao/src/pages/mobile/scenarios/plan/list/planApi.tsx b/Cunkebao/src/pages/mobile/scenarios/plan/list/planApi.tsx index ee67b59c..b365abce 100644 --- a/Cunkebao/src/pages/mobile/scenarios/plan/list/planApi.tsx +++ b/Cunkebao/src/pages/mobile/scenarios/plan/list/planApi.tsx @@ -70,24 +70,72 @@ const PlanApi: React.FC = ({ // 处理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 = ({ const renderConfigTab = () => (
- {/* API密钥配置 */} + {/* 鉴权参数配置 */}
@@ -122,7 +170,7 @@ const PlanApi: React.FC = ({
- {/* 接口地址配置 */} + {/* 接口地址与参数说明 */}
@@ -150,27 +198,42 @@ const PlanApi: React.FC = ({ {/* 参数说明 */}
-

必要参数

+

鉴权参数(必填)

- name - 客户姓名 + apiKey - 分配给第三方的接口密钥(每个任务唯一)
- phone - 手机号码 + sign - 签名值,按文档的签名规则生成 +
+
+ timestamp - 秒级时间戳(与服务器时间差不超过 5 分钟)
-

可选参数

+

业务参数

- source - 来源标识 + wechatId - 微信号,存在时优先作为主标识 +
+
+ phone - 手机号,当 wechatId 为空时用作主标识 +
+
+ name - 客户姓名 +
+
+ source - 线索来源描述,如“百度推广”、“抖音直播间”
remark - 备注信息
- tags - 客户标签 + tags - 微信标签,逗号分隔,如 "高意向,电商,女装" +
+
+ siteTags - 站内标签,逗号分隔,用于站内进一步细分
@@ -179,32 +242,34 @@ const PlanApi: React.FC = ({
); - const renderQuickTestTab = () => ( -
-
-

快速测试URL

-
- -
-
- - + const renderQuickTestTab = () => { + return ( +
+
+

快速测试 URL(GET 示例)

+
+ +
+
+ + +
-
- ); + ); + }; const renderDocsTab = () => (
@@ -213,15 +278,8 @@ const PlanApi: React.FC = ({
-

完整API文档

-

详细的接口说明和参数文档

- - -
- -
-

集成指南

-

第三方平台集成教程

+

对外获客线索上报接口文档(V1)

+

@@ -229,43 +287,63 @@ const PlanApi: React.FC = ({ 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: ` '张三', + '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 +357,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 +471,7 @@ HttpResponse response = client.send(request, HttpResponse.BodyHandlers.o 所有数据传输均采用HTTPS加密
-
diff --git a/Cunkebao/src/utils/apiUrl.ts b/Cunkebao/src/utils/apiUrl.ts index a6a7fd92..02b5f502 100644 --- a/Cunkebao/src/utils/apiUrl.ts +++ b/Cunkebao/src/utils/apiUrl.ts @@ -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("/")) { diff --git a/Server/public/doc/api_v1.md b/Server/public/doc/api_v1.md new file mode 100644 index 00000000..95c8643d --- /dev/null +++ b/Server/public/doc/api_v1.md @@ -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 | 否 | 画像类型,枚举值:
0-浏览
1-点击
2-下单/购买
3-注册
4-互动
默认值:0 | +| `portrait.source` | int | 否 | 画像来源,枚举值:
0-本站
1-老油条
2-老坑爹
默认值:0 | +| `portrait.sourceData` | object | 否 | 画像明细数据(键值对,会存储为 JSON 格式)
可包含任意业务相关的键值对,如:年龄、性别、城市、商品ID、页面URL等 | +| `portrait.remark` | string | 否 | 画像备注信息,最大长度100字符 | +| `portrait.uniqueId` | string | 否 | 画像去重用唯一 ID
用于防止重复记录,相同 `uniqueId` 的画像数据在半小时内会被合并统计(count字段累加)
建议格式:`{来源标识}_{用户标识}_{时间戳}_{序号}` | + +#### 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`。 + +--- From 58377b46700ef7b3b3c6891092ca30d5f60c71bc Mon Sep 17 00:00:00 2001 From: wong <106998207@qq.com> Date: Wed, 3 Dec 2025 16:29:40 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/scenarios/plan/list/planApi.tsx | 47 ++++++++++++++----- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/Cunkebao/src/pages/mobile/scenarios/plan/list/planApi.tsx b/Cunkebao/src/pages/mobile/scenarios/plan/list/planApi.tsx index b365abce..f2f84642 100644 --- a/Cunkebao/src/pages/mobile/scenarios/plan/list/planApi.tsx +++ b/Cunkebao/src/pages/mobile/scenarios/plan/list/planApi.tsx @@ -271,19 +271,42 @@ const PlanApi: React.FC = ({ ); }; - const renderDocsTab = () => ( -
-
- -
- -
-

对外获客线索上报接口文档(V1)

-

-
+ const renderDocsTab = () => { + const docUrl = `${import.meta.env.VITE_API_BASE_URL}/doc/api_v1.md`; + + return ( +
+
+ +
+ +
+

对外获客线索上报接口文档(V1)

+

点击下方按钮可直接在浏览器中打开最新的远程文档。

+
+ + +
+
+
-
- ); + ); + }; const renderCodeTab = () => { const codeExamples = {