Files
cunkebao_v3/Server/application/superadmin/service/ProjectOverviewService.php
Manus AI 2753265edb sync(本地→GitHub): 2026-06-03 21:17 全量单向同步
## GitHub 同步说明

| 项 | 内容 |
|:---|:---|
| 仓库 | https://github.com/fnvtk/cunkebao_v3 |
| 分支 | develop |
| 方向 | 本地 → GitHub(单向 push) |
| 是否拉取远程 | 否(未 fetch / pull / merge) |
| 是否改本地源文件 | 否(仅 git add/commit,未编辑业务/文档正文) |
| 远程基准 | origin/develop @ b5b40e8b |
| 本批工作区变更 | 999 files, +47622 / -4007 lines |

## 一并推送的本地已有 commit(此前未 push)

1. 3aa7ecf8 deploy: 官网 ckb.quwanzhi.com 宝塔上线 DNS+SSL+验收文档
2. 16a70045 fix(官网): 桌面导航仅保留一个留资 CTA 并重新上线
3. dbe7b7aa fix(官网): Hero 改为主 CTA 按钮并去掉重复 1200+ 数据条
4. ac82726e chore(部署): 五端上线验收46项并统一留资与缓存版本

## 本 commit 目录统计

| 目录 | 文件数 | 说明 |
|:---|---:|:---|
| 开发文档/ | 639 | 10、项目管理、未完成项跨仓汇总、五端需求/部署/接口 |
| Server/ | 129 | 后端 API、迁移、部署相关 |
| Touchkebao/ | 80 | PC 工作台、获客、算力 |
| 官网/ | 61 | ckb.quwanzhi.com 静态站与 CTA 迭代 |
| Cunkebao/ | 49 | 移动端场景/设置/流量池 |
| SuperAdmin/ | 26 | 超管后台 |
| .cursor/ | 11 | 规则与 Skill |
| 其他 | 14 | docker-compose、.obsidian、.codegraph 等 |

## 重点文件(本地为准)

- 开发文档/1、需求/修改/未完成项与跨仓验收汇总.md
- 开发文档/10、项目管理/账号密码与登录环境一览.md
- 开发文档/10、项目管理/(新建项目管理目录与截图)
- 官网/ 全站 + 宝塔上线文档
- 五端上线验收 46 项(见 ac82726e)

## 刻意未上传

- node_modules/、.DS_Store、.smart-env/
- .开发文档_nested_git_backup/
- **/__pycache__/

## 验收

- 提交页(含本说明): https://github.com/fnvtk/cunkebao_v3/commit/HEAD
- 分支树: https://github.com/fnvtk/cunkebao_v3/tree/develop

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 21:17:41 +08:00

820 lines
31 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

<?php
namespace app\superadmin\service;
use app\common\model\User as UserModel;
use app\common\service\TrafficPoolSystemIdentifierService;
use app\cunkebao\service\DeviceGeoHelper;
use think\Db;
/**
* 超管项目总览 / 统一账号 / 设备同步
*/
class ProjectOverviewService
{
public function getSummary(): array
{
$projectTotal = (int) Db::name('company')->count('id');
$projectActive = (int) Db::name('company')->where('status', 1)->count('id');
$deviceTotal = (int) Db::name('device')->where('deleteTime', 0)->count('id');
$deviceOnline = (int) Db::name('device')->where('deleteTime', 0)->where('alive', 1)->count('id');
$customerQuery = Db::name('traffic_pool_company')->alias('tpc')->where('tpc.isDel', 0);
TrafficPoolSystemIdentifierService::applyExcludeToCompanyQuery($customerQuery, 'tpc');
$customerTotal = (int) $customerQuery->count('tpc.id');
$tokenTotal = (int) Db::name('tokens_company')->where('isAdmin', 1)->sum('tokens');
$contentTotal = (int) Db::name('content_library')->where('isDel', 0)->count('id');
return [
'projectTotal' => $projectTotal,
'projectActive' => $projectActive,
'projectDisabled' => $projectTotal - $projectActive,
'deviceTotal' => $deviceTotal,
'deviceOnline' => $deviceOnline,
'deviceOffline' => $deviceTotal - $deviceOnline,
'customerTotal' => $customerTotal,
'tokenBalanceTotal' => $tokenTotal,
'contentLibraryTotal' => $contentTotal,
];
}
public function getProjectRows(int $page = 1, int $limit = 20, string $keyword = ''): array
{
$query = Db::name('company')->alias('c')
->leftJoin('users u', 'u.companyId = c.companyId AND u.isAdmin = ' . UserModel::ADMIN_STP . ' AND u.deleteTime = 0')
->field([
'c.id', 'c.name', 'c.status', 'c.companyId', 'c.memo', 'c.createTime',
'u.account', 'u.phone', 'u.username', 'u.s2_accountId', 'u.status as userStatus',
]);
if ($keyword !== '') {
$like = '%' . $keyword . '%';
$query->where(function ($q) use ($like) {
$q->whereLike('c.name', $like)
->whereOr('u.account', 'like', $like)
->whereOr('u.phone', 'like', $like);
});
}
$total = (clone $query)->count('c.id');
$rows = $query->order('c.id', 'desc')->page($page, $limit)->select();
$companyIds = array_column($rows, 'companyId');
$deviceStats = $this->deviceStatsByCompany($companyIds);
$customerStats = $this->customerStatsByCompany($companyIds);
$subUserStats = $this->subUserStatsByCompany($companyIds);
$list = [];
foreach ($rows as $row) {
$cid = (int) $row['companyId'];
$deviceCount = $deviceStats[$cid]['total'] ?? 0;
$deviceOnline = $deviceStats[$cid]['online'] ?? 0;
$customerCount = $customerStats[$cid] ?? 0;
$subUserCount = $subUserStats[$cid] ?? 0;
$hasMasterPhone = !empty($row['phone']);
$health = 'ok';
if ((int) $row['status'] !== 1 || (int) ($row['userStatus'] ?? 0) !== 1) {
$health = 'error';
} elseif (!$hasMasterPhone || $deviceCount === 0) {
$health = 'warn';
}
$list[] = [
'id' => (int) $row['id'],
'companyId' => $cid,
'name' => $row['name'],
'status' => (int) $row['status'],
'account' => $row['account'] ?: '',
'phone' => $row['phone'] ?: '',
'username' => $row['username'] ?: '',
'deviceCount' => $deviceCount,
'deviceOnline' => $deviceOnline,
'customerCount' => $customerCount,
'subUserCount' => $subUserCount,
'health' => $health,
'createTime' => $this->formatTime($row['createTime']),
];
}
return [
'list' => $list,
'total' => $total,
'page' => $page,
'limit' => $limit,
];
}
public function getUnifiedAccounts(int $projectId): array
{
$company = Db::name('company')->where('id', $projectId)->find();
if (!$company) {
throw new \Exception('项目不存在', 404);
}
$companyId = (int) $company['companyId'];
$master = Db::name('users')
->where('companyId', $companyId)
->where('isAdmin', UserModel::ADMIN_STP)
->where('deleteTime', 0)
->find();
$masterUserId = ProjectContextService::primaryMasterUserId($companyId);
$subUserQuery = Db::name('users')
->where('companyId', $companyId)
->where('deleteTime', 0);
if ($masterUserId > 0) {
$subUserQuery->where('id', '<>', $masterUserId);
}
$subUsers = $subUserQuery
->field('id,account,username,phone,status,typeId,s2_accountId,isAdmin,createTime')
->select();
$storeUsers = Db::name('users')
->where('companyId', $companyId)
->where('typeId', 2)
->where('deleteTime', 0)
->field('id,account,username,phone,status,typeId,s2_accountId,createTime')
->select();
$devices = Db::name('device')
->where('companyId', $companyId)
->where('deleteTime', 0)
->field('id,memo,imei,phone,model,brand,alive,createTime')
->select();
$endpoints = $this->loginEndpoints();
$s2Bound = $master && !empty($master['s2_accountId']);
return [
'project' => [
'id' => (int) $company['id'],
'companyId' => $companyId,
'name' => $company['name'],
'status' => (int) $company['status'],
],
'master' => $master ? [
'id' => (int) $master['id'],
'account' => $master['account'],
'username' => $master['username'],
'phone' => $master['phone'],
'status' => (int) $master['status'],
'typeId' => (int) $master['typeId'],
's2_accountId' => $master['s2_accountId'] ?: '',
] : null,
'subUsers' => $subUsers ?: [],
'storeUsers' => $storeUsers ?: [],
'devices' => $devices ?: [],
'bindings' => [
'cunkebao' => (bool) $master,
'touchkebao' => $s2Bound,
'workphone' => $s2Bound,
'aiStaff' => count($storeUsers) > 0 || count($subUsers) > 0,
],
'loginUrls' => [
'cunkebao' => $endpoints['cunkebao'],
'touchkebao' => $endpoints['touchkebao'],
'superadmin' => $endpoints['superadmin'],
],
];
}
public function syncDevicesForCompany(int $companyId): int
{
$accountId = (int) Db::name('users')
->where('companyId', $companyId)
->where('isAdmin', UserModel::ADMIN_STP)
->where('deleteTime', 0)
->value('s2_accountId');
if ($accountId <= 0) {
throw new \Exception('项目未绑定工作手机账号 s2_accountId', 400);
}
$existing = Db::name('device')->where('companyId', $companyId)->where('deleteTime', 0)->column('id') ?: [0];
$ids = implode(',', array_map('intval', $existing));
$before = (int) Db::name('device')->where('companyId', $companyId)->where('deleteTime', 0)->count('id');
$sql = "INSERT INTO ck_device(`id`, `imei`, `model`, phone, operatingSystem, memo, alive, brand, rooted, xPosed, softwareVersion, extra, createTime, updateTime, deleteTime, companyId)
SELECT
d.id, d.imei, d.model, d.phone, d.operatingSystem, d.memo, d.alive, d.brand, d.rooted, d.xPosed, d.softwareVersion, d.extra, d.createTime, d.lastUpdateTime, d.deleteTime, a.departmentId AS companyId
FROM s2_device d
JOIN s2_company_account a ON d.currentAccountId = a.id
WHERE isDeleted = 0 AND deletedAndStop = 0 AND d.id NOT IN ({$ids}) AND a.departmentId = {$companyId}
ON DUPLICATE KEY UPDATE
imei = VALUES(imei), model = VALUES(model), phone = VALUES(phone),
operatingSystem = VALUES(operatingSystem), memo = VALUES(memo), alive = VALUES(alive),
brand = VALUES(brand), updateTime = VALUES(updateTime)";
Db::query($sql);
$after = (int) Db::name('device')->where('companyId', $companyId)->where('deleteTime', 0)->count('id');
return max(0, $after - $before);
}
/**
* 超管:从 s2_device 全量 upsert 到 ck_device含各项目历史绑定过的设备
*/
public function syncAllDevicesFromS2(): array
{
$beforeActive = (int) Db::name('device')->where('deleteTime', 0)->count('id');
$beforeAll = (int) Db::name('device')->count('id');
// LEFT JOIN无 currentAccountId 的设备也入库companyId=0 进「未归属」)
$sql = "INSERT INTO ck_device(`id`, `imei`, `model`, phone, operatingSystem, memo, alive, brand, rooted, xPosed, softwareVersion, extra, createTime, updateTime, deleteTime, companyId)
SELECT
d.id, d.imei, d.model, d.phone, d.operatingSystem, d.memo, d.alive, d.brand, d.rooted, d.xPosed, d.softwareVersion, d.extra, d.createTime, d.lastUpdateTime, d.deleteTime,
COALESCE(NULLIF(a.departmentId, 0), 0) AS companyId
FROM s2_device d
LEFT JOIN s2_company_account a ON d.currentAccountId = a.id AND a.isDeleted = 0
WHERE d.isDeleted = 0 AND d.deletedAndStop = 0
ON DUPLICATE KEY UPDATE
imei = VALUES(imei), model = VALUES(model), phone = VALUES(phone),
operatingSystem = VALUES(operatingSystem), memo = VALUES(memo), alive = VALUES(alive),
brand = VALUES(brand), updateTime = VALUES(updateTime), deleteTime = VALUES(deleteTime),
companyId = VALUES(companyId)";
Db::query($sql);
$afterActive = (int) Db::name('device')->where('deleteTime', 0)->count('id');
$afterAll = (int) Db::name('device')->count('id');
$archived = (int) Db::name('device')->where('deleteTime', '>', 0)->count('id');
$s2Eligible = $this->countS2EligibleDevices();
return [
'synced' => max(0, $afterActive - $beforeActive),
'syncedAll' => max(0, $afterAll - $beforeAll),
'totalActive' => $afterActive,
'totalArchived' => $archived,
'totalAll' => $afterAll,
's2Eligible' => $s2Eligible,
];
}
protected function countS2EligibleDevices(): int
{
try {
return (int) Db::table('s2_device')
->where('isDeleted', 0)
->where('deletedAndStop', 0)
->count('id');
} catch (\Throwable $e) {
return 0;
}
}
public function listDevices(int $page, int $limit, int $companyId = 0, string $keyword = '', int $alive = -1, bool $groupByProject = false, bool $includeArchived = false): array
{
if ($groupByProject) {
return $this->listDevicesGrouped($keyword, $alive, $includeArchived);
}
$query = Db::name('device')->alias('d')
->leftJoin('company c', 'c.companyId = d.companyId')
->where('d.deleteTime', 0);
if ($companyId > 0) {
$query->where('d.companyId', $companyId);
}
if ($alive >= 0) {
$query->where('d.alive', $alive);
}
if ($keyword !== '') {
$like = '%' . $keyword . '%';
$query->where(function ($q) use ($like) {
$q->whereLike('d.memo', $like)
->whereOr('d.imei', 'like', $like)
->whereOr('d.phone', 'like', $like)
->whereOr('c.name', 'like', $like);
});
}
$total = (clone $query)->count('d.id');
$rows = $query
->field('d.id,d.memo,d.imei,d.phone,d.model,d.brand,d.alive,d.companyId,d.createTime,c.name as projectName,c.id as projectId')
->order('d.alive', 'desc')
->order('d.updateTime', 'desc')
->page($page, $limit)
->select();
return [
'list' => $rows ?: [],
'total' => $total,
'page' => $page,
'limit' => $limit,
];
}
public function listDevicesGrouped(string $keyword = '', int $alive = -1, bool $includeArchived = false): array
{
$imeiDupes = $this->loadDuplicateImeiMap();
$query = Db::name('device')->alias('d')
->leftJoin('company c', 'c.companyId = d.companyId AND c.deleteTime = 0');
if (!$includeArchived) {
$query->where('d.deleteTime', 0);
}
if ($alive >= 0) {
$query->where('d.alive', $alive);
}
if ($keyword !== '') {
$like = '%' . $keyword . '%';
$query->where(function ($q) use ($like) {
$q->whereLike('d.memo', $like)
->whereOr('d.imei', 'like', $like)
->whereOr('d.phone', 'like', $like)
->whereOr('c.name', 'like', $like);
});
}
$rows = $query
->field('d.id,d.memo,d.imei,d.phone,d.model,d.brand,d.alive,d.companyId,d.createTime,d.updateTime,d.deleteTime,d.extra,c.name as projectName,c.id as projectId')
->order('d.alive', 'desc')
->order('d.updateTime', 'desc')
->order('d.memo', 'asc')
->select();
// §6.2 设备登录微信号(昵称/alias/avatar
$deviceIds = [];
foreach ($rows ?: [] as $r) {
$deviceIds[] = (int) (is_array($r) ? $r['id'] : $r['id']);
}
$wechatsMap = (new \app\common\service\WechatIdentityService())->deviceWechatsMap($deviceIds);
$groups = [];
$abnormalCount = 0;
$unassignedCount = 0;
foreach ($rows ?: [] as $row) {
$row = is_array($row) ? $row : $row->toArray();
$cid = (int) $row['companyId'];
$groupKey = $cid > 0 ? $cid : 0;
$geo = DeviceGeoHelper::parse($row['extra'] ?? '');
$deviceTags = $this->computeDeviceTags($row, $imeiDupes);
$isAbnormal = $this->isAbnormalDeviceTag($deviceTags);
if ($cid <= 0) {
$unassignedCount++;
}
if ($isAbnormal) {
$abnormalCount++;
}
if (!isset($groups[$groupKey])) {
$groups[$groupKey] = [
'companyId' => $groupKey,
'projectId' => $groupKey > 0 ? (int) ($row['projectId'] ?? 0) : 0,
'projectName' => $groupKey > 0
? ($row['projectName'] ?: "项目#{$groupKey}")
: '未归属',
'deviceTotal' => 0,
'deviceOnline' => 0,
'devices' => [],
];
}
$groups[$groupKey]['deviceTotal']++;
if ((int) $row['alive'] === 1) {
$groups[$groupKey]['deviceOnline']++;
}
$groups[$groupKey]['devices'][] = [
'id' => (int) $row['id'],
'memo' => $row['memo'] ?: '',
'imei' => $row['imei'] ?: '',
'phone' => $row['phone'] ?: '',
'model' => $row['model'] ?: '',
'brand' => $row['brand'] ?: '',
'alive' => (int) $row['alive'],
'companyId' => $cid,
'createTime' => $this->formatTime($row['createTime']),
'updateTime' => $this->formatTime($row['updateTime']),
'location' => $geo['location'] ?: '',
'city' => $geo['city'] ?: '',
'province' => $geo['province'] ?: '',
'localTime' => $geo['localTime'] ?: '',
'deviceTags' => $deviceTags,
'wechats' => $wechatsMap[(int) $row['id']] ?? [],
];
}
$companyIds = array_values(array_filter(array_map(function ($g) {
return (int) ($g['companyId'] ?? 0);
}, $groups), function ($cid) {
return $cid > 0;
}));
$classifier = new CompanyClassificationService();
$metaMap = $classifier->buildMetaMap($companyIds);
$tagLabels = CompanyClassificationService::tagLabels();
foreach ($groups as $key => $g) {
$cid = (int) $g['companyId'];
if ($cid <= 0) {
$groups[$key]['projectTags'] = [];
$groups[$key]['primaryTag'] = '';
$groups[$key]['isSandbox'] = false;
continue;
}
$meta = $metaMap[$cid] ?? [];
$tags = $meta['projectTags'] ?? [];
$groups[$key]['projectTags'] = $tags;
$groups[$key]['primaryTag'] = $meta['primaryTag'] ?? '';
$groups[$key]['projectTagLabels'] = array_map(function ($t) use ($tagLabels) {
return $tagLabels[$t] ?? $t;
}, $tags);
$groups[$key]['isSandbox'] = in_array(CompanyClassificationService::TAG_TEST, $tags, true)
|| in_array(CompanyClassificationService::TAG_LONG_IDLE, $tags, true)
|| in_array(CompanyClassificationService::TAG_EMPTY, $tags, true);
}
$groupList = array_values($groups);
usort($groupList, function ($a, $b) {
if (!empty($a['isSandbox']) && empty($b['isSandbox'])) {
return 1;
}
if (empty($a['isSandbox']) && !empty($b['isSandbox'])) {
return -1;
}
if ($a['companyId'] === 0) {
return 1;
}
if ($b['companyId'] === 0) {
return -1;
}
if ($b['deviceTotal'] !== $a['deviceTotal']) {
return $b['deviceTotal'] <=> $a['deviceTotal'];
}
return $b['deviceOnline'] <=> $a['deviceOnline'];
});
$totalDevices = count($rows ?: []);
$onlineDevices = 0;
$offlineDevices = 0;
$archivedDevices = 0;
foreach ($rows ?: [] as $row) {
$row = is_array($row) ? $row : $row->toArray();
if ((int) ($row['deleteTime'] ?? 0) > 0) {
$archivedDevices++;
} elseif ((int) $row['alive'] === 1) {
$onlineDevices++;
} else {
$offlineDevices++;
}
}
return [
'groups' => $groupList,
'summary' => [
'total' => $totalDevices,
'online' => $onlineDevices,
'offline' => $offlineDevices,
'archived' => $archivedDevices,
's2Eligible' => $this->countS2EligibleDevices(),
'projects' => count(array_filter($groupList, fn ($g) => $g['companyId'] > 0)),
'abnormal' => $abnormalCount,
'unassigned' => $unassignedCount,
'multiBindImeis' => count($imeiDupes),
],
];
}
/**
* @return array<string, int>
*/
protected function loadDuplicateImeiMap(): array
{
$dupRows = Db::name('device')
->where('deleteTime', 0)
->where('imei', '<>', '')
->field('imei, COUNT(DISTINCT companyId) as bindCount')
->group('imei')
->having('bindCount > 1')
->select();
$map = [];
foreach ($dupRows ?: [] as $r) {
$map[(string) $r['imei']] = (int) $r['bindCount'];
}
return $map;
}
/**
* @param array<string, int> $imeiDupes
* @return array<int, array{key:string,label:string,variant:string}>
*/
protected function computeDeviceTags(array $row, array $imeiDupes): array
{
$tags = [];
$imei = trim((string) ($row['imei'] ?? ''));
$companyId = (int) ($row['companyId'] ?? 0);
$alive = (int) ($row['alive'] ?? 0);
$updateTime = (int) ($row['updateTime'] ?? 0);
$deleteTime = (int) ($row['deleteTime'] ?? 0);
$activeThreshold = time() - (7 * 86400);
if ($deleteTime > 0) {
$tags[] = ['key' => 'archived', 'label' => '已归档', 'variant' => 'secondary'];
}
if ($companyId <= 0) {
$tags[] = ['key' => 'unassigned', 'label' => '未归属', 'variant' => 'destructive'];
}
if ($imei === '') {
$tags[] = ['key' => 'no_imei', 'label' => '无IMEI', 'variant' => 'destructive'];
}
if ($imei !== '' && isset($imeiDupes[$imei])) {
$tags[] = ['key' => 'multi_bind', 'label' => '多项目绑定', 'variant' => 'destructive'];
}
$hasAbnormal = !empty(array_intersect(
array_column($tags, 'key'),
['unassigned', 'no_imei', 'multi_bind']
));
if (!$hasAbnormal) {
if ($alive === 1 || $updateTime >= $activeThreshold) {
$tags[] = ['key' => 'active', 'label' => '现网', 'variant' => 'default'];
} else {
$tags[] = ['key' => 'historical', 'label' => '历史', 'variant' => 'secondary'];
}
}
return $tags;
}
/**
* @param array<int, array{key:string,label:string,variant:string}> $deviceTags
*/
protected function isAbnormalDeviceTag(array $deviceTags): bool
{
foreach ($deviceTags as $tag) {
if (in_array($tag['key'], ['unassigned', 'no_imei', 'multi_bind'], true)) {
return true;
}
}
return false;
}
/** 内容库高频调用阈值(>= 视为高频) */
const CONTENT_HIGH_FREQ = 10;
/**
* 内容库标签
*
* @return array<int, array{key:string,label:string,variant:string}>
*/
protected function computeLibraryTags(int $itemCount, int $callCount, int $status): array
{
$tags = [];
if ($itemCount === 0) {
$tags[] = ['key' => 'empty', 'label' => '空库', 'variant' => 'destructive'];
}
if ($callCount === 0) {
$tags[] = ['key' => 'zero_call', 'label' => '零调用', 'variant' => 'secondary'];
} elseif ($callCount >= self::CONTENT_HIGH_FREQ) {
$tags[] = ['key' => 'high_freq', 'label' => '高频', 'variant' => 'default'];
}
if ($status !== 1) {
$tags[] = ['key' => 'disabled', 'label' => '禁用', 'variant' => 'secondary'];
}
return $tags;
}
/** 全平台内容库 · 按项目分组 */
public function listContentLibrariesGrouped(string $keyword = '', string $classification = ''): array
{
$query = Db::name('content_library')->alias('cl')
->leftJoin('company c', 'c.companyId = cl.companyId')
->where('cl.isDel', 0);
if ($keyword !== '') {
$like = '%' . $keyword . '%';
$query->where(function ($q) use ($like) {
$q->whereLike('cl.name', $like)->whereOr('c.name', 'like', $like);
});
}
$rows = $query
->field('cl.id,cl.name,cl.status,cl.companyId,cl.createTime,c.name as projectName,c.id as projectId')
->order('cl.id', 'desc')
->select();
$libraryIds = [];
foreach ($rows ?: [] as $row) {
$libraryIds[] = (int) $row['id'];
}
$itemCounts = [];
$callStats = [];
$assignedMap = (new ContentLibraryAssignService())->assignedCountMap($libraryIds);
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 $c) {
$itemCounts[(int) $c['libraryId']] = (int) $c['count'];
}
try {
$calls = Db::name('content_library_external_log')
->field('libraryId, COUNT(*) as callCount, MAX(createTime) as lastCallTime')
->whereIn('libraryId', $libraryIds)
->group('libraryId')
->select();
foreach ($calls ?: [] as $c) {
$callStats[(int) $c['libraryId']] = [
'callCount' => (int) $c['callCount'],
'lastCallTime' => (int) $c['lastCallTime'],
];
}
} catch (\Throwable $e) {
// 迁移未执行时忽略
}
}
$groups = [];
foreach ($rows ?: [] as $row) {
$cid = (int) $row['companyId'];
if (!isset($groups[$cid])) {
$groups[$cid] = [
'companyId' => $cid,
'projectId' => (int) ($row['projectId'] ?? 0),
'projectName' => $row['projectName'] ?: "项目#{$cid}",
'libTotal' => 0,
'libEnabled' => 0,
'libraries' => [],
];
}
$groups[$cid]['libTotal']++;
if ((int) $row['status'] === 1) {
$groups[$cid]['libEnabled']++;
}
$libId = (int) $row['id'];
$call = $callStats[$libId] ?? ['callCount' => 0, 'lastCallTime' => 0];
$itemCount = $itemCounts[$libId] ?? 0;
$callCount = (int) ($call['callCount'] ?? 0);
$status = (int) $row['status'];
$libTags = $this->computeLibraryTags($itemCount, $callCount, $status);
$groups[$cid]['libraries'][] = [
'id' => $libId,
'name' => $row['name'],
'status' => $status,
'itemCount' => $itemCount,
'callCount' => $callCount,
'lastCallTime' => !empty($call['lastCallTime'])
? date('Y-m-d H:i:s', (int) $call['lastCallTime']) : '',
'createTime' => $this->formatTime($row['createTime']),
'libTags' => $libTags,
'assignedCount' => $assignedMap[$libId] ?? 0,
];
}
// 分类筛选(库级标签命中即保留该库;组内无库则剔除该组)
if ($classification !== '' && $classification !== 'all') {
foreach ($groups as $cid => $group) {
$kept = array_values(array_filter($group['libraries'], function ($lib) use ($classification) {
foreach ($lib['libTags'] as $t) {
if ($t['key'] === $classification) {
return true;
}
}
return false;
}));
if (empty($kept)) {
unset($groups[$cid]);
} else {
$groups[$cid]['libraries'] = $kept;
}
}
}
// 组内按调用次数降序
$zeroCallTotal = 0;
$emptyLibTotal = 0;
$highFreqTotal = 0;
$libTotalAll = 0;
foreach ($groups as $cid => $group) {
usort($groups[$cid]['libraries'], fn ($a, $b) => $b['callCount'] <=> $a['callCount']);
foreach ($groups[$cid]['libraries'] as $lib) {
$libTotalAll++;
if ($lib['callCount'] === 0) {
$zeroCallTotal++;
}
if ($lib['itemCount'] === 0) {
$emptyLibTotal++;
}
if ($lib['callCount'] >= self::CONTENT_HIGH_FREQ) {
$highFreqTotal++;
}
}
}
$groupList = array_values($groups);
usort($groupList, fn ($a, $b) => $b['libTotal'] <=> $a['libTotal']);
return [
'groups' => $groupList,
'summary' => [
'projects' => count($groupList),
'libraries' => $libTotalAll,
'zeroCall' => $zeroCallTotal,
'emptyLib' => $emptyLibTotal,
'highFreq' => $highFreqTotal,
],
];
}
protected function deviceStatsByCompany(array $companyIds): array
{
if (empty($companyIds)) {
return [];
}
$rows = Db::name('device')
->whereIn('companyId', $companyIds)
->where('deleteTime', 0)
->field('companyId, COUNT(*) as total, SUM(alive=1) as online')
->group('companyId')
->select();
$map = [];
foreach ($rows as $row) {
$map[(int) $row['companyId']] = [
'total' => (int) $row['total'],
'online' => (int) $row['online'],
];
}
return $map;
}
protected function customerStatsByCompany(array $companyIds): array
{
if (empty($companyIds)) {
return [];
}
$rows = Db::name('traffic_pool_company')->alias('tpc')
->whereIn('tpc.companyId', $companyIds)
->where('tpc.isDel', 0);
TrafficPoolSystemIdentifierService::applyExcludeToCompanyQuery($rows, 'tpc');
$rows = $rows->field('tpc.companyId, COUNT(*) as cnt')->group('tpc.companyId')->select();
$map = [];
foreach ($rows as $row) {
$map[(int) $row['companyId']] = (int) $row['cnt'];
}
return $map;
}
protected function subUserStatsByCompany(array $companyIds): array
{
if (empty($companyIds)) {
return [];
}
$masterMap = [];
foreach ($companyIds as $cid) {
$masterMap[(int) $cid] = ProjectContextService::primaryMasterUserId((int) $cid);
}
$rows = Db::name('users')
->whereIn('companyId', $companyIds)
->where('deleteTime', 0)
->field('companyId, id')
->select();
$map = [];
foreach ($companyIds as $cid) {
$map[(int) $cid] = 0;
}
foreach ($rows ?: [] as $row) {
$cid = (int) $row['companyId'];
$masterId = $masterMap[$cid] ?? 0;
if ($masterId > 0 && (int) $row['id'] === $masterId) {
continue;
}
if (isset($map[$cid])) {
$map[$cid]++;
}
}
return $map;
}
protected function loginEndpoints(): array
{
return [
'cunkebao' => 'http://localhost:3100',
'touchkebao' => 'http://localhost:3101',
'superadmin' => 'http://localhost:3103',
];
}
protected function formatTime($value): string
{
if (empty($value)) {
return '';
}
return is_numeric($value) ? date('Y-m-d H:i:s', (int) $value) : (string) $value;
}
}