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

847 lines
37 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\admin;
use app\BaseController;
use app\common\service\ResumeUploadsAdminService;
use app\controller\admin\concern\ExtractsTestResults;
use think\facade\Db;
use think\facade\Log;
use think\facade\Request;
/**
* 测试用户(小程序用户)管理 - 只读列表与详情
* 数据来源wechat_users测试记录来自 test_resultsuserId 关联 wechat_users.id
*/
class AppUser extends BaseController
{
use ExtractsTestResults;
/**
* 测试用户列表:分页、关键词搜索
* GET /api/v1/admin/app-users?page=1&pageSize=20&keyword=
*/
public function index()
{
$user = $this->request->user ?? null;
if (!$user) {
return error('未登录', 401);
}
if (!in_array($user['role'] ?? '', ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$page = (int) Request::param('page', 1);
$pageSize = (int) Request::param('pageSize', 20);
$pageSize = min(max($pageSize, 1), 100);
$keyword = trim(Request::param('keyword', ''));
$coldFaceLevelRaw = Request::param('coldFaceLevel', '');
$coldFaceLevels = [];
if (is_array($coldFaceLevelRaw)) {
$coldFaceLevels = array_values(array_filter(array_map('strval', $coldFaceLevelRaw)));
} elseif (is_string($coldFaceLevelRaw) && $coldFaceLevelRaw !== '') {
$coldFaceLevels = array_values(array_filter(array_map('trim', explode(',', $coldFaceLevelRaw))));
}
$coldFaceLevels = array_values(array_intersect($coldFaceLevels, ['cold', 'neutral', 'warm']));
$where = [];
if ($keyword !== '') {
$where[] = ['nickname|phone|city|province', 'like', '%' . $keyword . '%'];
}
// admin / enterprise_admin 均只能看本企业数据
$enterpriseId = $user['enterpriseId'] ?? null;
if (!$enterpriseId) {
// JWT 未含 enterpriseId 时回退查库(兼容旧 token
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
$enterpriseId = $adminRow['enterpriseId'] ?? null;
}
$baseQuery = null;
// 企业场景:禁止 column 全量 userId + 超大 whereIn改为「画像池子 + openid 去重」子查询 JOIN全在库内完成
if ($enterpriseId) {
$eid = (int) $enterpriseId;
// 子查询会在 SQL 中出现两次,条件用字面量避免占位符绑定重复/错位的风险
$poolSql = Db::name('user_profile')
->whereRaw('enterpriseId = ' . $eid)
->group('userId')
->field('userId')
->buildSql(true);
try {
$dedupSql = Db::name('wechat_users')
->alias('w2')
->join([$poolSql => 'p2'], 'w2.id = p2.userId')
->field('w2.openid, MAX(w2.id) AS mid')
->group('w2.openid')
->buildSql(true);
} catch (\Throwable $e) {
return paginate_response([], 0, $page, $pageSize);
}
$baseQuery = Db::name('wechat_users')->alias('w')
->join([$poolSql => 'p'], 'w.id = p.userId')
->join([$dedupSql => 'd'], 'w.id = d.mid');
if (!empty($coldFaceLevels)) {
try {
$cfSql = Db::name('user_profile')
->whereRaw('enterpriseId = ' . $eid)
->whereIn('coldFaceLevel', $coldFaceLevels)
->field('userId')
->buildSql(true);
$baseQuery->join([$cfSql => 'cf'], 'w.id = cf.userId');
} catch (\Throwable $e) {
// coldFace 字段不存在时忽略筛选
}
}
if ($keyword !== '') {
$like = '%' . addcslashes($keyword, '%_\\') . '%';
$baseQuery->whereRaw(
'(w.nickname LIKE ? OR w.phone LIKE ? OR w.city LIKE ? OR w.province LIKE ?)',
[$like, $like, $like, $like]
);
}
} else {
// 无企业归属(极少):沿用全表 openid 去重 + IN 列表
try {
$dedupIds = Db::name('wechat_users')->field('openid, MAX(id) as mid')->group('openid')->column('mid');
$dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
} catch (\Throwable $e) {
$dedupIds = Db::name('wechat_users')->column('id');
$dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
}
if (empty($dedupIds)) {
return paginate_response([], 0, $page, $pageSize);
}
$baseQuery = Db::name('wechat_users')->where('id', 'in', $dedupIds);
if ($where) {
$baseQuery->where($where);
}
}
if ($enterpriseId) {
$total = (int) (clone $baseQuery)->distinct(true)->count('w.id');
// 先按「去重后的 w.id」分页再拉全字段避免 JOIN 放大行数导致 LIMIT 作用在重复用户上,
// 进而出现「一页里混入全库统计感」或本页人数与 pageSize 不一致。
$idRows = (clone $baseQuery)
->field('w.id')
->group('w.id')
->order('w.id', 'desc')
->page($page, $pageSize)
->select()
->toArray();
$orderedIds = array_values(array_filter(array_map('intval', array_column($idRows, 'id'))));
if ($orderedIds === []) {
$list = [];
} else {
$rows = Db::name('wechat_users')->alias('w')
->whereIn('w.id', $orderedIds)
->field('w.id,w.nickname,w.openid,w.avatar,w.phone,w.gender,w.country,w.province,w.city,w.status,w.lastLoginAt,w.createdAt')
->select()
->toArray();
$byId = [];
foreach ($rows as $r) {
$byId[(int) ($r['id'] ?? 0)] = $r;
}
$list = [];
foreach ($orderedIds as $oid) {
if (isset($byId[$oid])) {
$list[] = $byId[$oid];
}
}
}
} else {
$total = (int) (clone $baseQuery)->count();
$list = (clone $baseQuery)
->field('id,nickname,openid,avatar,phone,gender,country,province,city,status,lastLoginAt,createdAt')
->order('id', 'desc')
->page($page, $pageSize)
->select()
->toArray();
}
// 为每条用户附加测试统计test_results.userId 对应 wechat_users.id
$ids = array_column($list, 'id');
$testCounts = [];
$lastTestAt = [];
$testTypes = []; // 每个用户最新几条测试类型,用于展示 MBTI/PDP/DISC
$payStats = [];
$enterpriseName = null;
if ($enterpriseId) {
$ent = Db::name('enterprises')->where('id', $enterpriseId)->find();
$enterpriseName = $ent['name'] ?? ('企业' . $enterpriseId);
}
$coopMap = [];
$gaokaoMap = [];
$gaokaoReportMap = [];
if (!empty($ids)) {
// 测试统计严格按 test_results.enterpriseId 归属企业过滤
$trBase = Db::name('test_results')->where('userId', 'in', $ids);
if ($enterpriseId) {
$trBase->where('enterpriseId', $enterpriseId);
}
// 次数与最近测试时间一次 GROUP 查完,减少往返
$trAggRows = (clone $trBase)
->field('userId, COUNT(*) AS cnt, MAX(createdAt) AS lastAt')
->group('userId')
->select()
->toArray();
foreach ($trAggRows as $r) {
$uid = (int) ($r['userId'] ?? 0);
if ($uid > 0) {
$testCounts[$uid] = (int) ($r['cnt'] ?? 0);
$lastTestAt[$uid] = $r['lastAt'];
}
}
// 每人每种 testType 仅取最新一条(含 MAX(id) 消解同一时间戳多行),禁止 select 全量记录
try {
$aggSub = Db::name('test_results')->where('userId', 'in', $ids);
if ($enterpriseId) {
$aggSub->where('enterpriseId', $enterpriseId);
}
$aggSql = $aggSub
->field('userId, testType, MAX(createdAt) as mc, MAX(id) as mid')
->group('userId, testType')
->buildSql(true);
$lastDetailQuery = Db::name('test_results')->alias('t')
->join([$aggSql => 'agg'], 't.userId = agg.userId AND t.testType = agg.testType AND t.id = agg.mid')
->field('t.id, t.userId, t.testType, t.resultData, t.createdAt, t.enterpriseId as testEnterpriseId');
if ($enterpriseId) {
$lastDetailQuery->where('t.enterpriseId', $enterpriseId);
}
$lastRows = $lastDetailQuery->select()->toArray();
} catch (\Throwable $e) {
$lastRows = (clone $trBase)
->field('id, userId, testType, resultData, createdAt, enterpriseId as testEnterpriseId')
->order('createdAt', 'desc')
->limit(2000)
->select()
->toArray();
}
foreach ($lastRows as $row) {
$uid = $row['userId'];
if (!isset($testTypes[$uid])) {
$testTypes[$uid] = [];
}
$testTypes[$uid][] = [
'testType' => $row['testType'],
'result' => is_string($row['resultData'] ?? '') ? ($row['resultData'] ?? '') : json_encode($row['resultData'] ?? '', JSON_UNESCAPED_UNICODE),
'createdAt' => $row['createdAt'],
'testScope' => !empty($row['testEnterpriseId']) ? 'enterprise' : 'personal',
];
}
foreach ($testTypes as $uid => &$tlist) {
usort($tlist, static function ($a, $b) {
return (int) ($b['createdAt'] ?? 0) <=> (int) ($a['createdAt'] ?? 0);
});
}
unset($tlist);
// 付款统计user_profile按当前企业过滤
try {
$profilesQuery = Db::name('user_profile')
->where('userId', 'in', $ids);
if ($enterpriseId) {
$profilesQuery->where('enterpriseId', $enterpriseId);
}
$profiles = $profilesQuery
->field('userId, SUM(paidOrders) AS paidOrders, SUM(totalPaidAmount) AS totalPaidAmount')
->group('userId')
->select()
->toArray();
foreach ($profiles as $p) {
$uid = (int) ($p['userId'] ?? 0);
if ($uid > 0) {
$payStats[$uid] = [
'paidOrders' => (int) ($p['paidOrders'] ?? 0),
'totalPaidAmount' => (int) ($p['totalPaidAmount'] ?? 0),
];
}
}
} catch (\Throwable $e) {
$payStats = [];
}
// 冷脸分字段(容错:迁移未执行时 coldFace* 字段缺失,查询异常则视为全空)
$coldFaceMap = [];
try {
$cfQuery = Db::name('user_profile')->where('userId', 'in', $ids);
if ($enterpriseId) {
$cfQuery->where('enterpriseId', $enterpriseId);
}
$cfRows = $cfQuery
->field('userId, coldFaceScore, coldFaceLevel, coldFaceUpdatedAt')
->select()
->toArray();
foreach ($cfRows as $r) {
$uid = (int) ($r['userId'] ?? 0);
if ($uid > 0) {
$coldFaceMap[$uid] = [
'score' => isset($r['coldFaceScore']) && $r['coldFaceScore'] !== null ? (int) $r['coldFaceScore'] : null,
'level' => $r['coldFaceLevel'] ?? null,
'updatedAt' => isset($r['coldFaceUpdatedAt']) ? (int) $r['coldFaceUpdatedAt'] : null,
];
}
}
} catch (\Throwable $e) {
$coldFaceMap = [];
}
// 本企业下用户合作意向user_cooperation_choices 唯一 keyuserId + enterpriseId
if ($enterpriseId) {
try {
$eid = (int) $enterpriseId;
$coopRows = Db::name('user_cooperation_choices')->alias('uc')
->leftJoin('enterprise_cooperation_modes ecm', 'ecm.enterpriseId = uc.enterpriseId AND ecm.modeCode = uc.modeCode')
->whereIn('uc.userId', $ids)
->where('uc.enterpriseId', $eid)
->field('uc.userId, uc.modeCode, uc.chosenAt, uc.updatedAt, ecm.title as modeTitle')
->select()
->toArray();
foreach ($coopRows as $cr) {
$uCo = (int) ($cr['userId'] ?? 0);
if ($uCo > 0) {
$coopMap[$uCo] = [
'modeCode' => (string) ($cr['modeCode'] ?? ''),
'modeTitle' => (string) ($cr['modeTitle'] ?? ''),
'chosenAt' => (int) ($cr['chosenAt'] ?? 0),
'updatedAt' => (int) ($cr['updatedAt'] ?? 0),
];
}
}
} catch (\Throwable $e) {
$coopMap = [];
}
}
// 高考任务状态与最近报告摘要
try {
$gqRows = Db::name('gaokao_user_profile')
->whereIn('userId', $ids)
->where('tenantId', (int) $enterpriseId)
->field('id,userId,entryStatus,mbtiStatus,pdpStatus,discStatus,formStatus,analyzeStatus,lastAnalyzeAt,latestReportId,tagsJson')
->select()
->toArray();
$reportIds = [];
foreach ($gqRows as $gr) {
$uid = (int) ($gr['userId'] ?? 0);
if ($uid > 0) {
$gaokaoMap[$uid] = $gr;
if (!empty($gr['latestReportId'])) {
$reportIds[] = (int) $gr['latestReportId'];
}
}
}
$reportIds = array_values(array_unique(array_filter($reportIds)));
if ($reportIds) {
$rRows = Db::name('test_results')
->whereIn('id', $reportIds)
->where('testType', 'gaokao')
->field('id,resultData')
->select()
->toArray();
foreach ($rRows as $rr) {
$raw = $rr['resultData'] ?? '';
$rd = is_string($raw) ? (json_decode($raw, true) ?: []) : (is_array($raw) ? $raw : []);
$ov = (string) ($rd['overview'] ?? '');
if ($ov === '' && isset($rd['report']['overview'])) {
$ov = (string) $rd['report']['overview'];
}
$gaokaoReportMap[(int) $rr['id']] = $ov;
}
}
} catch (\Throwable $e) {
$gaokaoMap = [];
$gaokaoReportMap = [];
}
}
foreach ($list as &$row) {
$id = $row['id'];
$testsForUser = $testTypes[$id] ?? [];
$row['username'] = $row['nickname'] ?? ('用户' . $id);
$row['testCount'] = (int) ($testCounts[$id] ?? 0);
$row['lastTestAt'] = $lastTestAt[$id] ?? null;
$row['tests'] = $testsForUser;
$row['mbtiType'] = $this->extractResultType($testsForUser, 'mbti');
$row['pdpType'] = $this->extractResultType($testsForUser, 'pdp');
$row['discType'] = $this->extractResultType($testsForUser, 'disc');
$row['sbtiType'] = $this->extractResultType($testsForUser, 'sbti');
$row['faceType'] = $this->extractResultType($testsForUser, 'face');
$row['faceMbtiType'] = $this->extractFaceSubType($testsForUser, 'mbti');
$row['faceDiscType'] = $this->extractFaceSubType($testsForUser, 'disc');
$row['facePdpType'] = $this->extractFaceSubType($testsForUser, 'pdp');
$row['enterprise'] = $enterpriseName !== null ? $enterpriseName : '全部';
$pay = $payStats[$id] ?? null;
$row['paidOrders'] = $pay ? (int) ($pay['paidOrders'] ?? 0) : 0;
$row['totalPaidAmount'] = $pay ? (int) ($pay['totalPaidAmount'] ?? 0) : 0;
$cf = $coldFaceMap[$id] ?? null;
if ((!$cf || $cf['score'] === null) && !empty($testsForUser)) {
$calc = $this->calcColdFace($testsForUser);
if ($calc) {
$this->writeColdFace((int) $id, $enterpriseId ? (int) $enterpriseId : null, $calc);
$cf = ['score' => $calc['score'], 'level' => $calc['level'], 'updatedAt' => time()];
}
}
$row['coldFaceScore'] = $cf && $cf['score'] !== null ? (int) $cf['score'] : null;
$row['coldFaceLevel'] = $cf && !empty($cf['level']) ? (string) $cf['level'] : null;
$row['coldFaceUpdatedAt'] = $cf ? ($cf['updatedAt'] ?? null) : null;
$co = $coopMap[$id] ?? null;
if ($co && ($co['modeCode'] !== '' || $co['modeTitle'] !== '' || (int) ($co['chosenAt'] ?? 0) > 0)) {
$row['cooperationModeCode'] = $co['modeCode'] !== '' ? $co['modeCode'] : null;
$row['cooperationModeTitle'] = $co['modeTitle'] !== '' ? $co['modeTitle'] : null;
$chAt = (int) ($co['chosenAt'] ?? 0);
$row['cooperationChosenAt'] = $chAt > 0 ? $chAt : null;
} else {
$row['cooperationModeCode'] = null;
$row['cooperationModeTitle'] = null;
$row['cooperationChosenAt'] = null;
}
$gq = $gaokaoMap[$id] ?? null;
$row['gaokaoEntryStatus'] = $gq ? (int) ($gq['entryStatus'] ?? 0) : 0;
$row['gaokaoAnalyzeStatus'] = $gq ? (int) ($gq['analyzeStatus'] ?? 0) : 0;
$row['gaokaoFormStatus'] = $gq ? (int) ($gq['formStatus'] ?? 0) : 0;
$row['gaokaoTaskStatus'] = [
'mbti' => $gq ? (int) ($gq['mbtiStatus'] ?? 0) : 0,
'pdp' => $gq ? (int) ($gq['pdpStatus'] ?? 0) : 0,
'disc' => $gq ? (int) ($gq['discStatus'] ?? 0) : 0,
];
$row['gaokaoLastAnalyzeAt'] = $gq ? (int) ($gq['lastAnalyzeAt'] ?? 0) : 0;
$rid = $gq ? (int) ($gq['latestReportId'] ?? 0) : 0;
$row['gaokaoOverview'] = $rid > 0 ? (string) ($gaokaoReportMap[$rid] ?? '') : '';
}
return paginate_response($list, $total, $page, $pageSize);
}
/**
* JOIN 后按去重用户计数(别名 w = wechat_users
* 不可用 fieldRaw + value(别名)ThinkPHP 的 value() 会清空 field 并把参数当成列名,聚合会丢失。
*
* @param \think\db\Query $query
*/
private function countDistinctAppUsers($query): int
{
try {
// find() 在无 where 时不执行 SQL仅 JOIN 也会被跳过),聚合恒错成 0
$row = (clone $query)
->whereRaw('1=1')
->fieldRaw('COUNT(DISTINCT `w`.`id`) AS `_agg_u`')
->find();
if (is_object($row) && method_exists($row, 'toArray')) {
$row = $row->toArray();
}
if (!is_array($row)) {
return 0;
}
return (int) ($row['_agg_u'] ?? 0);
} catch (\Throwable $e) {
Log::warning('[AppUser::countDistinctAppUsers] ' . $e->getMessage());
return 0;
}
}
/**
* 用户画像汇总卡片(与列表同一企业口径)
* GET /api/v1/admin/app-users/stats
*/
public function stats()
{
$user = $this->request->user ?? null;
if (!$user) {
return error('未登录', 401);
}
if (!in_array($user['role'] ?? '', ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$enterpriseId = $user['enterpriseId'] ?? null;
if (!$enterpriseId) {
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
$enterpriseId = $adminRow['enterpriseId'] ?? null;
}
$out = [
'faceCount' => 0,
'resumeCount' => 0,
'phoneCount' => 0,
'anyTestCount' => 0,
'cooperationCount' => 0,
];
try {
if ($enterpriseId) {
$eid = (int) $enterpriseId;
$poolSql = Db::name('user_profile')
->whereRaw('enterpriseId = ' . $eid)
->group('userId')
->field('userId')
->buildSql(true);
$dedupSql = Db::name('wechat_users')
->alias('w2')
->join([$poolSql => 'p2'], 'w2.id = p2.userId')
->field('w2.openid, MAX(w2.id) AS mid')
->group('w2.openid')
->buildSql(true);
$base = Db::name('wechat_users')->alias('w')
->join([$poolSql => 'p'], 'w.id = p.userId')
->join([$dedupSql => 'd'], 'w.id = d.mid');
$tblUp = Db::name('user_profile')->getTable();
$tblTr = Db::name('test_results')->getTable();
$out['faceCount'] = $this->countDistinctAppUsers(
(clone $base)->whereRaw(
'EXISTS (SELECT 1 FROM `' . $tblUp
. '` up WHERE up.userId = `w`.`id` AND up.enterpriseId = ? AND (TRIM(IFNULL(up.coldFaceLevel,\'\')) <> \'\' OR up.coldFaceScore IS NOT NULL))'
. ' OR EXISTS (SELECT 1 FROM `' . $tblTr
. '` tf WHERE tf.userId = `w`.`id` AND tf.enterpriseId = ? AND tf.testType IN (\'face\',\'ai\'))',
[$eid, $eid]
)
);
$out['phoneCount'] = $this->countDistinctAppUsers(
(clone $base)->whereRaw('TRIM(IFNULL(w.phone,\'\')) <> \'\'')
);
$out['anyTestCount'] = $this->countDistinctAppUsers(
(clone $base)
->join('test_results tr', 'tr.userId = w.id')
->where('tr.enterpriseId', $eid)
);
// 与「合作意向」Tab 一致:本企业 user_cooperation_choices 去重人数,不依赖 user_profile 池
$coopRow = Db::name('user_cooperation_choices')
->where('enterpriseId', $eid)
->fieldRaw('COUNT(DISTINCT `userId`) AS `_c`')
->find();
$out['cooperationCount'] = (int) (is_array($coopRow) ? ($coopRow['_c'] ?? 0) : 0);
$cntResumeUpload = $this->countDistinctAppUsers(
(clone $base)
->join('enterprise_resume_uploads eru', 'eru.userId = w.id')
->where('eru.enterpriseId', $eid)
);
$cntPhoneAndTest = $this->countDistinctAppUsers(
(clone $base)
->whereRaw('TRIM(IFNULL(w.phone,\'\')) <> \'\'')
->join('test_results tr2', 'tr2.userId = w.id')
->where('tr2.enterpriseId', $eid)
);
$cntBoth = $this->countDistinctAppUsers(
(clone $base)
->join('enterprise_resume_uploads eru', 'eru.userId = w.id')
->where('eru.enterpriseId', $eid)
->whereRaw('TRIM(IFNULL(w.phone,\'\')) <> \'\'')
->join('test_results tr3', 'tr3.userId = w.id')
->where('tr3.enterpriseId', $eid)
);
$out['resumeCount'] = max(0, $cntResumeUpload + $cntPhoneAndTest - $cntBoth);
} else {
$dedupSql = Db::name('wechat_users')
->alias('w2')
->field('w2.openid, MAX(w2.id) AS mid')
->group('w2.openid')
->buildSql(true);
$base = Db::name('wechat_users')->alias('w')
->join([$dedupSql => 'd'], 'w.id = d.mid');
$tblUp = Db::name('user_profile')->getTable();
$tblTr = Db::name('test_results')->getTable();
$out['faceCount'] = $this->countDistinctAppUsers(
(clone $base)->whereRaw(
'EXISTS (SELECT 1 FROM `' . $tblUp
. '` up WHERE up.userId = `w`.`id` AND (TRIM(IFNULL(up.coldFaceLevel,\'\')) <> \'\' OR up.coldFaceScore IS NOT NULL))'
. ' OR EXISTS (SELECT 1 FROM `' . $tblTr
. '` tf WHERE tf.userId = `w`.`id` AND tf.testType IN (\'face\',\'ai\'))'
)
);
$out['phoneCount'] = $this->countDistinctAppUsers(
(clone $base)->whereRaw('TRIM(IFNULL(w.phone,\'\')) <> \'\'')
);
$out['anyTestCount'] = $this->countDistinctAppUsers(
(clone $base)
->join('test_results tr', 'tr.userId = w.id')
);
// 与「合作意向」Tab 一致:全平台去重 userId无企业归属账号
$coopRowAll = Db::name('user_cooperation_choices')
->whereRaw('1=1')
->fieldRaw('COUNT(DISTINCT `userId`) AS `_c`')
->find();
$out['cooperationCount'] = (int) (is_array($coopRowAll) ? ($coopRowAll['_c'] ?? 0) : 0);
$cntResumeUpload = $this->countDistinctAppUsers(
(clone $base)
->join('enterprise_resume_uploads eru', 'eru.userId = w.id')
);
$cntPhoneAndTest = $this->countDistinctAppUsers(
(clone $base)
->whereRaw('TRIM(IFNULL(w.phone,\'\')) <> \'\'')
->join('test_results tr2', 'tr2.userId=w.id')
);
$cntBoth = $this->countDistinctAppUsers(
(clone $base)
->join('enterprise_resume_uploads eru', 'eru.userId=w.id')
->whereRaw('TRIM(IFNULL(w.phone,\'\')) <> \'\'')
->join('test_results tr3', 'tr3.userId=w.id')
);
$out['resumeCount'] = max(0, $cntResumeUpload + $cntPhoneAndTest - $cntBoth);
}
} catch (\Throwable $e) {
Log::warning('[AppUser::stats] ' . $e->getMessage(), ['trace' => $e->getTraceAsString()]);
}
return success($out);
}
/**
* 测试用户详情:基本信息 + 测试记录列表
* GET /api/v1/admin/app-users/:id
*/
public function detail($id)
{
$user = $this->request->user ?? null;
if (!$user) {
return error('未登录', 401);
}
if (!in_array($user['role'] ?? '', ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
// admin / enterprise_admin 均只能查看本企业的用户
$enterpriseId = $user['enterpriseId'] ?? null;
if (!$enterpriseId) {
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
$enterpriseId = $adminRow['enterpriseId'] ?? null;
}
if ($enterpriseId) {
// 使用 user_profile 判断该用户是否属于当前企业(以画像为主表)
$has = Db::name('user_profile')
->where('userId', $id)
->where('enterpriseId', $enterpriseId)
->find();
if (!$has) {
return error('无权限查看该用户', 403);
}
}
$row = Db::name('wechat_users')->where('id', $id)->find();
if (!$row) {
return error('用户不存在', 404);
}
$data = [
'id' => (int) $row['id'],
'username' => $row['nickname'] ?? ('用户' . $row['id']),
'nickname' => $row['nickname'] ?? '',
'avatar' => $row['avatar'] ?? '',
'phone' => $row['phone'] ?? '',
'email' => '',
'gender' => (int) ($row['gender'] ?? 0),
'country' => $row['country'] ?? '',
'province' => $row['province'] ?? '',
'city' => $row['city'] ?? '',
'status' => (int) ($row['status'] ?? 1),
'lastLoginAt' => isset($row['lastLoginAt']) ? (int) $row['lastLoginAt'] : null,
'createdAt' => isset($row['createdAt']) ? (int) $row['createdAt'] : null,
'updatedAt' => isset($row['updatedAt']) ? (int) $row['updatedAt'] : null,
];
// 测试列表:严格按 test_results.enterpriseId 归属本企业过滤
$testQuery = Db::name('test_results')->where('userId', $id);
if ($enterpriseId) {
$testQuery->where('enterpriseId', $enterpriseId);
}
$tests = $testQuery
->field('id, testType, resultData, enterpriseId as testEnterpriseId, createdAt, requiresPayment, isPaid, paidAmount, paidAt, orderId')
->order('createdAt', 'desc')
->select()
->toArray();
foreach ($tests as &$t) {
$raw = $t['resultData'] ?? '';
$t['result'] = is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE);
$t['testScope'] = !empty($t['testEnterpriseId']) ? 'enterprise' : 'personal';
unset($t['testEnterpriseId']);
}
$data['testCount'] = count($tests);
$data['testList'] = $tests;
$data['mbtiType'] = $this->extractResultType($tests, 'mbti');
$data['pdpType'] = $this->extractResultType($tests, 'pdp');
$data['discType'] = $this->extractResultType($tests, 'disc');
$data['sbtiType'] = $this->extractResultType($tests, 'sbti');
$data['faceType'] = $this->extractResultType($tests, 'face');
$data['faceMbtiType'] = $this->extractFaceSubType($tests, 'mbti');
$data['faceDiscType'] = $this->extractFaceSubType($tests, 'disc');
$data['facePdpType'] = $this->extractFaceSubType($tests, 'pdp');
// 冷脸分字段(详情)
$coldFace = null;
try {
$cfQuery = Db::name('user_profile')->where('userId', $id);
if ($enterpriseId) {
$cfQuery->where('enterpriseId', $enterpriseId);
}
$cfRow = $cfQuery->field('coldFaceScore, coldFaceLevel, coldFaceUpdatedAt')->find();
if ($cfRow) {
$coldFace = [
'score' => $cfRow['coldFaceScore'] !== null ? (int) $cfRow['coldFaceScore'] : null,
'level' => $cfRow['coldFaceLevel'] ?? null,
'updatedAt' => isset($cfRow['coldFaceUpdatedAt']) ? (int) $cfRow['coldFaceUpdatedAt'] : null,
];
}
} catch (\Throwable $e) {
$coldFace = null;
}
if ((!$coldFace || $coldFace['score'] === null) && !empty($tests)) {
$calc = $this->calcColdFace($tests);
if ($calc) {
$this->writeColdFace((int) $id, $enterpriseId ? (int) $enterpriseId : null, $calc);
$coldFace = ['score' => $calc['score'], 'level' => $calc['level'], 'updatedAt' => time()];
}
}
$data['coldFaceScore'] = $coldFace['score'] ?? null;
$data['coldFaceLevel'] = $coldFace['level'] ?? null;
$data['coldFaceUpdatedAt'] = $coldFace['updatedAt'] ?? null;
$data['cooperationModeCode'] = null;
$data['cooperationModeTitle'] = null;
$data['cooperationChosenAt'] = null;
if ($enterpriseId) {
try {
$cr = Db::name('user_cooperation_choices')->alias('uc')
->leftJoin('enterprise_cooperation_modes ecm', 'ecm.enterpriseId = uc.enterpriseId AND ecm.modeCode = uc.modeCode')
->where('uc.userId', $id)
->where('uc.enterpriseId', (int) $enterpriseId)
->field('uc.modeCode, uc.chosenAt, ecm.title as modeTitle')
->find();
if ($cr) {
$mc = trim((string) ($cr['modeCode'] ?? ''));
$mt = trim((string) ($cr['modeTitle'] ?? ''));
$data['cooperationModeCode'] = $mc !== '' ? $mc : null;
$data['cooperationModeTitle'] = $mt !== '' ? $mt : null;
$chAt = (int) ($cr['chosenAt'] ?? 0);
$data['cooperationChosenAt'] = $chAt > 0 ? $chAt : null;
}
} catch (\Throwable $e) {
// 表未迁移时忽略
}
}
$data['resumeUploads'] = ($enterpriseId && (int) $enterpriseId > 0)
? ResumeUploadsAdminService::listForWechatUser((int) $id, (int) $enterpriseId)
: [];
// 高考结果信息
try {
$gq = Db::name('gaokao_user_profile')
->where('userId', (int) $id)
->where('tenantId', (int) $enterpriseId)
->find();
if ($gq) {
$data['gaokaoProfile'] = $gq;
$rid = (int) ($gq['latestReportId'] ?? 0);
if ($rid > 0) {
$data['gaokaoLatestReport'] = Db::name('test_results')
->where('id', $rid)
->where('testType', 'gaokao')
->find();
}
} else {
$data['gaokaoProfile'] = null;
}
} catch (\Throwable $e) {
$data['gaokaoProfile'] = null;
}
return success($data);
}
/**
* 单条测试记录详情(与 testList 中单条结构一致,供后台「测试记录 → 详情」)
* GET /api/v1/admin/test-records/:id
*/
public function testRecord($id)
{
$user = $this->request->user ?? null;
if (!$user) {
return error('未登录', 401);
}
if (!in_array($user['role'] ?? '', ['admin', 'enterprise_admin'], true)) {
return error('无权限访问', 403);
}
$testId = (int) $id;
if ($testId <= 0) {
return error('记录ID无效', 400);
}
$tr = Db::name('test_results')->where('id', $testId)->find();
if (!$tr) {
return error('记录不存在', 404);
}
$wechatUserId = (int) ($tr['userId'] ?? 0);
if ($wechatUserId <= 0) {
return error('记录数据异常', 400);
}
$enterpriseId = $user['enterpriseId'] ?? null;
if (!$enterpriseId) {
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
$enterpriseId = $adminRow['enterpriseId'] ?? null;
}
if ($enterpriseId) {
$has = Db::name('user_profile')
->where('userId', $wechatUserId)
->where('enterpriseId', $enterpriseId)
->find();
if (!$has) {
return error('无权限查看', 403);
}
$tid = isset($tr['enterpriseId']) ? (int) $tr['enterpriseId'] : 0;
if ($tid !== (int) $enterpriseId) {
return error('无权限查看该测试记录', 403);
}
}
$raw = $tr['resultData'] ?? '';
$out = [
'id' => $testId,
'userId' => $wechatUserId,
'testType' => $tr['testType'] ?? '',
'result' => is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE),
'createdAt' => $tr['createdAt'] ?? null,
'requiresPayment' => (int) ($tr['requiresPayment'] ?? 0),
'isPaid' => (int) ($tr['isPaid'] ?? 0),
'paidAmount' => isset($tr['paidAmount']) ? (int) $tr['paidAmount'] : null,
'paidAt' => $tr['paidAt'] ?? null,
'orderId' => isset($tr['orderId']) ? (int) $tr['orderId'] : null,
'testScope' => !empty($tr['enterpriseId']) ? 'enterprise' : 'personal',
];
return success($out);
}
}