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('未连云端,仅做本地规则归并'); $this->src = null; } foreach (($step === 'all' ? ['report', 'fixZero', 'merge', 'devices'] : [$step]) as $s) { $output->writeln("=== {$s} ==="); 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(' 跳过(冲突): device ' . $sk['deviceId'] . ' memo=' . $sk['memo'] . ' 已绑 company=' . $sk['companyId'] . ''); } $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('需要连云端修复 companyId=0 的 legacy 计划'); 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; } }