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

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

595 lines
24 KiB
PHP

<?php
namespace app\command;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\console\input\Option;
use think\Db;
/**
* 旧版 Java 存客宝 → cunkebao_v3 数据补全迁移
*
* php think migrate:legacyCkb --step=report
* php think migrate:legacyCkb --step=fixCompany
* php think migrate:legacyCkb --step=fixTasks
* php think migrate:legacyCkb --step=customers --batch=3000
* php think migrate:legacyCkb --step=content
* php think migrate:legacyCkb --step=tags
*/
class MigrateLegacyCkbCommand extends Command
{
private array $cfg = [];
private ?\PDO $src = null;
private array $companyMap = [];
private array $taskIdMap = []; // legacyTaskId => new task id
protected function configure()
{
$this->setName('migrate:legacyCkb')
->setDescription('旧版存客宝云端数据迁移/补全到 cunkebao_v3')
->addOption('step', 's', Option::VALUE_REQUIRED, 'report|fixCompany|fixTasks|customers|content|tags|all', 'report')
->addOption('batch', 'b', Option::VALUE_OPTIONAL, '客资每批条数', 5000)
->addOption('legacy-task', null, Option::VALUE_OPTIONAL, '仅处理指定旧任务 lId', null)
->addOption('dry-run', null, Option::VALUE_NONE, '只统计不写库')
->addOption('material-limit', null, Option::VALUE_OPTIONAL, '每个内容库最多迁移素材条数', 2000);
}
protected function execute(Input $input, Output $output)
{
$this->cfg = include dirname(__DIR__, 2) . '/config/legacy_source.php';
$step = $input->getOption('step') ?: 'report';
$dry = (bool)$input->getOption('dry-run');
$this->src = $this->connectSource();
$this->companyMap = $this->buildCompanyMap();
$this->loadTaskIdMap();
$steps = $step === 'all'
? ['report', 'fixCompany', 'fixTasks', 'customers', 'content', 'tags']
: [$step];
foreach ($steps as $s) {
$output->writeln("<info>=== step: {$s} ===</info>");
switch ($s) {
case 'report':
$this->stepReport($output);
break;
case 'fixCompany':
$this->stepFixCompany($output, $dry);
break;
case 'fixTasks':
$this->stepFixTasks($output, $dry);
break;
case 'customers':
$this->stepCustomers($output, $dry, (int)$input->getOption('batch'), $input->getOption('legacy-task'));
break;
case 'content':
$this->stepContent($output, $dry, (int)$input->getOption('material-limit'));
break;
case 'tags':
$this->stepTags($output, $dry);
break;
default:
$output->writeln("<error>未知 step: {$s}</error>");
return 1;
}
}
return 0;
}
private function connectSource(): \PDO
{
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$this->cfg['host'],
$this->cfg['port'],
$this->cfg['database'],
$this->cfg['charset']
);
return new \PDO($dsn, $this->cfg['username'], $this->cfg['password'], [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
]);
}
private function buildCompanyMap(): array
{
$map = $this->cfg['company_map'] ?? [];
$accounts = $this->src->query('SELECT lId, sName FROM Account')->fetchAll();
$local = Db::name('company')->where('deleteTime', 0)->column('id', 'name');
foreach ($accounts as $row) {
$lid = (int)$row['lId'];
if (isset($map[$lid])) {
continue;
}
$name = trim((string)$row['sName']);
if ($name !== '' && isset($local[$name])) {
$map[$lid] = (int)$local[$name];
}
}
return $map;
}
private function resolveCompanyId(int $legacyAccountId): int
{
if (isset($this->companyMap[$legacyAccountId])) {
return (int)$this->companyMap[$legacyAccountId];
}
$stmt = $this->src->prepare('SELECT sName FROM Account WHERE lId = ?');
$stmt->execute([$legacyAccountId]);
$name = trim((string)($stmt->fetchColumn() ?: ''));
if ($name !== '') {
$id = Db::name('company')->where(['name' => $name, 'deleteTime' => 0])->value('id');
if ($id) {
$this->companyMap[$legacyAccountId] = (int)$id;
return (int)$id;
}
}
return 0;
}
private function loadTaskIdMap(): void
{
$rows = Db::name('customer_acquisition_task')
->where('isLegacy', 1)
->whereNotNull('legacyId')
->column('id', 'legacyId');
foreach ($rows as $legacyId => $id) {
$this->taskIdMap[(int)$legacyId] = (int)$id;
}
}
private function mapSceneId(?string $typeId): int
{
$key = strtolower(trim((string)$typeId));
return (int)($this->cfg['scene_map'][$key] ?? 9);
}
private function stepReport(Output $output): void
{
$remoteTasks = (int)$this->src->query('SELECT COUNT(*) FROM FriendRequestTask')->fetchColumn();
$remoteDetails = (int)$this->src->query('SELECT COUNT(*) FROM FriendRequestTaskDetail')->fetchColumn();
$remoteLibs = (int)$this->src->query('SELECT COUNT(*) FROM MaterialLib')->fetchColumn();
$remoteMaterials = (int)$this->src->query('SELECT COUNT(*) FROM Material')->fetchColumn();
$remoteLabels = (int)$this->src->query('SELECT COUNT(*) FROM WeChatFriendLabelLib')->fetchColumn();
$localTasks = Db::name('customer_acquisition_task')->count();
$localLegacy = Db::name('customer_acquisition_task')->where('isLegacy', 1)->count();
$localCustomers = Db::name('task_customer')->count();
$localLibs = Db::name('content_library')->where('isDel', 0)->count();
$localBadCo = Db::name('customer_acquisition_task')->where('isLegacy', 1)->where('companyId', 0)->count();
$output->writeln("云端 FriendRequestTask: {$remoteTasks}");
$output->writeln("云端 FriendRequestTaskDetail: {$remoteDetails}");
$output->writeln("云端 MaterialLib / Material: {$remoteLibs} / {$remoteMaterials}");
$output->writeln("云端 WeChatFriendLabelLib: {$remoteLabels}");
$output->writeln("本地 获客计划 / legacy: {$localTasks} / {$localLegacy}");
$output->writeln("本地 客资: {$localCustomers}");
$output->writeln("本地 内容库: {$localLibs}");
$output->writeln("本地 legacy 且 companyId=0: {$localBadCo}");
$output->writeln('公司映射: ' . json_encode($this->companyMap, JSON_UNESCAPED_UNICODE));
}
private function stepFixCompany(Output $output, bool $dry): void
{
$fixed = 0;
$tasks = Db::name('customer_acquisition_task')->where('isLegacy', 1)->select();
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 = $this->resolveCompanyId($accId);
if ($companyId <= 0) {
$output->writeln("<comment>跳过 task legacy={$legacyId} 无公司映射 AccountId={$accId}</comment>");
continue;
}
if ((int)$t['companyId'] === $companyId) {
continue;
}
if (!$dry) {
Db::name('customer_acquisition_task')->where('id', $t['id'])->update([
'companyId' => $companyId,
'updateTime' => time(),
]);
}
$fixed++;
}
$output->writeln($dry ? "将修复 companyId: {$fixed}" : "已修复 companyId: {$fixed}");
}
private function stepFixTasks(Output $output, bool $dry): void
{
$fixed = 0;
foreach ($this->taskIdMap as $legacyId => $newId) {
$stmt = $this->src->prepare(
'SELECT lId,sName,TypeId,sKey,sHello,sTheme,sTip,PosterId,AccountId,bEnable FROM FriendRequestTask WHERE lId = ?'
);
$stmt->execute([$legacyId]);
$old = $stmt->fetch();
if (!$old) {
continue;
}
$sceneConf = json_decode(
(string)Db::name('customer_acquisition_task')->where('id', $newId)->value('sceneConf'),
true
) ?: [];
$sceneConf['tips'] = $old['sTip'] ?? ($sceneConf['tips'] ?? '');
$sceneConf['greeting'] = $old['sHello'] ?? ($sceneConf['greeting'] ?? '');
$sceneConf['theme'] = $old['sTheme'] ?? ($sceneConf['theme'] ?? '');
$sceneConf['legacyTypeId'] = $old['TypeId'] ?? '';
$posterUrl = $this->resolvePosterUrl((int)($old['PosterId'] ?? 0));
if ($posterUrl) {
$sceneConf['posters'] = [['url' => $posterUrl, 'name' => $old['sName'] ?? '']];
}
$mini = $this->resolveMiniProgramForTask($legacyId);
if ($mini) {
$sceneConf['miniprogram'] = $mini;
}
$update = [
'sceneId' => $this->mapSceneId($old['TypeId'] ?? ''),
'sceneConf' => json_encode($sceneConf, JSON_UNESCAPED_UNICODE),
'companyId' => $this->resolveCompanyId((int)$old['AccountId']),
'status' => ((int)($old['bEnable'] ?? 0)) === 1 ? 1 : 0,
'updateTime' => time(),
];
if (!empty($old['sKey'])) {
$update['apiKey'] = $old['sKey'];
}
if (!$dry) {
Db::name('customer_acquisition_task')->where('id', $newId)->update($update);
}
$fixed++;
}
$output->writeln($dry ? "将修复计划配置: {$fixed}" : "已修复计划配置: {$fixed}");
}
private function resolvePosterUrl(int $posterId): ?string
{
if ($posterId <= 0) {
return null;
}
$stmt = $this->src->prepare(
'SELECT a.sCdnUrl FROM Poster p LEFT JOIN SysAttach a ON p.sThumb = a.lId WHERE p.lId = ? LIMIT 1'
);
$stmt->execute([$posterId]);
$url = $stmt->fetchColumn();
return $url ? (string)$url : null;
}
private function resolveMiniProgramForTask(int $legacyTaskId): ?array
{
$stmt = $this->src->prepare(
'SELECT m.sName, m.sContent FROM Material m
INNER JOIN FriendRequestTask t ON t.lId = ?
WHERE m.TypeId = 5 LIMIT 1'
);
$stmt->execute([$legacyTaskId]);
$row = $stmt->fetch();
if (!$row || empty($row['sContent'])) {
return null;
}
$content = json_decode($row['sContent'], true);
if (!is_array($content)) {
return null;
}
return [
'title' => $content['title'] ?? $row['sName'],
'appId' => $content['miniprogramId'] ?? ($content['weappinfo']['appid'] ?? ''),
'pagePath' => $content['pagepath'] ?? ($content['weappinfo']['pagepath'] ?? ''),
'raw' => $content,
];
}
private function mapDetailStatus(array $row): int
{
if (!empty($row['dPassedTime']) && $row['dPassedTime'] !== '0000-00-00 00:00:00') {
return 4;
}
if (!empty($row['dFailedTime']) && $row['dFailedTime'] !== '0000-00-00 00:00:00') {
return 3;
}
if (!empty($row['dSendTime'])) {
return 1;
}
return 0;
}
private function stepCustomers(Output $output, bool $dry, int $batch, ?string $onlyLegacyTask): void
{
$legacyFilter = $onlyLegacyTask !== null ? [(int)$onlyLegacyTask] : array_keys($this->taskIdMap);
$inserted = 0;
$skipped = 0;
foreach ($legacyFilter as $legacyTaskId) {
$newTaskId = $this->taskIdMap[$legacyTaskId] ?? null;
if (!$newTaskId) {
continue;
}
$existingPhones = Db::name('task_customer')
->where('task_id', $newTaskId)
->column('phone');
$existingSet = array_flip($existingPhones);
$offset = 0;
while (true) {
$stmt = $this->src->prepare(
'SELECT lId,sPhoneOrWechatId,StatusId,dSendTime,dPassedTime,dFailedTime,sFailMessage,dNewTime
FROM FriendRequestTaskDetail
WHERE FriendRequestTaskId = :tid
ORDER BY lId ASC LIMIT :lim OFFSET :off'
);
$stmt->bindValue(':tid', $legacyTaskId, \PDO::PARAM_INT);
$stmt->bindValue(':lim', $batch, \PDO::PARAM_INT);
$stmt->bindValue(':off', $offset, \PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll();
if (!$rows) {
break;
}
$batchInsert = [];
foreach ($rows as $row) {
$rawPhone = trim((string)($row['sPhoneOrWechatId'] ?? ''));
if ($rawPhone === '') {
$skipped++;
continue;
}
$phone = mb_substr($rawPhone, 0, 50);
if (isset($existingSet[$phone])) {
$skipped++;
continue;
}
$remark = strlen($rawPhone) > 50 ? $rawPhone : '';
$createTs = $row['dNewTime'] ? strtotime($row['dNewTime']) : time();
$addTs = $row['dSendTime'] ? strtotime($row['dSendTime']) : 0;
$passTs = $row['dPassedTime'] ? strtotime($row['dPassedTime']) : 0;
$batchInsert[] = [
'task_id' => $newTaskId,
'channelId' => 0,
'name' => '',
'source' => 'legacy_import',
'phone' => $phone,
'remark' => mb_substr($remark, 0, 100),
'tags' => null,
'siteTags' => null,
'processed_wechat_ids' => '',
'status' => $this->mapDetailStatus($row),
'fail_reason' => (string)($row['sFailMessage'] ?? ''),
'addTime' => $addTs ?: 0,
'passTime' => $passTs ?: 0,
'createTime' => $createTs,
'updateTime' => $createTs,
];
$existingSet[$phone] = true;
}
if (!$dry && $batchInsert) {
Db::name('task_customer')->insertAll($batchInsert);
}
$inserted += count($batchInsert);
$offset += $batch;
$output->writeln(" legacy task {$legacyTaskId} -> {$newTaskId}: +{$inserted} (skip {$skipped}) offset {$offset}");
}
}
$output->writeln($dry ? "将新增客资约 {$inserted}" : "已新增客资 {$inserted} 条,跳过 {$skipped}");
}
private function stepContent(Output $output, bool $dry, int $materialLimit = 2000): void
{
$libs = $this->src->query(
'SELECT lib.lId, lib.sName, lib.sKeyWord, lib.sExclude, lib.sConfigJson, lib.sCollectObjectJson,
lib.bAIEnable, lib.sAIRequire, lib.NewUserId, u.AccountId
FROM MaterialLib lib
LEFT JOIN SysUser u ON lib.NewUserId = u.lID'
)->fetchAll();
$libCreated = 0;
$itemCreated = 0;
foreach ($libs as $lib) {
$legacyLibId = (int)$lib['lId'];
$exists = Db::name('content_library')->where('legacyLibId', $legacyLibId)->find();
if ($exists) {
$libraryId = (int)$exists['id'];
} else {
$companyId = $this->resolveCompanyId((int)($lib['AccountId'] ?? 0));
if ($companyId <= 0) {
$companyId = 914;
}
$row = [
'formType' => 0,
'sourceType' => 1,
'name' => $lib['sName'] ?: ('旧库' . $legacyLibId),
'keywordInclude' => $lib['sKeyWord'] ? json_encode([$lib['sKeyWord']]) : null,
'keywordExclude' => $lib['sExclude'] ? json_encode([$lib['sExclude']]) : null,
'aiEnabled' => (int)($lib['bAIEnable'] ?? 0),
'aiPrompt' => $lib['sAIRequire'] ?? '',
'status' => 1,
'userId' => (int)($lib['NewUserId'] ?? 0),
'companyId' => $companyId,
'legacyLibId' => $legacyLibId,
'createTime' => time(),
'updateTime' => time(),
'isDel' => 0,
];
if ($dry) {
$libCreated++;
continue;
}
$libraryId = (int)Db::name('content_library')->insertGetId($row);
$libCreated++;
}
$mStmt = $this->src->prepare(
'SELECT lId,TypeId,sContent,sContentAI,sPic,sLink,sTitle,sThumb,MaterialLibId,dNewTime,lWechatTime
FROM Material WHERE MaterialLibId = ? ORDER BY lId ASC LIMIT ' . max(1, $materialLimit)
);
$mStmt->execute([$legacyLibId]);
while ($m = $mStmt->fetch()) {
$legacyMatId = (int)$m['lId'];
if (Db::name('content_item')->where('legacyMaterialId', $legacyMatId)->find()) {
continue;
}
$contentType = $this->mapMaterialType((int)($m['TypeId'] ?? 0));
$resUrls = [];
if (!empty($m['sPic'])) {
$resUrls[] = $m['sPic'];
}
if (!empty($m['sThumb']) && is_numeric($m['sThumb'])) {
$url = $this->resolveAttachUrl((int)$m['sThumb']);
if ($url) {
$resUrls[] = $url;
}
}
$item = [
'libraryId' => $libraryId,
'type' => 'moment',
'contentType' => $contentType,
'title' => $m['sTitle'] ?? '',
'content' => $m['sContent'] ?? '',
'contentAi' => $m['sContentAI'] ?? '',
'contentData' => json_encode(['link' => $m['sLink'] ?? '', 'legacyTypeId' => $m['TypeId']], JSON_UNESCAPED_UNICODE),
'resUrls' => $resUrls ? json_encode($resUrls, JSON_UNESCAPED_UNICODE) : null,
'status' => 1,
'legacyMaterialId' => $legacyMatId,
'createTime' => $m['dNewTime'] ? strtotime($m['dNewTime']) : time(),
'updateTime' => time(),
'isDel' => 0,
];
if (!$dry) {
Db::name('content_item')->insert($item);
}
$itemCreated++;
}
}
$output->writeln($dry ? "将新增内容库 {$libCreated} / 素材 {$itemCreated}" : "已新增内容库 {$libCreated} / 素材 {$itemCreated}");
}
private function mapMaterialType(int $typeId): int
{
$map = [1 => 4, 2 => 1, 3 => 3, 5 => 5, 43 => 3];
return $map[$typeId] ?? 0;
}
private function resolveAttachUrl(int $attachId): ?string
{
$stmt = $this->src->prepare('SELECT sCdnUrl FROM SysAttach WHERE lId = ?');
$stmt->execute([$attachId]);
$url = $stmt->fetchColumn();
return $url ? (string)$url : null;
}
private function stepTags(Output $output, bool $dry): void
{
$labels = $this->src->query(
'SELECT lId,sName,FriendRequestTaskId,UpId,ValueTypeId,jListValue,bSys FROM WeChatFriendLabelLib ORDER BY lId'
)->fetchAll();
$catCreated = 0;
$defCreated = 0;
foreach ($labels as $lab) {
$legacyTaskId = (int)($lab['FriendRequestTaskId'] ?? 0);
$companyId = 914;
if ($legacyTaskId > 0) {
$stmt = $this->src->prepare('SELECT AccountId FROM FriendRequestTask WHERE lId = ?');
$stmt->execute([$legacyTaskId]);
$companyId = $this->resolveCompanyId((int)$stmt->fetchColumn()) ?: 914;
}
$catName = '旧版_' . ($lab['sName'] ?: ('标签' . $lab['lId']));
$catCode = 'legacy_cat_' . (int)$lab['lId'];
$cat = Db::name('traffic_pool_tag_category')
->where(['companyId' => $companyId, 'categoryCode' => $catCode])
->find();
if (!$cat) {
if (!$dry) {
$catId = Db::name('traffic_pool_tag_category')->insertGetId([
'companyId' => $companyId,
'parentId' => 0,
'tagType' => 2,
'categoryCode' => $catCode,
'categoryName' => $catName,
'sort' => 0,
'status' => 1,
'createTime' => time(),
'updateTime' => time(),
'isDel' => 0,
]);
} else {
$catId = 0;
}
$catCreated++;
} else {
$catId = (int)$cat['id'];
}
$values = [];
if (!empty($lab['jListValue'])) {
$list = json_decode($lab['jListValue'], true);
if (is_array($list)) {
foreach ($list as $v) {
$values[] = is_array($v) ? ($v['name'] ?? $v['value'] ?? '') : (string)$v;
}
}
}
if (!$values) {
$values = [$lab['sName']];
}
foreach ($values as $val) {
$val = trim((string)$val);
if ($val === '') {
continue;
}
$exists = Db::name('traffic_pool_tag_define')
->where(['companyId' => $companyId, 'tagName' => $val, 'categoryId' => $catId ?? 0])
->find();
if ($exists) {
continue;
}
$tagCode = 'legacy_' . (int)$lab['lId'] . '_' . md5($val);
if (!$dry) {
Db::name('traffic_pool_tag_define')->insert([
'companyId' => $companyId,
'categoryId' => $catId ?? 0,
'tagType' => 2,
'tagCode' => substr($tagCode, 0, 50),
'tagName' => mb_substr($val, 0, 50),
'status' => 1,
'createTime' => time(),
'updateTime' => time(),
'isDel' => 0,
]);
}
$defCreated++;
}
if ($legacyTaskId > 0 && isset($this->taskIdMap[$legacyTaskId]) && !$dry) {
$newId = $this->taskIdMap[$legacyTaskId];
$task = Db::name('customer_acquisition_task')->where('id', $newId)->find();
$tagConf = json_decode($task['tagConf'] ?? '{}', true) ?: [];
$tagConf['legacyLabels'] = $tagConf['legacyLabels'] ?? [];
$tagConf['legacyLabels'][] = [
'legacyLibId' => (int)$lab['lId'],
'name' => $lab['sName'],
'values' => $values,
];
Db::name('customer_acquisition_task')->where('id', $newId)->update([
'tagConf' => json_encode($tagConf, JSON_UNESCAPED_UNICODE),
'updateTime' => time(),
]);
}
}
$output->writeln($dry ? "将新增标签类目 {$catCreated} / 定义 {$defCreated}" : "已新增标签类目 {$catCreated} / 定义 {$defCreated}");
}
}