## GitHub 同步说明
| 项 | 内容 |
|:---|:---|
| 仓库 | https://github.com/fnvtk/cunkebao_v3 |
| 分支 | develop |
| 方向 | 本地 → GitHub(单向 push) |
| 是否拉取远程 | 否(未 fetch / pull / merge) |
| 是否改本地文件 | 否(仅 git add/commit,未编辑任何源文件正文) |
| 基准 commit | 0643e78a |
| 变更规模 | 709 files, +26436 / -3407 lines |
## 目录变更统计
| 目录 | 文件数 | 说明 |
|:---|---:|:---|
| 开发文档/ | 404 | 四端进行中/已完成需求、接口文档、官网需求、部署脚本 |
| Server/ | 95 | 后端 API、迁移脚本、触客宝/超管/存客宝服务 |
| Cunkebao/ | 85 | 移动端 H5、设备绑定、客服、转号审批等 |
| Touchkebao/ | 68 | PC 工作台、AI 获客、算力中心、Glass UI |
| SuperAdmin/ | 33 | 超管项目中心、算力计费、内容库 |
| 官网/ | 19 | 新增存客宝官网静态页(index/pricing/solutions 等) |
| .cursor/ | 3 | Cursor 规则与 Skill |
| .obsidian/ | 2 | Obsidian 工作区配置 |
## 重点新增/更新(本地为准)
- 官网/: 完整静态站点(index、pricing、manual、solutions、api)
- 开发文档/1、需求/修改/*_进行中_20260530.md(存客宝/触客宝/AI数智员工)
- 开发文档/1、需求/官网需求.md
- 开发文档/5、接口/开发进度_接口文档.md 等接口文档迭代
- Touchkebao Glass UI 与 P0 收尾、通道获客算力
- SuperAdmin 算力计费规则、项目工作台
## 刻意未上传(本地保留)
- node_modules/
- .DS_Store、.smart-env/
- .开发文档_nested_git_backup/(旧嵌套 git 备份)
- **/__pycache__/
## 验收
GitHub 浏览: https://github.com/fnvtk/cunkebao_v3/tree/develop
Co-authored-by: Cursor <cursoragent@cursor.com>
216 lines
7.7 KiB
PHP
216 lines
7.7 KiB
PHP
<?php
|
||
|
||
namespace app\superadmin\service;
|
||
|
||
use app\api\controller\DeviceController as ApiDeviceController;
|
||
use app\common\model\Device as DeviceModel;
|
||
use app\common\model\User as UserModel;
|
||
use think\Db;
|
||
|
||
/**
|
||
* 设备跨项目转移:S2 换绑 + 本地 companyId + 清除原项目流量池 + 微信登录归属迁移
|
||
*/
|
||
class DeviceTransferService
|
||
{
|
||
public function transfer(int $deviceId, int $targetCompanyId): array
|
||
{
|
||
$device = DeviceModel::where('id', $deviceId)->where('deleteTime', 0)->find();
|
||
if (!$device) {
|
||
throw new \Exception('设备不存在', 404);
|
||
}
|
||
|
||
$fromCompanyId = (int) $device->companyId;
|
||
if ($fromCompanyId === $targetCompanyId) {
|
||
throw new \Exception('设备已在目标项目中', 400);
|
||
}
|
||
|
||
$targetUser = UserModel::where([
|
||
'companyId' => $targetCompanyId,
|
||
'isAdmin' => UserModel::ADMIN_STP,
|
||
'status' => UserModel::STATUS_ACTIVE,
|
||
])->where('deleteTime', 0)->find();
|
||
|
||
if (!$targetUser || empty($targetUser->s2_accountId)) {
|
||
throw new \Exception('目标项目未绑定工作手机账号(s2_accountId)', 400);
|
||
}
|
||
|
||
$wechatIds = Db::name('device_wechat_login')
|
||
->where('deviceId', $deviceId)
|
||
->where('companyId', $fromCompanyId)
|
||
->column('wechatId');
|
||
$wechatIds = array_values(array_filter(array_unique($wechatIds)));
|
||
|
||
$apiResult = (new ApiDeviceController())->updateaccount([
|
||
'id' => $deviceId,
|
||
'accountId' => (int) $targetUser->s2_accountId,
|
||
], true);
|
||
$decoded = is_string($apiResult) ? json_decode($apiResult, true) : null;
|
||
if (is_array($decoded) && isset($decoded['code']) && (int) $decoded['code'] !== 200) {
|
||
throw new \Exception($decoded['msg'] ?? '工作手机换绑失败', 500);
|
||
}
|
||
|
||
Db::startTrans();
|
||
try {
|
||
$clearedPools = $this->clearCompanyTrafficForWechats($fromCompanyId, $wechatIds);
|
||
|
||
DeviceModel::where('id', $deviceId)->update([
|
||
'companyId' => $targetCompanyId,
|
||
'updateTime' => time(),
|
||
]);
|
||
|
||
Db::name('device_wechat_login')
|
||
->where('deviceId', $deviceId)
|
||
->where('companyId', $fromCompanyId)
|
||
->update([
|
||
'companyId' => $targetCompanyId,
|
||
'updateTime' => time(),
|
||
]);
|
||
|
||
Db::commit();
|
||
} catch (\Throwable $e) {
|
||
Db::rollback();
|
||
throw $e;
|
||
}
|
||
|
||
return [
|
||
'deviceId' => $deviceId,
|
||
'fromCompanyId' => $fromCompanyId,
|
||
'targetCompanyId' => $targetCompanyId,
|
||
'clearedPools' => $clearedPools,
|
||
'wechatIds' => $wechatIds,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 批量迁移预检(§6.3):源→目标映射 + 冲突 IMEI 提示,不写库
|
||
*/
|
||
public function previewBatch(array $deviceIds, int $targetCompanyId): array
|
||
{
|
||
$deviceIds = array_values(array_unique(array_filter(array_map('intval', $deviceIds))));
|
||
if (empty($deviceIds)) {
|
||
throw new \Exception('请选择要迁移的设备', 400);
|
||
}
|
||
if ($targetCompanyId <= 0) {
|
||
throw new \Exception('目标项目无效', 400);
|
||
}
|
||
$targetName = (string) Db::name('company')->where('companyId', $targetCompanyId)->value('name');
|
||
|
||
$devices = Db::name('device')->whereIn('id', $deviceIds)->where('deleteTime', 0)
|
||
->field('id, memo, imei, companyId')->select();
|
||
|
||
// 目标项目已有 IMEI(用于冲突检测)
|
||
$targetImeis = Db::name('device')->where('companyId', $targetCompanyId)->where('deleteTime', 0)
|
||
->whereNotNull('imei')->where('imei', '<>', '')->column('imei');
|
||
$targetImeiSet = array_flip(array_filter($targetImeis));
|
||
|
||
$companyNameMap = [];
|
||
$items = [];
|
||
$conflicts = 0;
|
||
$alreadyThere = 0;
|
||
foreach ($devices ?: [] as $d) {
|
||
$fromCid = (int) $d['companyId'];
|
||
if (!isset($companyNameMap[$fromCid])) {
|
||
$companyNameMap[$fromCid] = (string) Db::name('company')->where('companyId', $fromCid)->value('name');
|
||
}
|
||
$imei = (string) ($d['imei'] ?? '');
|
||
$isConflict = $imei !== '' && isset($targetImeiSet[$imei]);
|
||
$isSame = $fromCid === $targetCompanyId;
|
||
if ($isConflict) {
|
||
$conflicts++;
|
||
}
|
||
if ($isSame) {
|
||
$alreadyThere++;
|
||
}
|
||
$items[] = [
|
||
'deviceId' => (int) $d['id'],
|
||
'name' => $d['memo'] ?: ('设备#' . (int) $d['id']),
|
||
'imei' => $imei,
|
||
'fromCompanyId' => $fromCid,
|
||
'fromName' => $companyNameMap[$fromCid] ?: ('项目#' . $fromCid),
|
||
'imeiConflict' => $isConflict,
|
||
'sameProject' => $isSame,
|
||
];
|
||
}
|
||
|
||
return [
|
||
'targetCompanyId' => $targetCompanyId,
|
||
'targetName' => $targetName ?: ('项目#' . $targetCompanyId),
|
||
'total' => count($items),
|
||
'conflicts' => $conflicts,
|
||
'alreadyThere' => $alreadyThere,
|
||
'items' => $items,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 批量迁移(§6.3):逐台调用 transfer,逐条收集成败
|
||
*/
|
||
public function migrateBatch(array $deviceIds, int $targetCompanyId): array
|
||
{
|
||
$deviceIds = array_values(array_unique(array_filter(array_map('intval', $deviceIds))));
|
||
if (empty($deviceIds)) {
|
||
throw new \Exception('请选择要迁移的设备', 400);
|
||
}
|
||
if ($targetCompanyId <= 0) {
|
||
throw new \Exception('目标项目无效', 400);
|
||
}
|
||
$ok = [];
|
||
$failed = [];
|
||
foreach ($deviceIds as $deviceId) {
|
||
try {
|
||
$this->transfer($deviceId, $targetCompanyId);
|
||
$ok[] = $deviceId;
|
||
} catch (\Throwable $e) {
|
||
$failed[] = ['deviceId' => $deviceId, 'reason' => $e->getMessage()];
|
||
}
|
||
}
|
||
return [
|
||
'targetCompanyId' => $targetCompanyId,
|
||
'requested' => count($deviceIds),
|
||
'migrated' => count($ok),
|
||
'okDeviceIds' => $ok,
|
||
'failed' => $failed,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 清除原项目下与设备微信号相关的流量池数据(软删)
|
||
*/
|
||
protected function clearCompanyTrafficForWechats(int $companyId, array $wechatIds): int
|
||
{
|
||
if ($companyId <= 0) {
|
||
return 0;
|
||
}
|
||
|
||
$query = Db::name('traffic_pool_company')
|
||
->where('companyId', $companyId)
|
||
->where('isDel', 0);
|
||
|
||
if (!empty($wechatIds)) {
|
||
$query->where(function ($q) use ($wechatIds) {
|
||
$q->whereIn('ownerWechatId', $wechatIds)
|
||
->whereOr(function ($sq) use ($wechatIds) {
|
||
$sq->whereIn('identifier', $wechatIds);
|
||
});
|
||
});
|
||
} else {
|
||
return 0;
|
||
}
|
||
|
||
$poolCompanyIds = $query->column('id');
|
||
if (empty($poolCompanyIds)) {
|
||
return 0;
|
||
}
|
||
|
||
$now = time();
|
||
Db::name('traffic_pool_company')->whereIn('id', $poolCompanyIds)->update([
|
||
'isDel' => 1,
|
||
'updateTime' => $now,
|
||
]);
|
||
Db::name('traffic_pool_tag')->whereIn('poolCompanyId', $poolCompanyIds)->update(['isDel' => 1]);
|
||
Db::name('traffic_pool_group_member')->whereIn('poolCompanyId', $poolCompanyIds)->update(['isDel' => 1]);
|
||
|
||
return count($poolCompanyIds);
|
||
}
|
||
}
|