where('companyId', $companyId) ->where('isAdmin', UserModel::ADMIN_STP) ->where('deleteTime', 0) ->field('id,account,username,phone,s2_accountId,status') ->find(); $deviceStats = $this->deviceStats($companyId); $assetStats = $this->assetStats($companyId); $wechatStats = $this->wechatStats($companyId); return array_merge($ctx, [ 'master' => $master ?: null, 'deviceStats' => $deviceStats, 'assetStats' => $assetStats, 'wechatStats' => $wechatStats, 'contentCount' => (int) Db::name('content_library')->where('companyId', $companyId)->where('isDel', 0)->count(), 'planCount' => (int) Db::name('customer_acquisition_task')->where('companyId', $companyId)->where('deleteTime', 0)->count(), 'subUserCount' => max(0, (int) Db::name('users')->where('companyId', $companyId)->where('deleteTime', 0)->count() - (ProjectContextService::primaryMasterUserId($companyId) > 0 ? 1 : 0)), ]); } public function assetStats(int $companyId): array { $rowQuery = Db::name('traffic_pool_company')->alias('tpc') ->where('tpc.companyId', $companyId) ->where('tpc.isDel', 0); TrafficPoolSystemIdentifierService::applyExcludeToCompanyQuery($rowQuery, 'tpc'); $row = $rowQuery->field([ 'COUNT(*) as customerTotal', 'COALESCE(SUM(totalOrderAmount),0) as totalConsumption', 'COALESCE(SUM(totalOrderCount),0) as totalOrders', 'COALESCE(AVG(rfmScore),0) as avgRfmScore', 'COALESCE(SUM(rfmScore),0) as rfmValuation', ]) ->find(); $rfmTypesQuery = Db::name('traffic_pool_company')->alias('tpc') ->where('tpc.companyId', $companyId) ->where('tpc.isDel', 0) ->where('tpc.rfmType', '<>', ''); TrafficPoolSystemIdentifierService::applyExcludeToCompanyQuery($rfmTypesQuery, 'tpc'); $rfmTypes = $rfmTypesQuery ->field('tpc.rfmType, COUNT(*) as cnt') ->group('tpc.rfmType') ->order('cnt', 'desc') ->limit(8) ->select(); return [ 'customerTotal' => (int) ($row['customerTotal'] ?? 0), 'totalConsumption' => round((float) ($row['totalConsumption'] ?? 0), 2), 'totalOrders' => (int) ($row['totalOrders'] ?? 0), 'avgRfmScore' => round((float) ($row['avgRfmScore'] ?? 0), 1), 'rfmValuation' => (int) ($row['rfmValuation'] ?? 0), 'rfmDistribution' => $rfmTypes ?: [], ]; } public function deviceStats(int $companyId): array { $total = (int) Db::name('device')->where('companyId', $companyId)->where('deleteTime', 0)->count(); $online = (int) Db::name('device')->where('companyId', $companyId)->where('deleteTime', 0)->where('alive', 1)->count(); return ['total' => $total, 'online' => $online, 'offline' => $total - $online]; } public function wechatStats(int $companyId): array { $rows = Db::name('device_wechat_login') ->alias('d') ->join('device dev', 'dev.id = d.deviceId') ->where('dev.companyId', $companyId) ->where('dev.deleteTime', 0) ->field('d.wechatId, d.alive, d.deviceId, dev.memo as deviceName') ->order('d.id', 'desc') ->limit(500) ->select(); $loggedIn = 0; $map = []; foreach ($rows ?: [] as $r) { if (!isset($map[$r['wechatId']])) { $map[$r['wechatId']] = $r; if ((int) $r['alive'] === 1) { $loggedIn++; } } } return [ 'total' => count($map), 'loggedIn' => $loggedIn, 'list' => array_values($map), ]; } public function listTraffic(int $projectId, int $page, int $limit, string $keyword = '', string $sortBy = 'rfmScore'): array { $companyId = ProjectContextService::resolveCompanyId($projectId); $query = Db::name('traffic_pool_company')->alias('tpc') ->join('traffic_pool tp', 'tp.id = tpc.poolId') ->where('tpc.companyId', $companyId) ->where('tpc.isDel', 0); TrafficPoolSystemIdentifierService::applyExcludeToPoolQuery($query); if ($keyword !== '') { $like = '%' . $keyword . '%'; $query->where(function ($q) use ($like) { $q->whereLike('tp.nickname', $like) ->whereOr('tpc.phone', 'like', $like) ->whereOr('tpc.realName', 'like', $like); }); } $total = (clone $query)->count('tpc.id'); $allowedSort = ['rfmScore', 'totalOrderAmount', 'createTime', 'lastInteractTime']; if (!in_array($sortBy, $allowedSort, true)) { $sortBy = 'rfmScore'; } $rows = $query ->field('tpc.id,tpc.identifier,tpc.phone,tpc.realName,tpc.rfmScore,tpc.rfmType,tpc.totalOrderAmount,tpc.totalOrderCount,tpc.createTime,tp.nickname,tp.wechatId,tp.avatar') ->order($sortBy, 'desc') ->page($page, $limit) ->select(); return ['list' => $rows ?: [], 'total' => $total, 'page' => $page, 'limit' => $limit]; } public function listContentLibrary(int $projectId, int $page, int $limit): array { $companyId = ProjectContextService::resolveCompanyId($projectId); $query = Db::name('content_library')->where('companyId', $companyId)->where('isDel', 0); $total = (clone $query)->count('id'); $list = $query->field('id,name,status,createTime,updateTime')->order('id', 'desc')->page($page, $limit)->select(); $libraryIds = array_column($list ?: [], 'id'); $itemCounts = []; if (!empty($libraryIds)) { $counts = Db::name('content_item') ->field('libraryId, COUNT(*) as count') ->whereIn('libraryId', $libraryIds) ->where('isDel', 0) ->group('libraryId') ->select(); foreach ($counts ?: [] as $row) { $itemCounts[(int) $row['libraryId']] = (int) $row['count']; } } foreach ($list ?: [] as &$row) { $row['itemCount'] = $itemCounts[(int) $row['id']] ?? 0; } unset($row); return ['list' => $list ?: [], 'total' => $total, 'page' => $page, 'limit' => $limit]; } public function saveContentLibrary(int $projectId, array $param): array { $companyId = ProjectContextService::resolveCompanyId($projectId); $master = $this->resolveMasterUser($companyId); $userId = (int) $master['id']; $now = time(); $id = (int) ($param['id'] ?? 0); $name = trim((string) ($param['name'] ?? '')); $status = isset($param['status']) ? (int) $param['status'] : null; if ($id > 0) { $library = Db::name('content_library') ->where('id', $id) ->where('companyId', $companyId) ->where('isDel', 0) ->find(); if (!$library) { throw new \Exception('内容库不存在', 404); } $update = ['updateTime' => $now]; if ($name !== '') { $dup = Db::name('content_library') ->where('companyId', $companyId) ->where('isDel', 0) ->where('name', $name) ->where('id', '<>', $id) ->count(); if ($dup > 0) { throw new \Exception('内容库名称已存在', 400); } $update['name'] = $name; } if ($status !== null) { $update['status'] = $status ? 1 : 0; } Db::name('content_library')->where('id', $id)->update($update); return ['id' => $id, 'action' => 'update']; } if ($name === '') { throw new \Exception('内容库名称不能为空', 400); } $exists = Db::name('content_library') ->where('companyId', $companyId) ->where('isDel', 0) ->where('name', $name) ->count(); if ($exists > 0) { throw new \Exception('内容库名称已存在', 400); } $newId = Db::name('content_library')->insertGetId([ 'name' => $name, 'sourceType' => 1, 'sourceFriends' => '[]', 'sourceGroups' => '[]', 'groupMembers' => '[]', 'catchType' => '[]', 'devices' => '[]', 'keywordInclude' => '[]', 'keywordExclude' => '[]', 'aiEnabled' => 0, 'aiPrompt' => '', 'timeEnabled' => 0, 'formType' => 1, 'status' => $status !== null ? ($status ? 1 : 0) : 0, 'userId' => $userId, 'companyId' => $companyId, 'createTime' => $now, 'updateTime' => $now, 'isDel' => 0, ]); return ['id' => $newId, 'action' => 'create']; } /** * 微信号好友迁移(对标 cunkebao PostTransferFriends,注入项目 companyId + 主账号 userId) */ public function migrateWechatFriends(int $projectId, array $param): array { $companyId = ProjectContextService::resolveCompanyId($projectId); $master = $this->resolveMasterUser($companyId); $userId = (int) $master['id']; $wechatId = trim((string) ($param['wechatId'] ?? '')); $inherit = !empty($param['inherit']); $greeting = trim((string) ($param['greeting'] ?? '')); $firstMessage = trim((string) ($param['firstMessage'] ?? '')); $devices = $param['devices'] ?? []; if ($wechatId === '') { throw new \Exception('迁移的微信不能为空', 400); } if (empty($devices) || !is_array($devices)) { throw new \Exception('迁移的设备不能为空', 400); } if ($greeting === '') { throw new \Exception('打招呼不能为空', 400); } $wechat = Db::name('wechat_customer')->alias('wc') ->join('wechat_account wa', 'wc.wechatId = wa.wechatId') ->where(['wc.wechatId' => $wechatId]) ->field('wa.*') ->find(); if (empty($wechat)) { throw new \Exception('该微信不存在', 404); } $deviceIds = Db::name('device') ->where(['companyId' => $companyId, 'deleteTime' => 0]) ->whereIn('id', array_map('intval', $devices)) ->column('id'); if (empty($deviceIds)) { throw new \Exception('所选设备不属于本项目', 400); } $sceneConf = [ 'enabled' => true, 'posters' => [ 'id' => 'poster-3', 'name' => '点击咨询', 'src' => 'https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E5%92%A8%E8%AF%A2-FTiyAMAPop2g9LvjLOLDz0VwPg3KVu.gif', ], ]; $reqConf = [ 'device' => $deviceIds, 'startTime' => '09:00', 'endTime' => '18:00', 'remarkType' => 'phone', 'addFriendInterval' => 60, 'greeting' => $greeting ?: ('我是' . $wechat['nickname'] . '的新号,请通过'), ]; $msgConf = []; if ($firstMessage !== '') { $msgConf = [[ 'day' => 0, 'messages' => [[ 'id' => 1, 'type' => 'text', 'content' => $firstMessage, 'intervalUnit' => 'seconds', 'sendInterval' => 5, ]], ]]; } $createAddFriendPlan = app('app\cunkebao\controller\plan\PostCreateAddFriendPlanV1Controller'); $taskId = Db::name('customer_acquisition_task')->insertGetId([ 'name' => '迁移好友(' . $wechat['nickname'] . ')', 'sceneId' => 10, 'sceneConf' => json_encode($sceneConf, JSON_UNESCAPED_UNICODE), 'reqConf' => json_encode($reqConf, JSON_UNESCAPED_UNICODE), 'tagConf' => json_encode([]), 'msgConf' => json_encode($msgConf, JSON_UNESCAPED_UNICODE), 'userId' => $userId, 'companyId' => $companyId, 'status' => 0, 'createTime' => time(), 'apiKey' => $createAddFriendPlan->generateApiKey(), ]); $friends = Db::table('s2_wechat_friend') ->where(['ownerWechatId' => $wechatId]) ->field('wechatId,alias,phone,labels,conRemark') ->group('wechatId') ->order('id DESC') ->select(); $friendRows = $friends ? (is_array($friends) ? $friends : $friends->toArray()) : []; $batchSize = 1000; $inserted = 0; for ($i = 0, $total = count($friendRows); $i < $total; $i += $batchSize) { $batchRows = array_slice($friendRows, $i, $batchSize); if (empty($batchRows)) { continue; } $newData = []; foreach ($batchRows as $row) { if (!empty($row['phone'])) { $phone = $row['phone']; } elseif (!empty($row['alias'])) { $phone = $row['alias']; } else { $phone = $row['wechatId']; } $tags = !empty($row['labels']) ? json_decode($row['labels'], true) : []; $newData[] = [ 'task_id' => $taskId, 'name' => '', 'source' => '迁移好友(' . $wechat['nickname'] . ')', 'phone' => $phone, 'remark' => $inherit ? ($row['conRemark'] ?? '') : '', 'tags' => $inherit ? json_encode($tags ?: [], JSON_UNESCAPED_UNICODE) : json_encode([]), 'siteTags' => json_encode([]), 'status' => 0, 'createTime' => time(), ]; } Db::name('task_customer')->insertAll($newData); $inserted += count($newData); } return ['taskId' => $taskId, 'friendCount' => $inserted]; } private function resolveMasterUser(int $companyId): array { $master = Db::name('users') ->where('companyId', $companyId) ->where('isAdmin', UserModel::ADMIN_STP) ->where('deleteTime', 0) ->field('id,account,username,phone,s2_accountId') ->find(); if (!$master) { throw new \Exception('项目未配置主账号', 400); } return $master; } public function listPlanScenes(int $projectId): array { ProjectContextService::resolveByProjectId($projectId); $companyId = ProjectContextService::resolveCompanyId($projectId); $rowsRaw = Db::name('plan_scene')->where('deleteTime', 0)->order('sort DESC, id ASC')->select(); $rows = $rowsRaw ? (is_array($rowsRaw) ? $rowsRaw : $rowsRaw->toArray()) : []; $byId = []; foreach ($rows as $row) { $byId[(int) $row['id']] = $row; } $planCounts = Db::name('customer_acquisition_task') ->where('companyId', $companyId) ->where('deleteTime', 0) ->field('sceneId, COUNT(*) as cnt') ->group('sceneId') ->select(); $countMap = []; foreach ($planCounts ?: [] as $p) { $countMap[(int) $p['sceneId']] = (int) $p['cnt']; } $overrideMap = CompanyFeatureSwitchService::getOverrideMap( (int) $companyId, CompanyFeatureSwitchService::TYPE_SCENE ); $list = []; foreach (PlanSceneCatalogService::productionCatalog() as $def) { $id = (int) $def['id']; $row = $byId[$id] ?? []; $globalStatus = (int) ($row['status'] ?? PlanSceneCatalogService::getDefaultStatus($id)); $effective = array_key_exists($id, $overrideMap) ? (int) $overrideMap[$id] : $globalStatus; $list[] = [ 'id' => $id, 'name' => $row['name'] ?? $def['name'], 'description' => $row['description'] ?? '', 'status' => $effective, 'globalStatus' => $globalStatus, 'planCount' => $countMap[$id] ?? 0, 'sort' => (int) ($row['sort'] ?? $def['sort']), ]; } return ['list' => $list]; } /** * 项目级设置场景开关 */ public function setPlanSceneStatus(int $projectId, int $sceneId, int $status): array { ProjectContextService::resolveByProjectId($projectId); $companyId = ProjectContextService::resolveCompanyId($projectId); if (!PlanSceneCatalogService::isProductionReady($sceneId)) { throw new \Exception('场景不可用', 400); } CompanyFeatureSwitchService::setStatus( $companyId, CompanyFeatureSwitchService::TYPE_SCENE, $sceneId, $status ); return ['id' => $sceneId, 'status' => $status ? 1 : 0]; } /** * 项目级工作台功能列表(含公司级有效状态) */ public function listWorkbenchFunctions(int $projectId): array { ProjectContextService::resolveByProjectId($projectId); $companyId = ProjectContextService::resolveCompanyId($projectId); $rows = Db::name('workbench_function')->order('sort ASC, id ASC')->select(); $rows = $rows ? (is_array($rows) ? $rows : $rows->toArray()) : []; $overrideMap = CompanyFeatureSwitchService::getOverrideMap( (int) $companyId, CompanyFeatureSwitchService::TYPE_WORKBENCH ); $list = []; foreach ($rows as $row) { $id = (int) $row['id']; $globalStatus = (int) ($row['status'] ?? 0); $effective = array_key_exists($id, $overrideMap) ? (int) $overrideMap[$id] : $globalStatus; $list[] = [ 'id' => $id, 'key' => (string) ($row['key'] ?? ''), 'title' => (string) ($row['title'] ?? ''), 'subtitle' => (string) ($row['subtitle'] ?? ''), 'status' => $effective, 'globalStatus' => $globalStatus, 'sort' => (int) ($row['sort'] ?? 0), ]; } return ['list' => $list]; } /** * 项目级设置工作台功能开关 */ public function setWorkbenchFunctionStatus(int $projectId, int $functionId, int $status): array { ProjectContextService::resolveByProjectId($projectId); $companyId = ProjectContextService::resolveCompanyId($projectId); $exists = Db::name('workbench_function')->where('id', $functionId)->find(); if (!$exists) { throw new \Exception('功能不存在', 404); } CompanyFeatureSwitchService::setStatus( $companyId, CompanyFeatureSwitchService::TYPE_WORKBENCH, $functionId, $status ); return ['id' => $functionId, 'status' => $status ? 1 : 0]; } public function migrateContentLibrary(int $projectId, int $libraryId, int $targetProjectId): void { $sourceCompanyId = ProjectContextService::resolveCompanyId($projectId); $targetCompanyId = ProjectContextService::resolveCompanyId($targetProjectId); if ($sourceCompanyId === $targetCompanyId) { throw new \Exception('源项目与目标项目相同', 400); } $updated = Db::name('content_library') ->where('id', $libraryId) ->where('companyId', $sourceCompanyId) ->where('isDel', 0) ->update(['companyId' => $targetCompanyId, 'updateTime' => time()]); if (!$updated) { throw new \Exception('内容库不存在或迁移失败', 404); } } public function migrateTraffic(int $projectId, array $poolCompanyIds, int $targetProjectId): int { $sourceCompanyId = ProjectContextService::resolveCompanyId($projectId); $targetCompanyId = ProjectContextService::resolveCompanyId($targetProjectId); if ($sourceCompanyId === $targetCompanyId) { throw new \Exception('源项目与目标项目相同', 400); } if (empty($poolCompanyIds)) { throw new \Exception('请选择要迁移的客户', 400); } $ids = array_map('intval', $poolCompanyIds); return Db::name('traffic_pool_company') ->whereIn('id', $ids) ->where('companyId', $sourceCompanyId) ->where('isDel', 0) ->update(['companyId' => $targetCompanyId, 'updateTime' => time()]); } public function exportTrafficCsv(int $projectId, int $limit = 5000): array { $data = $this->listTraffic($projectId, 1, min($limit, 5000), '', 'rfmScore'); return $data['list']; } }