541 lines
20 KiB
PHP
541 lines
20 KiB
PHP
<?php
|
||
namespace app\controller\superadmin;
|
||
|
||
use app\BaseController;
|
||
use think\facade\Db;
|
||
use think\facade\Request;
|
||
|
||
/**
|
||
* 将「平台侧」无企业归属的订单/测试/用户画像归并到指定企业,供企业后台与概览统计一致展示。
|
||
* 仅超级管理员可调用;默认 dryRun 预览,正式执行需 confirm=true。
|
||
*/
|
||
class DataMigration extends BaseController
|
||
{
|
||
/**
|
||
* 小程序用户「未绑定企业」的 SQL 条件:仅 NULL 或 0。
|
||
* 不使用 enterpriseId = ''(整型列 + ORM 易产生歧义),也不把已绑定企业(>0)算入。
|
||
*/
|
||
private function sqlWechatUserUnassignedEnterprise(): string
|
||
{
|
||
return '(`enterpriseId` IS NULL OR `enterpriseId` = 0)';
|
||
}
|
||
|
||
private function sqlTestResultUnassignedEnterprise(): string
|
||
{
|
||
return '(`enterpriseId` IS NULL OR `enterpriseId` = 0)';
|
||
}
|
||
|
||
/**
|
||
* 将同一 userId 下 personal + enterprise(目标企业) 两条 user_profile 合并为一行:
|
||
* 计数类字段相加;last* 类字段取 lastTestAt 更新的一侧(相同时优先企业行)
|
||
*/
|
||
private function mergePersonalUserProfileIntoEnterprise(int $userId, int $enterpriseId, int $now): void
|
||
{
|
||
if ($userId <= 0 || $enterpriseId <= 0) {
|
||
return;
|
||
}
|
||
|
||
$personal = Db::name('user_profile')
|
||
->where('userId', $userId)
|
||
->where('userType', 'personal')
|
||
->whereRaw('(`enterpriseId` IS NULL OR `enterpriseId` = 0)')
|
||
->find();
|
||
|
||
if (!$personal) {
|
||
return;
|
||
}
|
||
|
||
$enterprise = Db::name('user_profile')
|
||
->where('userId', $userId)
|
||
->where('userType', 'enterprise')
|
||
->where('enterpriseId', $enterpriseId)
|
||
->find();
|
||
|
||
if (!$enterprise) {
|
||
Db::name('user_profile')->where('id', (int) $personal['id'])->update([
|
||
'userType' => 'enterprise',
|
||
'enterpriseId' => $enterpriseId,
|
||
'updatedAt' => $now,
|
||
]);
|
||
|
||
return;
|
||
}
|
||
|
||
$sumKeys = [
|
||
'testsTotal', 'testsMbti', 'testsDisc', 'testsPdp', 'testsFace',
|
||
'ordersTotal', 'paidOrders', 'totalPaidAmount',
|
||
];
|
||
$update = [];
|
||
foreach ($sumKeys as $k) {
|
||
$update[$k] = (int) ($personal[$k] ?? 0) + (int) ($enterprise[$k] ?? 0);
|
||
}
|
||
|
||
$pAt = (int) ($personal['lastTestAt'] ?? 0);
|
||
$eAt = (int) ($enterprise['lastTestAt'] ?? 0);
|
||
$pickPersonal = $pAt > $eAt;
|
||
$src = $pickPersonal ? $personal : $enterprise;
|
||
|
||
foreach (['lastTestResultId', 'lastTestType', 'lastTestAt', 'lastMbtiResultId', 'lastDiscResultId', 'lastPdpResultId', 'lastFaceResultId'] as $k) {
|
||
if (array_key_exists($k, $src)) {
|
||
$update[$k] = $src[$k];
|
||
}
|
||
}
|
||
$update['updatedAt'] = $now;
|
||
|
||
Db::name('user_profile')->where('id', (int) $enterprise['id'])->update($update);
|
||
Db::name('user_profile')->where('id', (int) $personal['id'])->delete();
|
||
}
|
||
|
||
/**
|
||
* POST /api/v1/superadmin/data-migration/attach-orphan-orders
|
||
*
|
||
* Body JSON:
|
||
* - targetEnterpriseId (int, 必填) 目标企业 ID
|
||
* - dryRun (bool, 默认 true) true 只统计不写入
|
||
* - confirm (bool, 默认 false) 与 dryRun=false 同时为真时才写入
|
||
* - orderIds (int[], 可选) 仅处理这些订单 id(仍须满足当前无企业归属)
|
||
* - userIds (int[], 可选) 仅处理这些小程序用户 id 名下的无归属订单
|
||
* - syncPersonalTestResults (bool, 默认 true) 是否把同用户下 enterpriseId 为空的 personal 测试记录一并标到目标企业
|
||
* - syncWechatUsers (bool, 默认 true) 是否将 wechat_users.enterpriseId 为空的用户标到目标企业
|
||
* - clonePersonalProfile (bool, 默认 true) 若无 (userId,enterprise,enterprise) 画像行,则从 personal 行复制一条 enterprise 画像(便于「用户运营」列表出现)
|
||
*/
|
||
public function attachOrphanOrders()
|
||
{
|
||
$actor = $this->request->user ?? null;
|
||
if (!$actor || ($actor['role'] ?? '') !== 'superadmin') {
|
||
return error('仅超级管理员可操作', 403);
|
||
}
|
||
|
||
$body = Request::post();
|
||
if (!is_array($body)) {
|
||
$body = [];
|
||
}
|
||
|
||
$targetEnterpriseId = (int) ($body['targetEnterpriseId'] ?? 0);
|
||
if ($targetEnterpriseId <= 0) {
|
||
return error('targetEnterpriseId 无效', 400);
|
||
}
|
||
|
||
$ent = Db::name('enterprises')->where('id', $targetEnterpriseId)->find();
|
||
if (!$ent) {
|
||
return error('目标企业不存在', 404);
|
||
}
|
||
|
||
$dryRun = array_key_exists('dryRun', $body) ? (bool) $body['dryRun'] : true;
|
||
$confirm = !empty($body['confirm']);
|
||
$syncPersonalTestResults = array_key_exists('syncPersonalTestResults', $body) ? (bool) $body['syncPersonalTestResults'] : true;
|
||
$syncWechatUsers = array_key_exists('syncWechatUsers', $body) ? (bool) $body['syncWechatUsers'] : true;
|
||
$clonePersonalProfile = array_key_exists('clonePersonalProfile', $body) ? (bool) $body['clonePersonalProfile'] : true;
|
||
|
||
$orderIdsFilter = $this->normalizeIdList($body['orderIds'] ?? null);
|
||
$userIdsFilter = $this->normalizeIdList($body['userIds'] ?? null);
|
||
|
||
$orderQuery = Db::name('orders')->where(function ($q) {
|
||
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
|
||
});
|
||
|
||
if (!empty($orderIdsFilter)) {
|
||
$orderQuery->whereIn('id', $orderIdsFilter);
|
||
}
|
||
if (!empty($userIdsFilter)) {
|
||
$orderQuery->whereIn('userId', $userIdsFilter);
|
||
}
|
||
|
||
$orderRows = $orderQuery->field('id,userId,orderNo,enterpriseId,status,amount')->select()->toArray();
|
||
$affectedOrderIds = array_values(array_unique(array_filter(array_column($orderRows, 'id'))));
|
||
$userIdsFromOrders = array_values(array_unique(array_filter(array_column($orderRows, 'userId'))));
|
||
|
||
$testByOrderCount = 0;
|
||
if (!empty($affectedOrderIds)) {
|
||
$testByOrderCount = (int) Db::name('test_results')
|
||
->whereIn('orderId', $affectedOrderIds)
|
||
->count();
|
||
}
|
||
|
||
$personalTestExtraCount = 0;
|
||
if ($syncPersonalTestResults && !empty($userIdsFromOrders)) {
|
||
$personalTestExtraCount = (int) Db::name('test_results')
|
||
->whereIn('userId', $userIdsFromOrders)
|
||
->where('testScope', 'personal')
|
||
->where(function ($q) {
|
||
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
|
||
})
|
||
->count();
|
||
}
|
||
|
||
$wechatPatchCount = 0;
|
||
if ($syncWechatUsers && !empty($userIdsFromOrders)) {
|
||
$wechatPatchCount = (int) Db::name('wechat_users')
|
||
->whereIn('id', $userIdsFromOrders)
|
||
->whereRaw($this->sqlWechatUserUnassignedEnterprise())
|
||
->count();
|
||
}
|
||
|
||
$profileCloneCount = 0;
|
||
if ($clonePersonalProfile && !empty($userIdsFromOrders)) {
|
||
foreach ($userIdsFromOrders as $uid) {
|
||
$hasEnt = Db::name('user_profile')
|
||
->where('userId', $uid)
|
||
->where('userType', 'enterprise')
|
||
->where('enterpriseId', $targetEnterpriseId)
|
||
->find();
|
||
if (!$hasEnt) {
|
||
$profileCloneCount++;
|
||
}
|
||
}
|
||
}
|
||
|
||
$preview = [
|
||
'targetEnterpriseId' => $targetEnterpriseId,
|
||
'enterpriseName' => $ent['name'] ?? '',
|
||
'ordersMatched' => count($orderRows),
|
||
'orderIds' => $affectedOrderIds,
|
||
'distinctUserIds' => $userIdsFromOrders,
|
||
'testResultsByOrder' => $testByOrderCount,
|
||
'testResultsPersonalExtra' => $personalTestExtraCount,
|
||
'wechatUsersToPatch' => $wechatPatchCount,
|
||
'userProfilesToClone' => $profileCloneCount,
|
||
'dryRun' => $dryRun,
|
||
];
|
||
|
||
if ($dryRun || !$confirm) {
|
||
$preview['hint'] = $dryRun
|
||
? '当前为预览(dryRun=true)。若要执行写入,请传 dryRun=false 且 confirm=true。'
|
||
: '未执行写入:请同时传 dryRun=false 与 confirm=true。';
|
||
return success($preview);
|
||
}
|
||
|
||
$now = time();
|
||
Db::startTrans();
|
||
try {
|
||
if (!empty($affectedOrderIds)) {
|
||
Db::name('orders')
|
||
->whereIn('id', $affectedOrderIds)
|
||
->update([
|
||
'enterpriseId' => $targetEnterpriseId,
|
||
'updatedAt' => $now,
|
||
]);
|
||
|
||
Db::name('test_results')
|
||
->whereIn('orderId', $affectedOrderIds)
|
||
->update([
|
||
'enterpriseId' => $targetEnterpriseId,
|
||
'testScope' => 'enterprise',
|
||
'updatedAt' => $now,
|
||
]);
|
||
}
|
||
|
||
if ($syncPersonalTestResults && !empty($userIdsFromOrders)) {
|
||
Db::name('test_results')
|
||
->whereIn('userId', $userIdsFromOrders)
|
||
->where('testScope', 'personal')
|
||
->where(function ($q) {
|
||
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
|
||
})
|
||
->update([
|
||
'enterpriseId' => $targetEnterpriseId,
|
||
'testScope' => 'enterprise',
|
||
'updatedAt' => $now,
|
||
]);
|
||
}
|
||
|
||
if ($syncWechatUsers && !empty($userIdsFromOrders)) {
|
||
Db::name('wechat_users')
|
||
->whereIn('id', $userIdsFromOrders)
|
||
->whereRaw($this->sqlWechatUserUnassignedEnterprise())
|
||
->update([
|
||
'enterpriseId' => $targetEnterpriseId,
|
||
'updatedAt' => $now,
|
||
]);
|
||
}
|
||
|
||
if ($clonePersonalProfile && !empty($userIdsFromOrders)) {
|
||
foreach ($userIdsFromOrders as $uid) {
|
||
$this->ensureEnterpriseProfileFromPersonal((int) $uid, $targetEnterpriseId, $now);
|
||
}
|
||
}
|
||
|
||
Db::commit();
|
||
} catch (\Throwable $e) {
|
||
Db::rollback();
|
||
return error('迁移失败:' . $e->getMessage(), 500);
|
||
}
|
||
|
||
$preview['executed'] = true;
|
||
$preview['hint'] = '已写入。企业管理员刷新「订单运营 / 概览 / 用户运营」即可看到归属数据。超管仍可见全平台订单。';
|
||
return success($preview, '迁移完成');
|
||
}
|
||
|
||
/**
|
||
* @param mixed $raw
|
||
* @return int[]
|
||
*/
|
||
private function normalizeIdList($raw): array
|
||
{
|
||
if (!is_array($raw) || $raw === []) {
|
||
return [];
|
||
}
|
||
$out = [];
|
||
foreach ($raw as $v) {
|
||
$n = (int) $v;
|
||
if ($n > 0) {
|
||
$out[] = $n;
|
||
}
|
||
}
|
||
return array_values(array_unique($out));
|
||
}
|
||
|
||
private function ensureEnterpriseProfileFromPersonal(int $userId, int $enterpriseId, int $now): void
|
||
{
|
||
if ($userId <= 0 || $enterpriseId <= 0) {
|
||
return;
|
||
}
|
||
$exists = Db::name('user_profile')
|
||
->where('userId', $userId)
|
||
->where('userType', 'enterprise')
|
||
->where('enterpriseId', $enterpriseId)
|
||
->find();
|
||
if ($exists) {
|
||
return;
|
||
}
|
||
|
||
$personal = Db::name('user_profile')
|
||
->where('userId', $userId)
|
||
->where('userType', 'personal')
|
||
->where(function ($q) {
|
||
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
|
||
})
|
||
->order('id', 'desc')
|
||
->find();
|
||
|
||
$base = [
|
||
'userId' => $userId,
|
||
'userType' => 'enterprise',
|
||
'enterpriseId' => $enterpriseId,
|
||
'testsTotal' => 0,
|
||
'testsMbti' => 0,
|
||
'testsDisc' => 0,
|
||
'testsPdp' => 0,
|
||
'testsFace' => 0,
|
||
'ordersTotal' => 0,
|
||
'paidOrders' => 0,
|
||
'totalPaidAmount' => 0,
|
||
'lastTestResultId' => null,
|
||
'lastTestType' => null,
|
||
'lastTestAt' => null,
|
||
'lastMbtiResultId' => null,
|
||
'lastDiscResultId' => null,
|
||
'lastPdpResultId' => null,
|
||
'lastFaceResultId' => null,
|
||
'createdAt' => $now,
|
||
'updatedAt' => $now,
|
||
];
|
||
|
||
if ($personal) {
|
||
$copyFields = [
|
||
'testsTotal', 'testsMbti', 'testsDisc', 'testsPdp', 'testsFace',
|
||
'ordersTotal', 'paidOrders', 'totalPaidAmount',
|
||
'lastTestResultId', 'lastTestType', 'lastTestAt',
|
||
'lastMbtiResultId', 'lastDiscResultId', 'lastPdpResultId', 'lastFaceResultId',
|
||
];
|
||
foreach ($copyFields as $f) {
|
||
if (array_key_exists($f, $personal) && $personal[$f] !== null) {
|
||
$base[$f] = $personal[$f];
|
||
}
|
||
}
|
||
}
|
||
|
||
Db::name('user_profile')->insert($base);
|
||
}
|
||
|
||
/**
|
||
* 归属目标企业且仍存在 personal(NULL) 画像行的用户,逐批合并(避免一次载入全部 userId / 超长事务)。
|
||
*
|
||
* @return int 实际调用合并逻辑的用户数
|
||
*/
|
||
private function mergePersonalProfilesForEnterpriseInBatches(int $targetEnterpriseId, int $now, int $batchSize = 400): int
|
||
{
|
||
if ($targetEnterpriseId <= 0) {
|
||
return 0;
|
||
}
|
||
|
||
$tUp = Db::name('user_profile')->getTable();
|
||
$tWu = Db::name('wechat_users')->getTable();
|
||
$batchSize = max(50, min($batchSize, 2000));
|
||
$lastUid = 0;
|
||
$merged = 0;
|
||
|
||
while (true) {
|
||
$sql = 'SELECT DISTINCT `up`.`userId` AS `uid` FROM `' . $tUp . '` `up` '
|
||
. 'INNER JOIN `' . $tWu . '` `w` ON `w`.`id` = `up`.`userId` '
|
||
. 'WHERE `up`.`userType` = \'personal\' '
|
||
. 'AND (`up`.`enterpriseId` IS NULL OR `up`.`enterpriseId` = 0) '
|
||
. 'AND `w`.`enterpriseId` = ? '
|
||
. 'AND `up`.`userId` > ? '
|
||
. 'ORDER BY `up`.`userId` ASC '
|
||
. 'LIMIT ' . $batchSize;
|
||
|
||
$rows = Db::query($sql, [$targetEnterpriseId, $lastUid]);
|
||
if ($rows === null || $rows === [] || !is_array($rows)) {
|
||
break;
|
||
}
|
||
|
||
$uids = [];
|
||
foreach ($rows as $row) {
|
||
$uid = (int) ($row['uid'] ?? 0);
|
||
if ($uid > 0) {
|
||
$uids[] = $uid;
|
||
}
|
||
}
|
||
if ($uids === []) {
|
||
break;
|
||
}
|
||
|
||
foreach ($uids as $uid) {
|
||
$this->mergePersonalUserProfileIntoEnterprise($uid, $targetEnterpriseId, $now);
|
||
$merged++;
|
||
}
|
||
|
||
$lastUid = max($uids);
|
||
if (count($rows) < $batchSize) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
return $merged;
|
||
}
|
||
|
||
/**
|
||
* 存客宝归并:wechat_users 无企业用户写入目标企业;
|
||
* 已/将归属该企业的用户下,test_results 中 enterpriseId 为空的记录补写目标企业;
|
||
* user_profile 中 personal(NULL) 与 enterprise(目标) 合并计数后删除 personal 行。
|
||
*
|
||
* POST /api/v1/superadmin/data-migration/attach-orphans-to-cunkbao
|
||
*
|
||
* Body: targetEnterpriseId (可选)、dryRun (默认 true)、confirm
|
||
*/
|
||
public function attachOrphansToCunkbao()
|
||
{
|
||
$actor = $this->request->user ?? null;
|
||
if (!$actor || ($actor['role'] ?? '') !== 'superadmin') {
|
||
return error('仅超级管理员可操作', 403);
|
||
}
|
||
|
||
$body = Request::post();
|
||
if (!is_array($body)) {
|
||
$body = [];
|
||
}
|
||
|
||
$dryRun = array_key_exists('dryRun', $body) ? (bool) $body['dryRun'] : true;
|
||
$confirm = !empty($body['confirm']);
|
||
|
||
$targetEnterpriseId = (int) ($body['targetEnterpriseId'] ?? 0);
|
||
if ($targetEnterpriseId <= 0) {
|
||
$row = Db::name('enterprises')->where('name', 'like', '%存客宝%')->order('id', 'asc')->find();
|
||
if (!$row) {
|
||
return error('未找到名称包含「存客宝」的企业,请先在企业管理中创建或传入 targetEnterpriseId', 404);
|
||
}
|
||
$targetEnterpriseId = (int) $row['id'];
|
||
}
|
||
|
||
$ent = Db::name('enterprises')->where('id', $targetEnterpriseId)->find();
|
||
if (!$ent) {
|
||
return error('目标企业不存在', 404);
|
||
}
|
||
|
||
$eid = $targetEnterpriseId;
|
||
|
||
$wechatAffected = (int) Db::name('wechat_users')
|
||
->whereRaw($this->sqlWechatUserUnassignedEnterprise())
|
||
->count();
|
||
|
||
$wechatScopeExpr = '((w.`enterpriseId` IS NULL OR w.`enterpriseId` = 0) OR w.`enterpriseId` = ' . $eid . ')';
|
||
|
||
$testResultsAffected = (int) Db::name('test_results')->alias('tr')
|
||
->join('wechat_users w', 'w.id = tr.userId')
|
||
->whereRaw('(tr.`enterpriseId` IS NULL OR tr.`enterpriseId` = 0)')
|
||
->whereRaw($wechatScopeExpr)
|
||
->count();
|
||
|
||
$userProfilePersonalRows = (int) Db::name('user_profile')->alias('up')
|
||
->join('wechat_users w', 'w.id = up.userId')
|
||
->where('up.userType', 'personal')
|
||
->whereRaw('(up.`enterpriseId` IS NULL OR up.`enterpriseId` = 0)')
|
||
->whereRaw($wechatScopeExpr)
|
||
->count();
|
||
|
||
$preview = [
|
||
'targetEnterpriseId' => $targetEnterpriseId,
|
||
'enterpriseName' => $ent['name'] ?? '',
|
||
'wechatUsersRows' => $wechatAffected,
|
||
'testResultsRows' => $testResultsAffected,
|
||
'userProfilePersonalRows' => $userProfilePersonalRows,
|
||
'dryRun' => $dryRun,
|
||
];
|
||
|
||
$nothingToDo = $wechatAffected === 0 && $testResultsAffected === 0 && $userProfilePersonalRows === 0;
|
||
|
||
if ($dryRun || !$confirm) {
|
||
$preview['hint'] = $nothingToDo
|
||
? '当前无需归并:归属用户、测试记录与画像均无待处理项。'
|
||
: ($dryRun
|
||
? '当前为预览。写入请传 dryRun=false 且 confirm=true。'
|
||
: '未写入:请同时传 dryRun=false 与 confirm=true。');
|
||
return success($preview);
|
||
}
|
||
|
||
if ($nothingToDo) {
|
||
$preview['hint'] = '无需写入。';
|
||
return success($preview, '无需归并');
|
||
}
|
||
|
||
if (function_exists('set_time_limit')) {
|
||
@set_time_limit(0);
|
||
}
|
||
@ignore_user_abort(true);
|
||
|
||
$now = time();
|
||
|
||
Db::startTrans();
|
||
try {
|
||
Db::name('wechat_users')
|
||
->whereRaw($this->sqlWechatUserUnassignedEnterprise())
|
||
->update([
|
||
'enterpriseId' => $targetEnterpriseId,
|
||
'updatedAt' => $now,
|
||
]);
|
||
|
||
$trTable = Db::name('test_results')->getTable();
|
||
$wuTable = Db::name('wechat_users')->getTable();
|
||
Db::execute(
|
||
'UPDATE `' . $trTable . '` AS `tr` INNER JOIN `' . $wuTable . '` AS `w` ON `tr`.`userId` = `w`.`id` '
|
||
. 'SET `tr`.`enterpriseId` = ?, `tr`.`testScope` = \'enterprise\', `tr`.`updatedAt` = ? '
|
||
. 'WHERE `w`.`enterpriseId` = ? AND (`tr`.`enterpriseId` IS NULL OR `tr`.`enterpriseId` = 0)',
|
||
[$targetEnterpriseId, $now, $targetEnterpriseId]
|
||
);
|
||
|
||
Db::commit();
|
||
} catch (\Throwable $e) {
|
||
Db::rollback();
|
||
|
||
return error('归并失败(用户/测试写入):' . $e->getMessage(), 500);
|
||
}
|
||
|
||
try {
|
||
$mergedProfiles = $this->mergePersonalProfilesForEnterpriseInBatches($targetEnterpriseId, $now);
|
||
} catch (\Throwable $e) {
|
||
return error(
|
||
'用户与测试记录已写入,但画像合并失败(可重试本接口):' . $e->getMessage(),
|
||
500
|
||
);
|
||
}
|
||
|
||
$preview['executed'] = true;
|
||
$preview['profileMergeProcessed'] = $mergedProfiles;
|
||
$preview['hint'] = '已写入:小程序用户归属、无企业测试记录已批量更新;personal 画像已合并 '
|
||
. $mergedProfiles . ' 人次。';
|
||
|
||
return success($preview, '归并完成');
|
||
}
|
||
}
|