Files
cunkebao_v3/Server/application/superadmin/service/ProjectWorkspaceService.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

579 lines
22 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\CompanyFeatureSwitchService;
use app\common\service\TrafficPoolSystemIdentifierService;
use app\cunkebao\service\PlanSceneCatalogService;
use think\Db;
/**
* 超管 · 以项目为中心的工作台(只读存客宝/触客宝同源数据表,不改 cunkebao/chukebao 代码)
*/
class ProjectWorkspaceService
{
public function getWorkspace(int $projectId): array
{
$ctx = ProjectContextService::resolveByProjectId($projectId);
$companyId = $ctx['companyId'];
$master = Db::name('users')
->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'];
}
}