Files
cunkebao_v3/Server/application/command/ConsolidateLocalProjectsCommand.php
Manus AI 5517457929 sync: 以本地为准同步全量变更至 GitHub
含四端需求文档、前端/后端/超管/触客宝迭代及部署脚本更新;未拉取远程。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 04:24:56 +08:00

416 lines
16 KiB
PHP
Raw 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\command;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\console\input\Option;
use think\Db;
/**
* 旧版数据迁入后:按项目归并到本地公司(仅本机,不上云)
*
* php think consolidate:localProjects --step=report
* php think consolidate:localProjects --step=all
*/
class ConsolidateLocalProjectsCommand extends Command
{
private array $cfg = [];
private ?\PDO $src = null;
protected function configure()
{
$this->setName('consolidate:localProjects')
->setDescription('本地项目归并:老坑爹/黑科技/魔兽→2130卡若系→914')
->addOption('step', 's', Option::VALUE_REQUIRED, 'report|fixZero|merge|devices|all', 'report')
->addOption('dry-run', null, Option::VALUE_NONE, '只统计');
}
protected function execute(Input $input, Output $output)
{
$this->cfg = include dirname(__DIR__, 2) . '/config/legacy_source.php';
$dry = (bool)$input->getOption('dry-run');
$step = $input->getOption('step') ?: 'report';
try {
$this->src = $this->connectSource();
} catch (\Throwable $e) {
$output->writeln('<comment>未连云端,仅做本地规则归并</comment>');
$this->src = null;
}
foreach (($step === 'all' ? ['report', 'fixZero', 'merge', 'devices'] : [$step]) as $s) {
$output->writeln("<info>=== {$s} ===</info>");
if ($s === 'report') {
$this->stepReport($output);
} elseif ($s === 'fixZero') {
$this->stepFixZeroCompany($output, $dry);
} elseif ($s === 'merge') {
$this->stepMerge($output, $dry);
} elseif ($s === 'devices') {
$this->stepDevices($output, $dry);
}
}
return 0;
}
/**
* 设备 + 微信登录归并(需求五 §5.2/5.6
* 规则ck_device.memo 命中 task_name_like 关键词 → 归到 target_id
* 冲突保护设备已绑其他活跃项目companyId 非 0/非 target/不在 absorb→ 跳过并报告。
*/
private function stepDevices(Output $output, bool $dry): void
{
foreach ($this->cfg['consolidate_groups'] as $key => $g) {
$targetId = (int)$g['target_id'];
$keywords = array_map(fn($s) => trim($s, '%'), $g['task_name_like'] ?? []);
$keywords = array_values(array_filter($keywords));
if (empty($keywords)) {
$output->writeln("{$key}: 无设备关键词,跳过");
continue;
}
$absorb = array_map('intval', $g['absorb_company_ids'] ?? []);
// memo 命中关键词的设备
$q = Db::name('device')->where('deleteTime', 0)->where(function ($query) use ($keywords) {
foreach ($keywords as $kw) {
$query->whereOr('memo', 'like', '%' . $kw . '%');
}
});
$devices = $q->field('id,memo,companyId')->select();
$migrated = 0;
$skipped = [];
$wechatLoginUpdated = 0;
foreach ($devices as $d) {
$cid = (int)$d['companyId'];
if ($cid === $targetId) {
continue; // 已归属
}
// 冲突保护绑定到其他活跃项目非0/非target/不在absorb→ 跳过
if ($cid > 0 && $cid !== $targetId && !in_array($cid, $absorb, true)) {
$skipped[] = ['deviceId' => (int)$d['id'], 'memo' => $d['memo'], 'companyId' => $cid];
continue;
}
if (!$dry) {
Db::name('device')->where('id', $d['id'])->update([
'companyId' => $targetId,
'updateTime' => time(),
]);
try {
$wechatLoginUpdated += (int)Db::name('device_wechat_login')
->where('deviceId', $d['id'])
->update(['companyId' => $targetId]);
} catch (\Throwable $e) {
// device_wechat_login 表/字段差异不阻断
}
}
$migrated++;
}
$output->writeln(json_encode([
'group' => $key,
'target' => $targetId,
'matched' => count($devices),
'migrated' => $migrated,
'wechatLoginUpdated' => $wechatLoginUpdated,
'skipped' => count($skipped),
'dryRun' => $dry,
], JSON_UNESCAPED_UNICODE));
foreach ($skipped as $sk) {
$output->writeln(' <comment>跳过(冲突): device ' . $sk['deviceId']
. ' memo=' . $sk['memo'] . ' 已绑 company=' . $sk['companyId'] . '</comment>');
}
$this->backfillDeviceWechatLogin($output, $targetId, $dry);
}
}
/**
* 补 ck_device_wechat_login按 ck_device.imei → s2_device → s2_wechat_account.wechatId
* 解决老坑爹/油条姐等同项目微信号侧栏缺失M1
*/
private function backfillDeviceWechatLogin(Output $output, int $targetId, bool $dry): void
{
$devices = Db::name('device')
->where('companyId', $targetId)
->where('deleteTime', 0)
->field('id,imei,companyId,memo')
->select();
$inserted = 0;
$updated = 0;
$skipped = 0;
$now = time();
foreach ($devices as $d) {
$imei = trim((string)($d['imei'] ?? ''));
if ($imei === '') {
continue;
}
try {
$s2DeviceId = Db::table('s2_device')->where('imei', $imei)->value('id');
if (!$s2DeviceId) {
continue;
}
$accounts = Db::table('s2_wechat_account')
->where('currentDeviceId', $s2DeviceId)
->field('wechatId,wechatAlive,alias,nickname')
->select();
} catch (\Throwable $e) {
continue;
}
foreach ($accounts as $acc) {
$wechatId = trim((string)($acc['wechatId'] ?? ''));
if ($wechatId === '') {
continue;
}
$exists = Db::name('device_wechat_login')
->where('deviceId', $d['id'])
->where('wechatId', $wechatId)
->find();
if ($exists) {
$skipped++;
if (!$dry && (int)($exists['companyId'] ?? 0) !== $targetId) {
Db::name('device_wechat_login')->where('id', $exists['id'])->update([
'companyId' => $targetId,
'updateTime' => $now,
]);
$updated++;
}
continue;
}
if (!$dry) {
Db::name('device_wechat_login')->insert([
'deviceId' => (int)$d['id'],
'wechatId' => $wechatId,
'alive' => (int)($acc['wechatAlive'] ?? 0),
'companyId' => $targetId,
'createTime' => $now,
'updateTime' => $now,
'isTips' => 0,
]);
}
$inserted++;
}
}
$output->writeln(json_encode([
'backfillWechatLogin' => true,
'target' => $targetId,
'devicesScanned' => count($devices),
'inserted' => $inserted,
'companyIdUpdated' => $updated,
'skippedExisting' => $skipped,
'dryRun' => $dry,
], JSON_UNESCAPED_UNICODE));
}
private function connectSource(): \PDO
{
$c = $this->cfg;
$dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $c['host'], $c['port'], $c['database']);
return new \PDO($dsn, $c['username'], $c['password'], [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
}
private function stepReport(Output $output): void
{
foreach ($this->cfg['consolidate_groups'] as $key => $g) {
$tid = (int)$g['target_id'];
$plans = (int)Db::name('customer_acquisition_task')->where('companyId', $tid)->where('deleteTime', 0)->count();
$cust = (int)Db::query(
'SELECT COUNT(*) c FROM ck_task_customer c INNER JOIN ck_customer_acquisition_task t ON c.task_id=t.id WHERE t.companyId=?',
[$tid]
)[0]['c'];
$libs = (int)Db::name('content_library')->where(['companyId' => $tid, 'isDel' => 0])->count();
$output->writeln("{$key} → company {$tid}: 计划 {$plans} | 客资 {$cust} | 内容库 {$libs}");
}
$zero = (int)Db::name('customer_acquisition_task')->whereRaw('companyId=0')->count();
$output->writeln("companyId=0 计划: {$zero}");
}
private function stepFixZeroCompany(Output $output, bool $dry): void
{
if (!$this->src) {
$output->writeln('<error>需要连云端修复 companyId=0 的 legacy 计划</error>');
return;
}
$map = $this->cfg['company_map'] ?? [];
$tasks = Db::name('customer_acquisition_task')->whereRaw('companyId=0')->where('isLegacy', 1)->select();
$fixed = 0;
foreach ($tasks as $t) {
$legacyId = (int)$t['legacyId'];
$stmt = $this->src->prepare('SELECT AccountId FROM FriendRequestTask WHERE lId=?');
$stmt->execute([$legacyId]);
$accId = (int)$stmt->fetchColumn();
$companyId = (int)($map[$accId] ?? 0);
$companyId = $companyId > 0 ? $companyId : $this->resolveByGroupRules($accId, (string)$t['name']);
if ($companyId <= 0) {
$companyId = 914;
}
if (!$dry) {
Db::name('customer_acquisition_task')->where('id', $t['id'])->update([
'companyId' => $companyId,
'updateTime' => time(),
]);
}
$fixed++;
}
$output->writeln($dry ? "将修复 {$fixed} 条 companyId=0" : "已修复 {$fixed} 条 companyId=0");
}
private function resolveByGroupRules(int $accountId, string $taskName): int
{
foreach ($this->cfg['consolidate_groups'] as $g) {
if (in_array($accountId, $g['legacy_account_ids'] ?? [], true)) {
return (int)$g['target_id'];
}
foreach ($g['task_name_like'] ?? [] as $like) {
$needle = trim($like, '%');
if ($needle !== '' && mb_strpos($taskName, $needle) !== false) {
return (int)$g['target_id'];
}
}
}
return 0;
}
private function stepMerge(Output $output, bool $dry): void
{
$now = time();
foreach ($this->cfg['consolidate_groups'] as $key => $g) {
$targetId = (int)$g['target_id'];
$fromIds = array_map('intval', $g['absorb_company_ids'] ?? []);
$taskIds = $this->collectTaskIdsToMove($g, $fromIds, $targetId);
if (!$dry) {
Db::name('company')->where('id', $targetId)->update([
'memo' => $g['memo'] ?? '',
'updateTime' => $now,
]);
}
$stats = ['plans' => 0, 'libs' => 0, 'pool' => 0, 'tags_cat' => 0, 'users' => 0];
if ($taskIds) {
if (!$dry) {
$stats['plans'] = Db::name('customer_acquisition_task')
->whereIn('id', $taskIds)
->update(['companyId' => $targetId, 'updateTime' => $now]);
} else {
$stats['plans'] = count($taskIds);
}
}
$allFrom = array_unique(array_merge($fromIds, $this->findCompaniesByTaskKeywords($g)));
foreach ($allFrom as $fromId) {
if ($fromId === $targetId || $fromId <= 0) {
continue;
}
$stats = $this->moveCompanyScopedRows($fromId, $targetId, $dry, $stats);
}
// 计划名命中但 company 不在 absorb 列表(如 914 上的魔兽计划 → 2130
$keywordTaskIds = $this->findTaskIdsByKeywordsOnly($g, $targetId);
if ($keywordTaskIds && !$dry) {
Db::name('customer_acquisition_task')->whereIn('id', $keywordTaskIds)
->update(['companyId' => $targetId, 'updateTime' => $now]);
$stats['plans'] += count($keywordTaskIds);
} elseif ($keywordTaskIds) {
$stats['plans'] += count($keywordTaskIds);
}
$onlyAbsorb = array_filter($fromIds, fn($id) => $id > 0 && $id !== $targetId);
foreach ($onlyAbsorb as $fromId) {
if (!$dry) {
Db::name('company')->where('id', $fromId)->update([
'deleteTime' => $now,
'memo' => '已合并至项目' . $targetId . '(' . $key . ')',
'updateTime' => $now,
]);
}
}
$output->writeln(json_encode(['group' => $key, 'target' => $targetId, 'stats' => $stats], JSON_UNESCAPED_UNICODE));
}
}
private function collectTaskIdsToMove(array $g, array $fromIds, int $targetId): array
{
$ids = [];
if ($fromIds) {
$rows = Db::name('customer_acquisition_task')->whereIn('companyId', $fromIds)->column('id');
$ids = array_merge($ids, $rows);
}
return array_values(array_unique($ids));
}
private function findCompaniesByTaskKeywords(array $g): array
{
$q = Db::name('customer_acquisition_task')->where('deleteTime', 0);
$q->where(function ($query) use ($g) {
$first = true;
foreach ($g['task_name_like'] ?? [] as $like) {
if ($first) {
$query->whereLike('name', $like);
$first = false;
} else {
$query->whereOr('name', 'like', $like);
}
}
});
return array_values(array_unique($q->column('companyId')));
}
private function findTaskIdsByKeywordsOnly(array $g, int $targetId): array
{
if (empty($g['task_name_like'])) {
return [];
}
$q = Db::name('customer_acquisition_task')->where('deleteTime', 0)->where('companyId', '<>', $targetId);
$q->where(function ($query) use ($g) {
$first = true;
foreach ($g['task_name_like'] as $like) {
if ($first) {
$query->whereLike('name', $like);
$first = false;
} else {
$query->whereOr('name', 'like', $like);
}
}
});
return $q->column('id');
}
private function moveCompanyScopedRows(int $fromId, int $targetId, bool $dry, array $stats): array
{
$tables = [
'content_library' => 'libs',
'traffic_pool_company' => 'pool',
'traffic_pool_tag_category' => 'tags_cat',
'traffic_pool_tag_define' => 'tags_cat',
'traffic_pool_tag' => 'tags_cat',
'traffic_source' => 'pool',
'wechat_tag' => 'pool',
'plan_tags' => 'pool',
'acquisition_global_settings' => 'pool',
'users' => 'users',
'tokens_company' => 'users',
];
foreach ($tables as $table => $key) {
try {
$cnt = (int)Db::name($table)->where('companyId', $fromId)->count();
if ($cnt > 0 && !$dry) {
Db::name($table)->where('companyId', $fromId)->update(['companyId' => $targetId]);
}
$stats[$key] = ($stats[$key] ?? 0) + $cnt;
} catch (\Throwable $e) {
// 表不存在或字段不同则跳过
}
}
return $stats;
}
}