sync: 以本地为准同步全量变更至 GitHub
含四端需求文档、前端/后端/超管/触客宝迭代及部署脚本更新;未拉取远程。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
85
Server/script/migration/README.md
Normal file
85
Server/script/migration/README.md
Normal 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)。
|
||||
91
Server/script/migration/backfill_kefu_privilege_ids.php
Normal file
91
Server/script/migration/backfill_kefu_privilege_ids.php
Normal 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);
|
||||
154
Server/script/migration/fix_user_company.php
Normal file
154
Server/script/migration/fix_user_company.php
Normal 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";
|
||||
134
Server/script/migration/migrate_company_attribution.php
Normal file
134
Server/script/migration/migrate_company_attribution.php
Normal 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";
|
||||
170
Server/script/migration/snapshot.php
Normal file
170
Server/script/migration/snapshot.php
Normal 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);
|
||||
61
Server/script/seed_ai_lead_demo.php
Normal file
61
Server/script/seed_ai_lead_demo.php
Normal 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";
|
||||
Reference in New Issue
Block a user