Files
MBTI_wang/api/app/controller/superadmin/DataMigration.php
Ghost a411bf7b21 高考版优化
审核模式优化
2026-04-29 18:01:26 +08:00

541 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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, '归并完成');
}
}