sync: 以本地为准同步全量变更至 GitHub

含四端需求文档、前端/后端/超管/触客宝迭代及部署脚本更新;未拉取远程。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Manus AI
2026-05-30 04:24:56 +08:00
parent 711783baf5
commit 5517457929
579 changed files with 45472 additions and 8111 deletions

View File

@@ -0,0 +1,85 @@
# 存客宝数据迁移脚本
> 卡若 2026-05-27 · 数据迁移 D1/D2/E1
>
> ⚠️ **仅本地执行**。所有脚本默认 DRY-RUN不执行加 `apply=1` 才真正迁移。
> 触客宝 / AI 数字员工只读,禁止改对方代码。
## 文件
| 文件 | 作用 |
|------|------|
| `snapshot.php` | 采集迁移前/后快照diff 生成差异报表 |
| `migrate_company_attribution.php` | 将指定手机号 / 旧 companyId 下的数据迁移到目标 companyId |
| `runtime/migration/{phone}/` | 每个手机号的快照与报告目录git 不提交) |
## 标准流程
### 1. 迁移前快照
```bash
docker compose exec -T server php /var/www/html/script/migration/snapshot.php \
phone=13779954946 stage=before
```
输出:`Server/runtime/migration/13779954946/snapshot-before-YYYYMMDDHHMMSS.json`
### 2. 拟运行DRY-RUN
```bash
docker compose exec -T server php /var/www/html/script/migration/migrate_company_attribution.php \
sourcePhone=13779954946 fromCompany=15880802661 toCompany=914
```
只打印各表待迁移行数,不会修改数据。
### 3. 卡若审核后真正执行
```bash
docker compose exec -T server php /var/www/html/script/migration/migrate_company_attribution.php \
sourcePhone=13779954946 fromCompany=15880802661 toCompany=914 apply=1
```
### 4. 迁移后快照
```bash
docker compose exec -T server php /var/www/html/script/migration/snapshot.php \
phone=13779954946 stage=after
```
### 5. Diff 生成差异报表
```bash
docker compose exec -T server php /var/www/html/script/migration/snapshot.php \
diff phone=13779954946
```
输出:`runtime/migration/13779954946/diff-YYYYMMDDHHMMSS.json`
## 迁移覆盖表
| 表 | 字段 | 说明 |
|----|------|------|
| `customer_acquisition_task` | `companyId` | 海报 / 订单 / 付款码 / 微信群 / API 获客等场景计划 |
| `user_tokens` | `companyId` | 算力余额 |
| `plan_distribution_channel` | `companyId` | 渠道获客的分销渠道 |
| `plan_distribution_record` | `companyId` | 渠道分销记录 |
| `ckb_lead_records` | `companyId` | 客资入池队列 |
| `content_library` | `companyId` | 内容库 |
| `workbench_*` | `companyId` | 工作台 8 类自动化任务 |
## 卡若验收 Checklist
迁移完成后必须验证:
- [ ] 我的页统计与快照 after 一致
- [ ] 海报获客列表中可见历史计划,状态/数量与迁移前一致
- [ ] 算力余额无丢失
- [ ] 触客宝端只读校验子账号归属正确(**不改对方代码**
- [ ] AI 数字员工只读校验设备/账号能力(**不改对方代码**
## 失败回滚
DRY-RUN 失败:不会动数据。
APPLY 失败:事务自动 rollback。
APPLY 成功但需要逆向:手动写 reverse 脚本(参数 fromCompany ↔ toCompany 互换 + apply=1

View File

@@ -0,0 +1,91 @@
<?php
/**
* 历史客服 privilegeIds 补全脚本
*
* 问题:早期 s2_company_account.privilegeIds 为 [] / 空,触客宝登录缺 S2 菜单
* 默认集与 CustomerServiceController::create 一致
*
* 用法:
* docker compose exec -T server php /var/www/html/script/migration/backfill_kefu_privilege_ids.php
* docker compose exec -T server php /var/www/html/script/migration/backfill_kefu_privilege_ids.php apply=1
* docker compose exec -T server php /var/www/html/script/migration/backfill_kefu_privilege_ids.php companyId=2130 apply=1
*
* 卡若 2026-05-29 · 存客宝_待办 §4.1
*/
declare(strict_types=1);
require __DIR__ . '/../../thinkphp/base.php';
use think\Db;
use think\App;
$app = new App(realpath(__DIR__ . '/../../application'));
$app->initialize();
$argMap = [];
foreach ($argv as $arg) {
if (strpos($arg, '=') !== false) {
list($k, $v) = explode('=', $arg, 2);
$argMap[trim($k)] = trim($v);
}
}
$apply = isset($argMap['apply']) && $argMap['apply'] === '1';
$companyId = isset($argMap['companyId']) ? (int)$argMap['companyId'] : 0;
/** 与 CustomerServiceController::DEFAULT_KEFU_PRIVILEGE_IDS 保持一致 */
$defaultIds = [
1001, 1002, 1004, 1023, 1406,
20003, 20021, 20022, 20023, 20032, 20041, 20049, 20054, 20055, 20060,
20100, 20102, 20107,
];
$defaultJson = json_encode($defaultIds, JSON_UNESCAPED_UNICODE);
$query = Db::table('s2_company_account')->where('status', 0);
if ($companyId > 0) {
$query->where('departmentId', $companyId);
}
$rows = $query
->field('id, departmentId, userName, privilegeIds')
->select();
$targets = [];
foreach ($rows ?: [] as $row) {
$raw = trim((string)($row['privilegeIds'] ?? ''));
$decoded = $raw === '' ? [] : (json_decode($raw, true) ?: []);
if (!is_array($decoded) || count($decoded) === 0) {
$targets[] = $row;
}
}
echo sprintf("[backfill_kefu_privilege_ids] mode=%s companyFilter=%d targets=%d\n",
$apply ? 'APPLY' : 'DRY-RUN',
$companyId,
count($targets)
);
foreach ($targets as $row) {
$line = sprintf(
" id=%d company=%d user=%s privilegeIds=%s",
(int)$row['id'],
(int)$row['departmentId'],
(string)$row['userName'],
(string)($row['privilegeIds'] ?? '')
);
echo $line . "\n";
if ($apply) {
Db::table('s2_company_account')->where('id', (int)$row['id'])->update([
'privilegeIds' => $defaultJson,
'updateTime' => time(),
]);
}
}
if (!$apply && count($targets) > 0) {
echo "\n追加 apply=1 执行写入\n";
}
exit(0);

View File

@@ -0,0 +1,154 @@
<?php
/**
* 存客宝 · 单用户公司归属修复(按手机号迁移到目标 companyId
*
* 用途13779954946 应从卡若(914)迁到黑科技/老坑爹/魔兽(2130)15880802661 保持 914。
* 只迁移该 userId 名下的计划、内容库、算力账户,不影响同公司其他用户。
*
* 用法:
* docker compose exec -T server php /var/www/html/script/migration/fix_user_company.php \
* phone=13779954946 toCompany=2130
*
* docker compose exec -T server php /var/www/html/script/migration/fix_user_company.php \
* phone=13779954946 toCompany=2130 apply=1
*/
declare(strict_types=1);
require __DIR__ . '/../../thinkphp/base.php';
use think\Db;
use think\App;
$app = new App(realpath(__DIR__ . '/../../application'));
$app->initialize();
$args = [];
foreach ($argv as $arg) {
if (strpos($arg, '=') !== false) {
list($k, $v) = explode('=', $arg, 2);
$args[trim($k)] = trim($v);
}
}
$phone = $args['phone'] ?? '';
$toCompany = (int)($args['toCompany'] ?? 0);
$apply = !empty($args['apply']);
if (!$phone || !$toCompany) {
fwrite(STDERR, "usage: phone=1XXXXXXXXXX toCompany=2130 [apply=1]\n");
exit(1);
}
$user = Db::name('users')
->where('phone', $phone)
->whereOr('account', $phone)
->find();
if (!$user) {
fwrite(STDERR, "未找到手机号 {$phone} 对应用户\n");
exit(1);
}
$userId = (int)$user['id'];
$fromCompany = (int)$user['companyId'];
if ($fromCompany === $toCompany) {
echo "用户 {$phone} (id={$userId}) 已在 companyId={$toCompany},无需迁移。\n";
exit(0);
}
$mode = $apply ? '【APPLY】' : '【DRY-RUN】';
echo "{$mode} phone={$phone} userId={$userId} username={$user['username']} "
. "companyId {$fromCompany}{$toCompany}\n";
$fromName = Db::name('company')->where('id', $fromCompany)->value('name') ?: '?';
$toName = Db::name('company')->where('id', $toCompany)->value('name') ?: '?';
echo " 源公司: {$fromName} ({$fromCompany})\n";
echo " 目标公司: {$toName} ({$toCompany})\n";
$plan = [];
Db::startTrans();
try {
// 1) 用户主表
$plan['users'] = 1;
echo " · ck_users: 1 行\n";
if ($apply) {
Db::name('users')->where('id', $userId)->update([
'companyId' => $toCompany,
'updateTime' => time(),
]);
}
// 2) 按 userId 迁移的业务表
$userScoped = [
['customer_acquisition_task', 'userId', 'companyId', '获客计划'],
['content_library', 'userId', 'companyId', '内容库'],
['tokens_company', 'userId', 'companyId', '算力账户'],
];
foreach ($userScoped as [$table, $userField, $companyField, $desc]) {
try {
$count = (int)Db::name($table)
->where($userField, $userId)
->where($companyField, $fromCompany)
->count();
$plan[$table] = $count;
echo " · {$table} ({$desc}): {$count}\n";
if ($apply && $count > 0) {
Db::name($table)
->where($userField, $userId)
->where($companyField, $fromCompany)
->update([$companyField => $toCompany]);
}
} catch (\Throwable $e) {
echo " · [SKIP] {$table}: {$e->getMessage()}\n";
}
}
// 3) 该用户名下、仍挂在旧公司的内容库素材libraryId 归属已迁则自动正确)
try {
$libIds = Db::name('content_library')->where('userId', $userId)->column('id');
if ($libIds) {
// 无 companyId 字段,仅统计
$itemCount = (int)Db::name('content_item')
->whereIn('libraryId', $libIds)
->where('isDel', 0)
->count();
$plan['content_item'] = $itemCount;
echo " · content_item (素材,随内容库): {$itemCount}\n";
}
} catch (\Throwable $e) {
echo " · [SKIP] content_item: {$e->getMessage()}\n";
}
if ($apply) {
Db::commit();
echo "\n✅ 迁移完成\n";
} else {
Db::rollback();
echo "\n[DRY-RUN] 未写入。确认后加 apply=1 重跑。\n";
}
} catch (\Throwable $e) {
Db::rollback();
fwrite(STDERR, "失败回滚:{$e->getMessage()}\n");
exit(2);
}
$reportDir = dirname(__DIR__, 2) . '/runtime/migration/' . $phone;
if (!is_dir($reportDir)) {
@mkdir($reportDir, 0755, true);
}
$reportPath = $reportDir . '/fix-user-company-' . ($apply ? 'apply' : 'dryrun') . '-' . date('YmdHis') . '.json';
file_put_contents($reportPath, json_encode([
'phone' => $phone,
'userId' => $userId,
'fromCompany' => $fromCompany,
'toCompany' => $toCompany,
'fromName' => $fromName,
'toName' => $toName,
'mode' => $apply ? 'apply' : 'dry-run',
'plan' => $plan,
'finishedAt' => date('c'),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
echo "report: {$reportPath}\n";

View File

@@ -0,0 +1,134 @@
<?php
/**
* 存客宝数据迁移 · 公司归属修复脚本DRY-RUN 优先)
*
* 用途:把指定手机号 / 旧 companyId 下的计划、客资、积分、海报/渠道获客记录
* 迁移到目标 companyId卡若指定。默认 DRY-RUN只输出 SQL不执行。
*
* 用法:
* # 仅打印影响行数与映射,不执行
* docker compose exec -T server php /var/www/html/script/migration/migrate_company_attribution.php \
* sourcePhone=13779954946 fromCompany=15880802661 toCompany=914
*
* # 加 --apply 后真正执行(卡若手动确认后再加)
* docker compose exec -T server php /var/www/html/script/migration/migrate_company_attribution.php \
* sourcePhone=13779954946 fromCompany=15880802661 toCompany=914 apply=1
*
* 卡若 2026-05-27 · 数据迁移 D1/D2 公司归属修复
*/
declare(strict_types=1);
require __DIR__ . '/../../thinkphp/base.php';
use think\Db;
use think\App;
$app = new App(realpath(__DIR__ . '/../../application'));
$app->initialize();
// 参数解析
$args = [];
foreach ($argv as $arg) {
if (strpos($arg, '=') !== false) {
list($k, $v) = explode('=', $arg, 2);
$args[trim($k)] = trim($v);
}
}
$sourcePhone = $args['sourcePhone'] ?? '';
$fromCompany = (int)($args['fromCompany'] ?? 0);
$toCompany = (int)($args['toCompany'] ?? 0);
$apply = !empty($args['apply']);
if (!$sourcePhone || !$fromCompany || !$toCompany) {
fwrite(STDERR, "usage: sourcePhone=1XXX fromCompany=X toCompany=Y [apply=1]\n");
exit(1);
}
$mode = $apply ? '【APPLY 实际执行】' : '【DRY-RUN 不执行】';
echo "$mode source phone=$sourcePhone, from companyId=$fromCompany → to companyId=$toCompany\n";
// 步骤 1定位待迁移对象
$users = Db::name('users')
->where('companyId', $fromCompany)
->where(function ($q) use ($sourcePhone) {
$q->where('phone', $sourcePhone)->whereOr('account', $sourcePhone);
})
->select();
if (!$users) {
echo "未找到符合条件的用户,退出。\n";
exit(0);
}
$userIds = array_column((array)$users, 'id');
echo "影响用户:" . count($userIds) . " 个 (ids=" . implode(',', $userIds) . ")\n";
$tables = [
// [表名, 字段, 描述]
['customer_acquisition_task', 'companyId', '获客计划(含海报/渠道/付款码等场景)'],
['tokens_company', 'companyId', '算力账户'],
['plan_distribution_channel', 'companyId', '渠道获客分销渠道'],
['plan_distribution_record', 'companyId', '渠道分销记录'],
['ckb_lead_records', 'companyId', '客资入池队列'],
['content_library', 'companyId', '内容库'],
['workbench_moments_sync', 'companyId', '工作台 · 朋友圈同步'],
['workbench_group_push', 'companyId', '工作台 · 群消息推送'],
['workbench_auto_like', 'companyId', '工作台 · 自动点赞'],
];
$plan = [];
$totalAffected = 0;
Db::startTrans();
try {
foreach ($tables as [$table, $field, $desc]) {
$exists = Db::query("SHOW TABLES LIKE '" . Db::name($table)->getTable() . "'");
if (!$exists) {
echo " · [SKIP] 表 $table 不存在\n";
continue;
}
$count = Db::name($table)->where($field, $fromCompany)->count();
echo " · $table ($desc): 待迁移 $count";
if ($apply && $count > 0) {
$affected = Db::name($table)
->where($field, $fromCompany)
->update([$field => $toCompany]);
$totalAffected += $affected;
echo " → 已迁移 $affected\n";
} else {
echo "\n";
}
$plan[$table] = $count;
}
if ($apply) {
Db::commit();
echo "\n✅ 迁移完成,共影响 $totalAffected\n";
} else {
Db::rollback();
echo "\n[DRY-RUN] 共可迁移 " . array_sum($plan) . " 行(未执行)。如确认无误,请追加 apply=1 重跑。\n";
}
} catch (\Throwable $e) {
Db::rollback();
fwrite(STDERR, "失败回滚:" . $e->getMessage() . "\n");
exit(2);
}
// 输出迁移报告
$reportDir = dirname(__DIR__, 2) . '/runtime/migration/' . $sourcePhone;
if (!is_dir($reportDir)) {
@mkdir($reportDir, 0755, true);
}
$reportPath = $reportDir . '/migrate-' . ($apply ? 'apply' : 'dryrun') . '-' . date('YmdHis') . '.json';
file_put_contents($reportPath, json_encode([
'sourcePhone' => $sourcePhone,
'fromCompany' => $fromCompany,
'toCompany' => $toCompany,
'mode' => $apply ? 'apply' : 'dry-run',
'userIds' => $userIds,
'plan' => $plan,
'totalAffected' => $totalAffected,
'finishedAt' => date('c'),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
echo "report: $reportPath\n";

View File

@@ -0,0 +1,170 @@
<?php
/**
* 存客宝数据迁移 · 快照脚本
*
* 用途:在迁移前/迁移后采集账号、公司、设备、微信、好友、计划、积分快照,
* 并对比生成差异报表。仅本地执行,不直接修改业务数据。
*
* 用法:
* docker compose exec -T server php /var/www/html/script/migration/snapshot.php phone=13779954946 stage=before
* docker compose exec -T server php /var/www/html/script/migration/snapshot.php phone=13779954946 stage=after
* docker compose exec -T server php /var/www/html/script/migration/snapshot.php diff phone=13779954946
*
* 输出文件位于 Server/runtime/migration/{phone}/{stage}-{timestamp}.json
*
* 卡若 2026-05-27 · 数据迁移 D1/D2/E1 快照需求
*/
declare(strict_types=1);
require __DIR__ . '/../../thinkphp/base.php';
use think\Db;
use think\App;
$app = new App(realpath(__DIR__ . '/../../application'));
$app->initialize();
// 参数解析
$argMap = [];
foreach ($argv as $arg) {
if (strpos($arg, '=') !== false) {
list($k, $v) = explode('=', $arg, 2);
$argMap[trim($k)] = trim($v);
} else {
$argMap[trim($arg)] = true;
}
}
$phone = $argMap['phone'] ?? '';
$stage = $argMap['stage'] ?? 'before';
$mode = isset($argMap['diff']) ? 'diff' : 'snapshot';
if (!$phone || !preg_match('/^1[3-9]\d{9}$/', $phone)) {
fwrite(STDERR, "usage: snapshot.php phone=1XXXXXXXXXX stage=before|after\n");
fwrite(STDERR, " snapshot.php diff phone=1XXXXXXXXXX\n");
exit(1);
}
$runtimeDir = dirname(__DIR__, 2) . '/runtime/migration/' . $phone;
if (!is_dir($runtimeDir)) {
@mkdir($runtimeDir, 0755, true);
}
/**
* 采集快照
*
* @return array
*/
function collectSnapshot(string $phone): array
{
$users = Db::name('users')
->where('phone', $phone)
->whereOr('account', $phone)
->select();
$userIds = array_column((array)$users, 'id');
$companyIds = array_values(array_unique(array_filter(array_column((array)$users, 'companyId'))));
$accounts = Db::table('s2_company_account')
->whereIn('departmentId', $companyIds ?: [0])
->column('id');
$deviceTotal = $accounts
? (int)Db::table('s2_device')->whereIn('currentAccountId', $accounts)->where(['isDeleted' => 0])->count()
: 0;
$wechatTotal = $accounts
? (int)Db::table('s2_wechat_account')->whereIn('deviceAccountId', $accounts)->count()
: 0;
$friendTotal = $accounts
? (int)Db::table('s2_wechat_friend')->whereIn('accountId', $accounts)->where(['isDeleted' => 0])->count()
: 0;
$planRows = $companyIds
? Db::name('customer_acquisition_task')
->whereIn('companyId', $companyIds)
->where('deleteTime', 0)
->field('sceneId, status, COUNT(*) as cnt')
->group('sceneId, status')
->select()
: [];
$tokens = $userIds
? Db::name('tokens_company')->whereIn('userId', $userIds)->select()
: [];
return [
'phone' => $phone,
'collectedAt' => date('c'),
'userIds' => $userIds,
'companyIds' => $companyIds,
'accountIds' => array_map('intval', $accounts ?: []),
'totals' => [
'users' => count($userIds),
'companies' => count($companyIds),
's2Accounts' => count($accounts ?: []),
'devices' => $deviceTotal,
'wechats' => $wechatTotal,
'friends' => $friendTotal,
],
'plans' => array_map(static function ($r) {
return [
'sceneId' => (int)$r['sceneId'],
'status' => (int)$r['status'],
'count' => (int)$r['cnt'],
];
}, (array)$planRows),
'tokens' => array_map(static function ($t) {
return [
'userId' => (int)($t['userId'] ?? 0),
'balance' => (int)($t['totalTokens'] ?? 0),
'used' => (int)($t['totalConsumed'] ?? 0),
];
}, (array)$tokens),
];
}
if ($mode === 'diff') {
$files = glob($runtimeDir . '/snapshot-*.json');
if (!$files || count($files) < 2) {
fwrite(STDERR, "需要至少两个快照before / after才能 diff当前: " . count($files) . "\n");
exit(2);
}
sort($files);
$before = json_decode((string)file_get_contents($files[0]), true);
$after = json_decode((string)file_get_contents(end($files)), true);
$diff = [];
foreach ($before['totals'] ?? [] as $k => $v) {
$aft = $after['totals'][$k] ?? 0;
$diff[$k] = ['before' => $v, 'after' => $aft, 'delta' => $aft - $v];
}
$report = [
'phone' => $phone,
'beforeAt' => $before['collectedAt'] ?? null,
'afterAt' => $after['collectedAt'] ?? null,
'beforeFile' => basename($files[0]),
'afterFile' => basename(end($files)),
'totals' => $diff,
'planBefore' => $before['plans'] ?? [],
'planAfter' => $after['plans'] ?? [],
'tokensBefore' => $before['tokens'] ?? [],
'tokensAfter' => $after['tokens'] ?? [],
];
$reportPath = $runtimeDir . '/diff-' . date('YmdHis') . '.json';
file_put_contents($reportPath, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
echo "diff written: $reportPath\n";
echo json_encode($diff, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n";
exit(0);
}
$snap = collectSnapshot($phone);
$path = $runtimeDir . '/snapshot-' . $stage . '-' . date('YmdHis') . '.json';
file_put_contents($path, json_encode($snap, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
echo "snapshot written: $path\n";
echo "totals: " . json_encode($snap['totals'], JSON_UNESCAPED_UNICODE) . "\n";
exit(0);

View File

@@ -0,0 +1,61 @@
<?php
/**
* 触客宝 · 灌入演示 AI 获客客资(本地联调用)
* 用法docker compose exec server php script/seed_ai_lead_demo.php [companyId]
*/
require __DIR__ . '/../thinkphp/base.php';
$app = new think\App();
$app->initialize();
$companyId = isset($argv[1]) ? (int)$argv[1] : 2130;
$now = time();
\app\chukebao\controller\AiLeadController::ingest([
'companyId' => $companyId,
'nickname' => '张先生·魔兽一键宏',
'phone' => '13800138001',
'unionId' => 'demo_lead_001',
'planName' => '场景获客·抖音口播',
'tags' => ['高意向', '魔兽玩家', '一键宏'],
'poolKeywordsText' => '一键宏,军训,解放双手',
'rfmLevel' => 'A',
'rfmScore' => 72,
'intention' => '咨询',
'behaviorTimeline' => [
['time' => date('Y-m-d H:i', $now - 3600), 'action' => '浏览落地页', 'detail' => '老坑爹一键宏介绍'],
['time' => date('Y-m-d H:i', $now - 1800), 'action' => '提交表单', 'detail' => '留下手机号'],
],
]);
\app\chukebao\controller\AiLeadController::ingest([
'companyId' => $companyId,
'nickname' => '李女士·暗黑代练',
'phone' => '13900139002',
'unionId' => 'demo_lead_002',
'planName' => '场景获客·京东群发',
'tags' => ['未添加', '暗黑3'],
'rfmLevel' => 'B',
'rfmScore' => 55,
'intention' => '比价',
'behaviorTimeline' => [
['time' => date('Y-m-d H:i', $now - 7200), 'action' => '点击广告', 'detail' => '暗黑破坏神代练'],
],
]);
\app\chukebao\controller\AiLeadController::ingest([
'companyId' => $companyId,
'nickname' => '王老板·工作室合作',
'phone' => '13700137003',
'unionId' => 'demo_lead_003',
'planName' => '场景获客·合伙人',
'tags' => ['B端', '合作'],
'rfmLevel' => 'S',
'rfmScore' => 88,
'intention' => '合作洽谈',
'behaviorTimeline' => [
['time' => date('Y-m-d H:i', $now - 600), 'action' => 'Webhook推送', 'detail' => '存客宝场景获客'],
],
]);
$count = think\Db::name('ai_lead')->where('companyId', $companyId)->count();
echo "OK companyId={$companyId} ai_lead total={$count}\n";