diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/api.ts b/Cunkebao/src/pages/mobile/mine/traffic-pool/api.ts index 588e0a4c8..21722094b 100644 --- a/Cunkebao/src/pages/mobile/mine/traffic-pool/api.ts +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/api.ts @@ -313,7 +313,9 @@ export async function getPoolDetail(id: number): Promise< allotRecords: AllotRecord[]; } > { - return request("/v1/traffic/pool/v2/detail", { id }, "GET"); + return request("/v1/traffic/pool/v2/detail", { id }, "GET", { + timeout: 0, // 去除超时限制 + }); } /** @@ -403,6 +405,59 @@ export async function removeTag( ); } +/** + * 从标签引擎同步用户标签 + */ +export async function syncTagsFromEngine( + poolCompanyId: number, +): Promise<{ syncedCount: number; skippedCount: number; total: number }> { + return request( + "/v1/traffic/pool/v2/tag/sync-from-engine", + { poolCompanyId }, + "POST", + { + timeout: 0, // 去除超时限制 + }, + ); +} + +// ==================== 来源和行为相关 API ==================== + +/** + * 分页获取流量来源 + */ +export async function getPoolSources(params: { + poolCompanyId: number; + page?: number; + pageSize?: number; + keyword?: string; +}): Promise<{ + list: TrafficSource[]; + total: number; + page: number; + pageSize: number; +}> { + return request("/v1/traffic/pool/v2/sources", params, "GET"); +} + +/** + * 分页获取流量行为轨迹 + */ +export async function getPoolBehaviors(params: { + poolCompanyId: number; + page?: number; + pageSize?: number; + keyword?: string; + behaviorType?: number; +}): Promise<{ + list: TrafficBehavior[]; + total: number; + page: number; + pageSize: number; +}> { + return request("/v1/traffic/pool/v2/behaviors", params, "GET"); +} + // ==================== 分配相关 API ==================== /** @@ -533,4 +588,3 @@ export const TAG_TYPE_TEXT: Record = { [TAG_TYPE.SITE]: "站内标签", [TAG_TYPE.AI]: "AI标签", }; - diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/index.module.scss b/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/index.module.scss index 4112cea20..b15f7316b 100644 --- a/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/index.module.scss +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/index.module.scss @@ -24,6 +24,13 @@ .icon { font-size: 16px; } + + .sourceCount { + font-size: 12px; + font-weight: normal; + color: #999; + margin-left: 4px; + } } .sectionContent { @@ -184,7 +191,35 @@ padding: 12px 16px; } -// 编辑按钮 +// 标题操作区域 +.titleActions { + margin-left: auto; + display: flex; + align-items: center; + gap: 12px; +} + +// 操作按钮 +.actionBtn { + font-size: 13px; + font-weight: normal; + color: #1890ff; + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; + + &:active { + opacity: 0.7; + } + + &.loading { + color: #999; + cursor: not-allowed; + } +} + +// 编辑按钮(保留兼容) .editBtn { margin-left: auto; font-size: 13px; @@ -349,6 +384,34 @@ // 来源信息 .sourceList { + max-height: 300px; // 固定高度 + overflow-y: auto; // 可滚动 + padding-right: 4px; // 为滚动条留出空间 + + // 滚动条样式 + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-track { + background: #f5f5f5; + border-radius: 2px; + } + + &::-webkit-scrollbar-thumb { + background: #d9d9d9; + border-radius: 2px; + + &:hover { + background: #bfbfbf; + } + } + + // 展开状态:不限制高度 + &.sourceListExpanded { + max-height: 500px; // 展开时更大的高度 + } + .sourceItem { display: flex; align-items: flex-start; @@ -429,3 +492,31 @@ } } } + +// 搜索框包装器 +.searchBarWrapper { + margin-bottom: 12px; + padding: 0 4px; +} + +// 显示更多按钮 +.showMoreBtn { + text-align: center; + padding: 12px 16px; + color: #1890ff; + font-size: 14px; + cursor: pointer; + border-top: 1px solid #f0f0f0; + margin-top: 8px; + user-select: none; + + &:active { + opacity: 0.7; + background: #f5f5f5; + } + + &.loading { + color: #999; + cursor: not-allowed; + } +} diff --git a/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/index.tsx b/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/index.tsx index e1a0d5c59..ef515d3d8 100644 --- a/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/index.tsx +++ b/Cunkebao/src/pages/mobile/mine/traffic-pool/detail/index.tsx @@ -1,7 +1,7 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; import { useParams } from "react-router-dom"; import Layout from "@/components/Layout/Layout"; -import { Avatar, Empty, Popup, CheckList, Button as MobileButton } from "antd-mobile"; +import { Avatar, Empty, Popup, CheckList, Button as MobileButton, SearchBar } from "antd-mobile"; import { Spin, message } from "antd"; import { UserOutlined, @@ -13,6 +13,7 @@ import { TrophyOutlined, EditOutlined, CloseCircleOutlined, + SyncOutlined, } from "@ant-design/icons"; import NavCommon from "@/components/NavCommon"; import { fetchUserDetail, addTag, removeTag } from "./api"; @@ -23,6 +24,9 @@ import { INTENTION_LEVEL_TEXT, FRIEND_STATUS_TEXT, getTagDefines, + syncTagsFromEngine, + getPoolSources, + getPoolBehaviors, type TagDefine, } from "../api"; import styles from "./index.module.scss"; @@ -66,6 +70,26 @@ const TrafficPoolDetail: React.FC = () => { const [tagDefines, setTagDefines] = useState([]); const [selectedTagIds, setSelectedTagIds] = useState([]); const [tagLoading, setTagLoading] = useState(false); + const [syncLoading, setSyncLoading] = useState(false); + const [showAllSources, setShowAllSources] = useState(false); // 是否显示全部来源 + + // 来源分页相关状态 + const [sourcesPage, setSourcesPage] = useState(1); + const [sourcesLoading, setSourcesLoading] = useState(false); + const [sourcesTotal, setSourcesTotal] = useState(0); + const [allSources, setAllSources] = useState([]); + const [sourcesKeyword, setSourcesKeyword] = useState(""); // 来源搜索关键词 + const [sourcesSearchInput, setSourcesSearchInput] = useState(""); // 来源搜索输入框实时值 + const sourcesSearchTimer = useRef(null); // 来源搜索防抖定时器 + + // 行为分页相关状态 + const [behaviorsPage, setBehaviorsPage] = useState(1); + const [behaviorsLoading, setBehaviorsLoading] = useState(false); + const [behaviorsTotal, setBehaviorsTotal] = useState(0); + const [allBehaviors, setAllBehaviors] = useState([]); + const [behaviorsKeyword, setBehaviorsKeyword] = useState(""); // 行为搜索关键词 + const [behaviorsSearchInput, setBehaviorsSearchInput] = useState(""); // 行为搜索输入框实时值 + const behaviorsSearchTimer = useRef(null); // 行为搜索防抖定时器 useEffect(() => { if (id) { @@ -75,10 +99,63 @@ const TrafficPoolDetail: React.FC = () => { const loadDetail = async () => { if (!id) return; + // 如果有搜索关键词,不重置搜索状态 + if (sourcesKeyword || behaviorsKeyword) { + return; + } + setLoading(true); try { const res = await fetchUserDetail(parseInt(id)); setDetail(res); + // 初始化来源和行为数据(详情接口返回的前50条) + const initialSources = res.sources || []; + const initialBehaviors = res.behaviors || []; + setAllSources(initialSources); + setAllBehaviors(initialBehaviors); + + // 如果返回了50条,说明可能还有更多,需要获取总数 + // 详情接口返回了50条,相当于分页接口的page=1-3(每页20条) + // 所以分页应该从page=4开始(如果总数>50) + if (initialSources.length >= 50) { + try { + const sourcesResult = await getPoolSources({ + poolCompanyId: parseInt(id), + page: 1, + pageSize: 1, // 只需要获取总数 + }); + setSourcesTotal(sourcesResult.total); + // 详情接口返回了50条,相当于分页接口的前3页(每页20条),所以从第4页开始 + setSourcesPage(3); // 已加载3页(50条) + } catch (e) { + // 如果获取失败,使用已加载的数量 + setSourcesTotal(initialSources.length); + setSourcesPage(1); + } + } else { + setSourcesTotal(initialSources.length); + setSourcesPage(1); + } + + if (initialBehaviors.length >= 50) { + try { + const behaviorsResult = await getPoolBehaviors({ + poolCompanyId: parseInt(id), + page: 1, + pageSize: 1, // 只需要获取总数 + }); + setBehaviorsTotal(behaviorsResult.total); + // 详情接口返回了50条,相当于分页接口的前3页(每页20条),所以从第4页开始 + setBehaviorsPage(3); // 已加载3页(50条) + } catch (e) { + // 如果获取失败,使用已加载的数量 + setBehaviorsTotal(initialBehaviors.length); + setBehaviorsPage(1); + } + } else { + setBehaviorsTotal(initialBehaviors.length); + setBehaviorsPage(1); + } } catch (error: any) { message.error(error?.message || "获取详情失败"); } finally { @@ -86,6 +163,137 @@ const TrafficPoolDetail: React.FC = () => { } }; + // 搜索来源(带防抖) + const handleSourcesSearchChange = (value: string) => { + setSourcesSearchInput(value); + + // 清除之前的定时器 + if (sourcesSearchTimer.current) { + clearTimeout(sourcesSearchTimer.current); + } + + // 设置新的定时器,500ms 后执行搜索 + sourcesSearchTimer.current = setTimeout(() => { + setSourcesKeyword(value); + setSourcesPage(1); // 搜索时重置到第一页 + setShowAllSources(true); // 搜索时自动展开 + }, 500); + }; + + // 搜索来源数据 + const searchSources = async (keyword: string, page: number = 1) => { + if (!id) return; + setSourcesLoading(true); + try { + const result = await getPoolSources({ + poolCompanyId: parseInt(id), + page, + pageSize: keyword ? 100 : 50, // 搜索时每次100条,非搜索时50条 + keyword, + }); + + if (page === 1) { + // 第一页,替换数据 + setAllSources(result.list); + } else { + // 后续页,合并数据(去重) + const existingIds = new Set(allSources.map((s: any) => s.id)); + const newSources = result.list.filter((s: any) => !existingIds.has(s.id)); + setAllSources([...allSources, ...newSources]); + } + + setSourcesTotal(result.total); + setSourcesPage(page); + } catch (error: any) { + message.error(error?.message || "搜索失败"); + } finally { + setSourcesLoading(false); + } + }; + + // 加载更多来源 + const loadMoreSources = async () => { + if (!id || sourcesLoading) return; + const nextPage = sourcesPage + 1; + await searchSources(sourcesKeyword, nextPage); + }; + + // 监听搜索关键词变化 + useEffect(() => { + if (id && sourcesKeyword !== undefined && sourcesKeyword !== "") { + searchSources(sourcesKeyword, 1); + } else if (id && sourcesKeyword === "") { + // 清空搜索时,重新加载详情 + loadDetail(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sourcesKeyword]); + + // 搜索行为(带防抖) + const handleBehaviorsSearchChange = (value: string) => { + setBehaviorsSearchInput(value); + + // 清除之前的定时器 + if (behaviorsSearchTimer.current) { + clearTimeout(behaviorsSearchTimer.current); + } + + // 设置新的定时器,500ms 后执行搜索 + behaviorsSearchTimer.current = setTimeout(() => { + setBehaviorsKeyword(value); + setBehaviorsPage(1); // 搜索时重置到第一页 + }, 500); + }; + + // 搜索行为数据 + const searchBehaviors = async (keyword: string, page: number = 1) => { + if (!id) return; + setBehaviorsLoading(true); + try { + const result = await getPoolBehaviors({ + poolCompanyId: parseInt(id), + page, + pageSize: keyword ? 100 : 50, // 搜索时每次100条,非搜索时50条 + keyword, + }); + + if (page === 1) { + // 第一页,替换数据 + setAllBehaviors(result.list); + } else { + // 后续页,合并数据(去重) + const existingIds = new Set(allBehaviors.map((b: any) => b.id)); + const newBehaviors = result.list.filter((b: any) => !existingIds.has(b.id)); + setAllBehaviors([...allBehaviors, ...newBehaviors]); + } + + setBehaviorsTotal(result.total); + setBehaviorsPage(page); + } catch (error: any) { + message.error(error?.message || "搜索失败"); + } finally { + setBehaviorsLoading(false); + } + }; + + // 加载更多行为 + const loadMoreBehaviors = async () => { + if (!id || behaviorsLoading) return; + const nextPage = behaviorsPage + 1; + await searchBehaviors(behaviorsKeyword, nextPage); + }; + + // 监听搜索关键词变化 + useEffect(() => { + if (id && behaviorsKeyword !== undefined && behaviorsKeyword !== "") { + searchBehaviors(behaviorsKeyword, 1); + } else if (id && behaviorsKeyword === "") { + // 清空搜索时,重新加载详情 + loadDetail(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [behaviorsKeyword]); + // 打开标签编辑弹窗 const openTagModal = async () => { setTagModalVisible(true); @@ -155,6 +363,21 @@ const TrafficPoolDetail: React.FC = () => { } }; + // 从标签引擎同步标签 + const handleSyncTags = async () => { + if (!id) return; + setSyncLoading(true); + try { + const result = await syncTagsFromEngine(parseInt(id)); + message.success(`同步完成:已同步 ${result.syncedCount} 个标签`); + loadDetail(); // 重新加载详情 + } catch (error: any) { + message.error(error?.message || "同步失败"); + } finally { + setSyncLoading(false); + } + }; + // 格式化时间戳 const formatTime = (timestamp: number | null) => { if (!timestamp) return "-"; @@ -339,9 +562,17 @@ const TrafficPoolDetail: React.FC = () => {
标签 - - 编辑 - +
+ + 同步 + + + 编辑 + +
{detail.tags && detail.tags.length > 0 ? (
@@ -421,60 +652,124 @@ const TrafficPoolDetail: React.FC = () => {
来源追溯 + {sourcesTotal > 0 && ( + ({sourcesTotal}) + )}
- {detail.sources && detail.sources.length > 0 ? ( -
- {detail.sources.map((source: any, index: number) => ( -
-
- {sourceIcons[source.sourceType] || "📋"} -
-
-
- {source.sourceTypeName || SOURCE_TYPE_TEXT[source.sourceType] || "未知来源"} - {/* 显示来源名称:优先用 sourceName,群成员来源时用 chatroomInfo,避免重复 */} - {source.sourceType === 2 ? ( - // 群成员来源:优先显示 chatroomInfo 的群名称 - source.chatroomInfo?.chatroomName && ( - - {` - ${source.chatroomInfo.chatroomName}`} - - ) - ) : ( - // 其他来源:显示 sourceName - source.sourceName && ` - ${source.sourceName}` + {/* 搜索框 */} +
+ { + setSourcesSearchInput(""); + setSourcesKeyword(""); + setSourcesPage(1); + setShowAllSources(false); + // 重新加载详情数据 + if (id) { + const loadDetailData = async () => { + try { + const res = await fetchUserDetail(parseInt(id)); + const initialSources = res.sources || []; + setAllSources(initialSources); + setSourcesTotal(initialSources.length); + setSourcesPage(1); + } catch (e) { + // ignore + } + }; + loadDetailData(); + } + }} + /> +
+ {allSources && allSources.length > 0 ? ( + <> +
+ {(showAllSources ? allSources : allSources.slice(0, 5)).map((source: any, index: number) => ( +
+
+ {sourceIcons[source.sourceType] || "📋"} +
+
+
+ {source.sourceTypeName || SOURCE_TYPE_TEXT[source.sourceType] || "未知来源"} + {/* 显示来源名称:优先用 sourceName,群成员来源时用 chatroomInfo,避免重复 */} + {source.sourceType === 2 ? ( + // 群成员来源:优先显示 chatroomInfo 的群名称 + source.chatroomInfo?.chatroomName && ( + + {` - ${source.chatroomInfo.chatroomName}`} + + ) + ) : ( + // 其他来源:显示 sourceName + source.sourceName && ` - ${source.sourceName}` + )} +
+ {/* 显示ID:群ID或好友ID */} + {source.displayId && ( +
+ {source.sourceType === 2 ? '群ID:' : source.sourceType === 1 ? '好友ID:' : 'ID:'} + {source.displayId} +
)} -
- {/* 显示ID:群ID或好友ID */} - {source.displayId && ( -
- {source.sourceType === 2 ? '群ID:' : source.sourceType === 1 ? '好友ID:' : 'ID:'} - {source.displayId} + {/* 群归属客服信息 */} + {source.sourceType === 2 && source.chatroomOwners && source.chatroomOwners.length > 0 && ( +
+ 归属客服: + {source.chatroomOwners.map((owner: any, ownerIndex: number) => ( + + {owner.ownerNickname || owner.ownerAlias || owner.ownerWechatId} + {ownerIndex < source.chatroomOwners.length - 1 && '、'} + + ))} +
+ )} +
+ {source.createTimeFormatted || formatTime(source.createTime)}
- )} - {/* 群归属客服信息 */} - {source.sourceType === 2 && source.chatroomOwners && source.chatroomOwners.length > 0 && ( -
- 归属客服: - {source.chatroomOwners.map((owner: any, ownerIndex: number) => ( - - {owner.ownerNickname || owner.ownerAlias || owner.ownerWechatId} - {ownerIndex < source.chatroomOwners.length - 1 && '、'} - - ))} -
- )} -
- {source.createTimeFormatted || formatTime(source.createTime)}
+ {source.isFirstSource === 1 && ( + 首次 + )}
- {source.isFirstSource === 1 && ( - 首次 - )} + ))} +
+ {allSources.length > 5 && ( +
setShowAllSources(!showAllSources)}> + {showAllSources ? '收起' : `显示更多 (${allSources.length - 5}条)`}
- ))} -
+ )} + {showAllSources && allSources.length < sourcesTotal && ( +
{ + if (!sourcesLoading) { + loadMoreSources(); + } + }} + > + {sourcesLoading ? '加载中...' : `加载更多 (${sourcesTotal - allSources.length}条)`} +
+ )} + {/* 如果已加载50条但总数未知,也显示加载更多 */} + {showAllSources && allSources.length >= 50 && sourcesTotal === allSources.length && ( +
{ + if (!sourcesLoading) { + loadMoreSources(); + } + }} + > + {sourcesLoading ? '加载中...' : '加载更多'} +
+ )} + ) : (
暂无来源记录
)} @@ -486,11 +781,43 @@ const TrafficPoolDetail: React.FC = () => {
行为轨迹 + {behaviorsTotal > 0 && ( + ({behaviorsTotal}) + )}
- {detail.behaviors && detail.behaviors.length > 0 ? ( -
- {detail.behaviors.slice(0, 20).map((behavior: any, index: number) => ( + {/* 搜索框 */} +
+ { + setBehaviorsSearchInput(""); + setBehaviorsKeyword(""); + setBehaviorsPage(1); + // 重新加载详情数据 + if (id) { + const loadDetailData = async () => { + try { + const res = await fetchUserDetail(parseInt(id)); + const initialBehaviors = res.behaviors || []; + setAllBehaviors(initialBehaviors); + setBehaviorsTotal(initialBehaviors.length); + setBehaviorsPage(1); + } catch (e) { + // ignore + } + }; + loadDetailData(); + } + }} + /> +
+ {allBehaviors && allBehaviors.length > 0 ? ( + <> +
+ {allBehaviors.map((behavior: any, index: number) => (
{behaviorIcons[behavior.behaviorType] || "📋"} @@ -506,7 +833,33 @@ const TrafficPoolDetail: React.FC = () => {
))} -
+
+ {allBehaviors.length < behaviorsTotal && ( +
{ + if (!behaviorsLoading) { + loadMoreBehaviors(); + } + }} + > + {behaviorsLoading ? '加载中...' : `加载更多 (${behaviorsTotal - allBehaviors.length}条)`} +
+ )} + {/* 如果已加载50条但总数未知,也显示加载更多 */} + {allBehaviors.length >= 50 && behaviorsTotal === allBehaviors.length && ( +
{ + if (!behaviorsLoading) { + loadMoreBehaviors(); + } + }} + > + {behaviorsLoading ? '加载中...' : '加载更多'} +
+ )} + ) : (
暂无行为记录
)} diff --git a/Server/application/common/model/TrafficPoolBehavior.php b/Server/application/common/model/TrafficPoolBehavior.php index 43c45c6af..4f848fb7a 100644 --- a/Server/application/common/model/TrafficPoolBehavior.php +++ b/Server/application/common/model/TrafficPoolBehavior.php @@ -195,6 +195,46 @@ class TrafficPoolBehavior extends Model ->limit($limit) ->select(); } + + /** + * 分页获取用户行为轨迹 + * @param int $poolCompanyId + * @param int $page 页码 + * @param int $pageSize 每页数量 + * @param string $keyword 搜索关键词(搜索行为名称) + * @param int $behaviorType 行为类型筛选 + * @return array ['list' => [], 'total' => 0, 'page' => 1, 'pageSize' => 10] + */ + public static function getUserJourneyPaginated(int $poolCompanyId, int $page = 1, int $pageSize = 20, string $keyword = '', int $behaviorType = 0): array + { + $query = self::where('poolCompanyId', $poolCompanyId); + + // 关键词搜索 + if (!empty($keyword)) { + $query->where('behaviorName', 'like', '%' . $keyword . '%'); + } + + // 行为类型筛选 + if ($behaviorType > 0) { + $query->where('behaviorType', $behaviorType); + } + + // 统计总数 + $total = $query->count(); + + // 分页查询 + $behaviors = $query->order('behaviorTime DESC') + ->page($page, $pageSize) + ->select() + ->toArray(); + + return [ + 'list' => $behaviors, + 'total' => $total, + 'page' => $page, + 'pageSize' => $pageSize + ]; + } } diff --git a/Server/application/common/model/TrafficPoolSource.php b/Server/application/common/model/TrafficPoolSource.php index 35f7c816a..4ce56e41f 100644 --- a/Server/application/common/model/TrafficPoolSource.php +++ b/Server/application/common/model/TrafficPoolSource.php @@ -135,14 +135,19 @@ class TrafficPoolSource extends Model /** * 获取流量的所有来源(带群归属信息) * @param int $poolCompanyId + * @param int $limit 限制数量,0表示不限制 * @return array */ - public static function getSourcesWithOwners(int $poolCompanyId): array + public static function getSourcesWithOwners(int $poolCompanyId, int $limit = 0): array { - $sources = self::where('poolCompanyId', $poolCompanyId) - ->order('createTime DESC') - ->select() - ->toArray(); + $query = self::where('poolCompanyId', $poolCompanyId) + ->order('createTime DESC'); + + if ($limit > 0) { + $query->limit($limit); + } + + $sources = $query->select()->toArray(); if (empty($sources)) { return []; @@ -325,6 +330,125 @@ class TrafficPoolSource extends Model return $chatroom; } + + /** + * 分页获取流量的来源(带群归属信息) + * @param int $poolCompanyId + * @param int $page 页码 + * @param int $pageSize 每页数量 + * @param string $keyword 搜索关键词(搜索来源名称) + * @return array ['list' => [], 'total' => 0, 'page' => 1, 'pageSize' => 10] + */ + public static function getSourcesWithOwnersPaginated(int $poolCompanyId, int $page = 1, int $pageSize = 20, string $keyword = ''): array + { + $query = self::where('poolCompanyId', $poolCompanyId); + + // 关键词搜索 + if (!empty($keyword)) { + $query->where('sourceName', 'like', '%' . $keyword . '%'); + } + + // 统计总数 + $total = $query->count(); + + // 分页查询 + $sources = $query->order('createTime DESC') + ->page($page, $pageSize) + ->select() + ->toArray(); + + if (empty($sources)) { + return [ + 'list' => [], + 'total' => 0, + 'page' => $page, + 'pageSize' => $pageSize + ]; + } + + // 收集所有群ID + $chatroomIds = []; + foreach ($sources as $source) { + if (!empty($source['sourceChatroomId'])) { + $chatroomIds[] = $source['sourceChatroomId']; + } + } + + // 查询群信息和归属客服 + $chatroomOwners = []; + if (!empty($chatroomIds)) { + $chatroomOwners = self::getChatroomOwners($chatroomIds); + } + + // 组装数据(按来源类型和关键标识去重) + $result = []; + $seenChatroomIds = []; // 用于群成员来源去重 + $seenFriendIds = []; // 用于好友添加来源去重 + + foreach ($sources as $source) { + // 群成员来源去重:同一个群只保留一条记录 + if ($source['sourceType'] == self::SOURCE_TYPE_GROUP_MEMBER && !empty($source['sourceChatroomId'])) { + $chatroomId = $source['sourceChatroomId']; + if (isset($seenChatroomIds[$chatroomId])) { + continue; // 跳过重复的群 + } + $seenChatroomIds[$chatroomId] = true; + } + + // 好友添加来源去重:按来源微信ID或来源名称去重 + if ($source['sourceType'] == self::SOURCE_TYPE_FRIEND_ADD) { + $friendAddKey = $source['sourceWechatId'] ?? ($source['sourceName'] ?? ''); + if (!empty($friendAddKey) && isset($seenFriendIds[$friendAddKey])) { + continue; // 跳过重复的好友添加 + } + $seenFriendIds[$friendAddKey] = true; + } + + $sourceData = $source; + + // 设置显示ID + if ($source['sourceType'] == self::SOURCE_TYPE_GROUP_MEMBER && !empty($source['sourceChatroomId'])) { + $sourceData['displayId'] = "群ID:" . $source['sourceChatroomId']; + } elseif ($source['sourceType'] == self::SOURCE_TYPE_FRIEND_ADD) { + $sourceData['displayId'] = "好友ID:" . ($source['sourceWechatId'] ?? $source['sourceName'] ?? '-'); + } else { + $sourceData['displayId'] = null; + } + + // 格式化时间(兼容时间戳和日期字符串) + if (!empty($source['createTime'])) { + if (is_numeric($source['createTime'])) { + $sourceData['createTimeFormatted'] = date('Y-m-d H:i:s', (int)$source['createTime']); + } else { + $sourceData['createTimeFormatted'] = $source['createTime']; + } + } else { + $sourceData['createTimeFormatted'] = null; + } + + // 添加来源类型名称 + $sourceData['sourceTypeName'] = self::SOURCE_TYPE_NAMES[$source['sourceType']] ?? '未知来源'; + + // 如果是群成员来源,添加群归属信息 + if ($source['sourceType'] == self::SOURCE_TYPE_GROUP_MEMBER && !empty($source['sourceChatroomId'])) { + $chatroomId = $source['sourceChatroomId']; + $sourceData['chatroomOwners'] = $chatroomOwners[$chatroomId] ?? []; + $sourceData['chatroomInfo'] = self::getChatroomInfo($chatroomId); + } else { + $sourceData['chatroomOwners'] = []; + $sourceData['chatroomInfo'] = null; + } + + $result[] = $sourceData; + } + + return [ + 'list' => $result, + 'total' => $total, + 'page' => $page, + 'pageSize' => $pageSize + ]; + } } diff --git a/Server/application/common/service/TagEngineService.php b/Server/application/common/service/TagEngineService.php index ab2581ce5..2d91de6ac 100644 --- a/Server/application/common/service/TagEngineService.php +++ b/Server/application/common/service/TagEngineService.php @@ -14,7 +14,7 @@ class TagEngineService * API基础URL * @var string */ - private $baseUrl = 'http://192.168.1.134:3000'; + private $baseUrl = 'http://192.168.1.40:8080'; /** * API Key diff --git a/Server/application/cunkebao/config/route.php b/Server/application/cunkebao/config/route.php index ba20ffb10..eed3f5094 100644 --- a/Server/application/cunkebao/config/route.php +++ b/Server/application/cunkebao/config/route.php @@ -104,6 +104,7 @@ Route::group('v1/', function () { Route::get('tag/pool-tags', 'app\cunkebao\controller\TrafficPoolV2Controller@getPoolTags'); // 获取流量的标签 Route::post('tag/add', 'app\cunkebao\controller\TrafficPoolV2Controller@addTag'); // 添加标签 Route::delete('tag/remove', 'app\cunkebao\controller\TrafficPoolV2Controller@removeTag'); // 移除标签 + Route::post('tag/sync-from-engine', 'app\cunkebao\controller\TrafficPoolV2Controller@syncTagsFromEngine'); // 从标签引擎同步标签 // 分配相关 Route::post('allocate', 'app\cunkebao\controller\TrafficPoolV2Controller@allocatePool'); // 分配流量 @@ -111,6 +112,10 @@ Route::group('v1/', function () { // 统计相关 Route::get('statistics', 'app\cunkebao\controller\TrafficPoolV2Controller@getStatistics'); // 获取统计数据 + + // 来源和行为相关 + Route::get('sources', 'app\cunkebao\controller\TrafficPoolV2Controller@getPoolSources'); // 分页获取来源 + Route::get('behaviors', 'app\cunkebao\controller\TrafficPoolV2Controller@getPoolBehaviors'); // 分页获取行为轨迹 }); // 工作台相关 diff --git a/Server/application/cunkebao/controller/TrafficPoolV2Controller.php b/Server/application/cunkebao/controller/TrafficPoolV2Controller.php index 2c621103d..5954ac3eb 100644 --- a/Server/application/cunkebao/controller/TrafficPoolV2Controller.php +++ b/Server/application/cunkebao/controller/TrafficPoolV2Controller.php @@ -10,6 +10,8 @@ use app\common\model\TrafficPoolTag; use app\common\model\TrafficPoolTagCategory; use app\common\model\TrafficPoolTagDefine; use app\common\model\TrafficPoolAllotRecord; +use app\common\model\TrafficPoolSource; +use app\common\model\TrafficPoolBehavior; use app\common\service\ClassTableService; use library\ResponseHelper; @@ -461,6 +463,28 @@ class TrafficPoolV2Controller extends BaseController } } + /** + * 从标签引擎同步用户标签 + * @return \think\response\Json + */ + public function syncTagsFromEngine() + { + $poolCompanyId = $this->request->param('poolCompanyId', 0, 'intval'); + $companyId = $this->getUserInfo('companyId'); + $operatorId = $this->getUserInfo('id'); + + if (empty($poolCompanyId)) { + return ResponseHelper::error('流量ID不能为空'); + } + + try { + $result = $this->poolService->syncTagsFromEngine($poolCompanyId, $companyId, $operatorId); + return ResponseHelper::success($result, "同步成功:已同步 {$result['syncedCount']} 个标签"); + } catch (\Exception $e) { + return ResponseHelper::error('同步标签失败:' . $e->getMessage()); + } + } + // ==================== 分配相关接口 ==================== /** @@ -565,5 +589,74 @@ class TrafficPoolV2Controller extends BaseController return ResponseHelper::error('获取统计数据失败:' . $e->getMessage()); } } + + /** + * 分页获取流量来源 + * @return \think\response\Json + */ + public function getPoolSources() + { + $poolCompanyId = $this->request->param('poolCompanyId', 0, 'intval'); + $companyId = $this->getUserInfo('companyId'); + $page = $this->request->param('page', 1, 'intval'); + $pageSize = $this->request->param('pageSize', 20, 'intval'); + $keyword = $this->request->param('keyword', ''); + + if (empty($poolCompanyId)) { + return ResponseHelper::error('流量ID不能为空'); + } + + // 验证流量归属 + $poolCompany = TrafficPoolCompany::where('id', $poolCompanyId) + ->where('companyId', $companyId) + ->where('isDel', 0) + ->find(); + + if (!$poolCompany) { + return ResponseHelper::error('流量不存在'); + } + + try { + $result = TrafficPoolSource::getSourcesWithOwnersPaginated($poolCompanyId, $page, $pageSize, $keyword); + return ResponseHelper::success($result); + } catch (\Exception $e) { + return ResponseHelper::error('获取来源列表失败:' . $e->getMessage()); + } + } + + /** + * 分页获取流量行为轨迹 + * @return \think\response\Json + */ + public function getPoolBehaviors() + { + $poolCompanyId = $this->request->param('poolCompanyId', 0, 'intval'); + $companyId = $this->getUserInfo('companyId'); + $page = $this->request->param('page', 1, 'intval'); + $pageSize = $this->request->param('pageSize', 20, 'intval'); + $keyword = $this->request->param('keyword', ''); + $behaviorType = $this->request->param('behaviorType', 0, 'intval'); + + if (empty($poolCompanyId)) { + return ResponseHelper::error('流量ID不能为空'); + } + + // 验证流量归属 + $poolCompany = TrafficPoolCompany::where('id', $poolCompanyId) + ->where('companyId', $companyId) + ->where('isDel', 0) + ->find(); + + if (!$poolCompany) { + return ResponseHelper::error('流量不存在'); + } + + try { + $result = TrafficPoolBehavior::getUserJourneyPaginated($poolCompanyId, $page, $pageSize, $keyword, $behaviorType); + return ResponseHelper::success($result); + } catch (\Exception $e) { + return ResponseHelper::error('获取行为轨迹失败:' . $e->getMessage()); + } + } } diff --git a/Server/application/cunkebao/service/TrafficPoolService.php b/Server/application/cunkebao/service/TrafficPoolService.php index 270c3c23a..91f4488ac 100644 --- a/Server/application/cunkebao/service/TrafficPoolService.php +++ b/Server/application/cunkebao/service/TrafficPoolService.php @@ -287,8 +287,8 @@ class TrafficPoolService // 获取标签 $data['tags'] = TrafficPoolTag::getTagsByPoolCompany($poolCompanyId)->toArray(); - // 获取来源历史(带群归属信息) - $data['sources'] = TrafficPoolSource::getSourcesWithOwners($poolCompanyId); + // 获取来源历史(带群归属信息,限制50条) + $data['sources'] = TrafficPoolSource::getSourcesWithOwners($poolCompanyId, 50); // 获取行为轨迹(最近50条) $data['behaviors'] = TrafficPoolBehavior::getUserJourney($poolCompanyId, 50)->toArray(); @@ -296,6 +296,58 @@ class TrafficPoolService // 获取分配历史 $data['allotRecords'] = TrafficPoolAllotRecord::getAllotHistory($poolCompanyId)->toArray(); + // 如果消息数为0,从微信消息表中统计实际消息数 + if (empty($data['totalMsgCount']) || $data['totalMsgCount'] == 0) { + $msgCount = 0; + + // 优先通过wechatFriendId统计(最准确) + if (!empty($poolCompany->wechatFriendId)) { + $msgCount = Db::table('s2_wechat_message') + ->where('wechatFriendId', $poolCompany->wechatFriendId) + ->where('type', 1) // 好友消息(type=1) + ->where('isDeleted', 0) + ->count(); + } + + // 如果wechatFriendId没有统计到,尝试通过identifier(微信ID)统计 + if ($msgCount == 0 && !empty($poolCompany->identifier)) { + // 统计发送者或接收者是该微信ID的消息 + // 需要关联s2_wechat_friend表,通过wechatId匹配 + $msgCount = Db::table('s2_wechat_message') + ->alias('wm') + ->join(['s2_wechat_friend' => 'wf'], 'wm.wechatFriendId = wf.id', 'LEFT') + ->where(function($query) use ($poolCompany) { + $query->where('wm.senderWechatId', $poolCompany->identifier) + ->whereOr('wf.wechatId', $poolCompany->identifier); + }) + ->where('wm.type', 1) // 好友消息 + ->where('wm.isDeleted', 0) + ->where('wf.isDeleted', 0) + ->count(); + } + + // 如果从行为表也有记录,取较大值(兼容旧数据) + $behaviorMsgCount = TrafficPoolBehavior::where('poolCompanyId', $poolCompanyId) + ->whereIn('behaviorType', [ + TrafficPoolBehavior::BEHAVIOR_TYPE_SEND_MSG, + TrafficPoolBehavior::BEHAVIOR_TYPE_RECEIVE_MSG + ]) + ->count(); + + if ($behaviorMsgCount > $msgCount) { + $msgCount = $behaviorMsgCount; + } + + if ($msgCount > 0) { + // 更新数据库中的消息数 + $poolCompany->save([ + 'totalMsgCount' => $msgCount, + 'updateTime' => time() + ]); + $data['totalMsgCount'] = $msgCount; + } + } + // 计算 RFM $data['rfmR'] = $poolCompany->lastInteractTime ? (int)floor((time() - $poolCompany->lastInteractTime) / 86400) : 9999; $data['rfmScore'] = $this->calculateRfmScore($data['rfmR'], $data['rfmF'], $data['rfmM']); @@ -502,6 +554,209 @@ class TrafficPoolService 'sourceDistribution' => $sourceDistribution ]; } + + /** + * 从标签引擎同步用户标签 + * + * @param int $poolCompanyId 流量池公司ID + * @param int $companyId 公司ID + * @param int $operatorId 操作人ID + * @return array 同步结果 + */ + public function syncTagsFromEngine(int $poolCompanyId, int $companyId, int $operatorId = null) + { + // 获取流量池记录 + $poolCompany = TrafficPoolCompany::where('id', $poolCompanyId) + ->where('companyId', $companyId) + ->where('isDel', 0) + ->find(); + + if (!$poolCompany) { + throw new \Exception('流量不存在'); + } + + // 获取标识信息用于查询标签引擎 + $identifiers = []; + + // 微信ID + if (!empty($poolCompany->identifier)) { + $identifiers[] = [ + 'type' => 'wechat', + 'value' => $poolCompany->identifier + ]; + } + + // 手机号 + if (!empty($poolCompany->phone)) { + $identifiers[] = [ + 'type' => 'phone', + 'value' => $poolCompany->phone + ]; + } + + if (empty($identifiers)) { + throw new \Exception('无有效标识可用于查询标签'); + } + + + // 调用标签引擎服务 + $tagEngineService = new \app\common\service\TagEngineService(); + $result = $tagEngineService->queryByIdentifiers($identifiers, [ + 'mask_identifier' => false + ]); + //exit_data($result); + if ($result === false) { + throw new \Exception('标签引擎查询失败'); + } + + // 检查返回结果 + if (isset($result['code']) && $result['code'] !== 0) { + throw new \Exception($result['message'] ?? '标签引擎返回错误'); + } + + $data = $result['data'] ?? $result; + if (!is_array($data)) { + $data = []; + } + + $syncedCount = 0; + $skippedCount = 0; + + // 处理返回的标签数据 + foreach ($data as $item) { + if (empty($item['found']) || empty($item['tags'])) { + continue; + } + + foreach ($item['tags'] as $tagData) { + try { + // 查找或创建标签定义 + $tagDefine = $this->findOrCreateTagDefine( + $companyId, + $tagData['tag_code'] ?? '', + $tagData['tag_name'] ?? '', + $tagData['category'] ?? '标签引擎', + $tagData['tag_type'] ?? 'string' + ); + + if (!$tagDefine) { + $skippedCount++; + continue; + } + + // 添加标签到流量池 + $tag = TrafficPoolTag::addTag( + $poolCompanyId, + $poolCompany->identifier, + $companyId, + $tagDefine->id, + TrafficPoolTag::SOURCE_AI, // 来源为AI/外部同步 + $operatorId, + $tagData['tag_value'] ?? null, + null // score + ); + + if ($tag) { + $syncedCount++; + } else { + $skippedCount++; + } + } catch (\Exception $e) { + $skippedCount++; + continue; + } + } + } + + return [ + 'syncedCount' => $syncedCount, + 'skippedCount' => $skippedCount, + 'total' => $syncedCount + $skippedCount + ]; + } + + /** + * 查找或创建标签定义 + * + * @param int $companyId 公司ID + * @param string $tagCode 标签代码 + * @param string $tagName 标签名称 + * @param string $categoryName 分类名称 + * @param string $valueType 值类型 + * @return \app\common\model\TrafficPoolTagDefine|null + */ + protected function findOrCreateTagDefine( + int $companyId, + string $tagCode, + string $tagName, + string $categoryName, + string $valueType + ) { + if (empty($tagName)) { + return null; + } + + // 标签类型映射 + $typeMap = [ + 'numeric' => 'number', + 'enum' => 'enum', + 'string' => 'string', + 'boolean' => 'boolean', + 'datetime' => 'datetime', + 'json' => 'json', + ]; + $mappedType = $typeMap[$valueType] ?? 'string'; + + // 先查找是否已存在该标签定义 + $tagDefine = \app\common\model\TrafficPoolTagDefine::where('companyId', $companyId) + ->where('tagName', $tagName) + ->where('tagType', TrafficPoolTag::TAG_TYPE_AI) // AI标签类型 + ->where('isDel', 0) + ->find(); + + if ($tagDefine) { + return $tagDefine; + } + + // 查找或创建分类 + $category = \app\common\model\TrafficPoolTagCategory::where('companyId', $companyId) + ->where('categoryName', $categoryName) + ->where('tagType', TrafficPoolTag::TAG_TYPE_AI) + ->where('isDel', 0) + ->find(); + + if (!$category) { + $category = new \app\common\model\TrafficPoolTagCategory(); + $category->save([ + 'companyId' => $companyId, + 'tagType' => TrafficPoolTag::TAG_TYPE_AI, + 'categoryName' => $categoryName, + 'description' => '从标签引擎同步的标签分类', + 'sortOrder' => 0, + 'isDel' => 0, + 'createTime' => time(), + 'updateTime' => time() + ]); + } + + // 创建标签定义 + $tagDefine = new \app\common\model\TrafficPoolTagDefine(); + $tagDefine->save([ + 'companyId' => $companyId, + 'categoryId' => $category->id, + 'tagType' => TrafficPoolTag::TAG_TYPE_AI, + 'tagCode' => $tagCode ?: 'engine_' . md5($tagName), + 'tagName' => $tagName, + 'valueType' => $mappedType, + 'description' => '从标签引擎同步', + 'isSystem' => 0, + 'isDel' => 0, + 'createTime' => time(), + 'updateTime' => time() + ]); + + return $tagDefine; + } } diff --git a/Server/extend/WeChatDeviceApi/Adapters/ChuKeBao/Adapter.php b/Server/extend/WeChatDeviceApi/Adapters/ChuKeBao/Adapter.php index 6876cb5fc..0e41e8f06 100644 --- a/Server/extend/WeChatDeviceApi/Adapters/ChuKeBao/Adapter.php +++ b/Server/extend/WeChatDeviceApi/Adapters/ChuKeBao/Adapter.php @@ -1821,8 +1821,8 @@ class Adapter implements WeChatServiceInterface COALESCE(f.M, 0) AS rfmM, 0 AS rfmScore, NULL AS rfmType, - 0 AS totalMsgCount, - NULL AS lastMsgTime, + COALESCE(msg_stats.msgCount, 0) AS totalMsgCount, + msg_stats.lastMsgTime AS lastMsgTime, 1 AS firstSourceType, f.createTime AS firstSourceTime, 1 AS lifecycle, @@ -1834,6 +1834,15 @@ class Adapter implements WeChatServiceInterface JOIN ck_traffic_pool tp ON tp.identifier = f.wechatId LEFT JOIN s2_wechat_account a ON f.wechatAccountId = a.id LEFT JOIN s2_company_account c ON c.id = a.deviceAccountId + LEFT JOIN ( + SELECT + wechatFriendId, + COUNT(*) AS msgCount, + MAX(createTime) AS lastMsgTime + FROM s2_wechat_message + WHERE type = 1 AND isDeleted = 0 + GROUP BY wechatFriendId + ) msg_stats ON msg_stats.wechatFriendId = f.id WHERE c.departmentId IS NOT NULL ORDER BY f.id DESC LIMIT ?, ? @@ -1850,6 +1859,8 @@ class Adapter implements WeChatServiceInterface lastInteractTime = GREATEST(COALESCE(ck_traffic_pool_company.lastInteractTime, 0), COALESCE(VALUES(lastInteractTime), 0)), rfmF = COALESCE(VALUES(rfmF), ck_traffic_pool_company.rfmF), rfmM = COALESCE(VALUES(rfmM), ck_traffic_pool_company.rfmM), + totalMsgCount = GREATEST(COALESCE(ck_traffic_pool_company.totalMsgCount, 0), COALESCE(VALUES(totalMsgCount), 0)), + lastMsgTime = GREATEST(COALESCE(ck_traffic_pool_company.lastMsgTime, 0), COALESCE(VALUES(lastMsgTime), 0)), status = VALUES(status), updateTime = UNIX_TIMESTAMP()";