chore: 管理端与 API 批量更新(测评恢复、看板、分销与用户)

1、修复了用户详情/测评结果与人脸结果解析展示问题;管理端订单、分销及超管用户相关接口与展示异常。

2、新增了 testResultParse 统一解析工具、mbti_test_results 恢复 SQL 及生成脚本;补充 API 路由与 Dashboard/用户侧能力。

3、优化了企业/超管数据看板与分销结算逻辑;精简超管 Settings 与布局代码;完善 faceResultDetail;小程序 app.js 小幅同步。

Made-with: Cursor
This commit is contained in:
Ghost
2026-03-31 10:56:28 +08:00
parent 3af7bb35dd
commit 3fdf9b6ed2
18 changed files with 1306 additions and 546 deletions

View File

@@ -73,13 +73,11 @@ class AppUser extends BaseController
->join([$dedupSql => 'd'], 'w.id = d.mid');
if ($keyword !== '') {
$kw = '%' . $keyword . '%';
$baseQuery->where(function ($q) use ($kw) {
$q->whereLike('w.nickname', $kw)
->whereOr('w.phone', 'like', $kw)
->whereOr('w.city', 'like', $kw)
->whereOr('w.province', 'like', $kw);
});
$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 列表
@@ -103,7 +101,7 @@ class AppUser extends BaseController
$total = (int) (clone $baseQuery)->distinct(true)->count('w.id');
$list = (clone $baseQuery)
->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')
->order('w.createdAt', 'desc')
->order('w.id', 'desc')
->page($page, $pageSize)
->select()
->toArray();
@@ -111,7 +109,7 @@ class AppUser extends BaseController
$total = (int) (clone $baseQuery)->count();
$list = (clone $baseQuery)
->field('id,nickname,openid,avatar,phone,gender,country,province,city,status,lastLoginAt,createdAt')
->order('createdAt', 'desc')
->order('id', 'desc')
->page($page, $pageSize)
->select()
->toArray();
@@ -324,4 +322,71 @@ class AppUser extends BaseController
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);
}
}

View File

@@ -48,21 +48,13 @@ class Dashboard extends BaseController
}
}
// 企业用户 ID 集合(用于后续统计个人版测试
$enterpriseUserIds = [];
// 总用户数:企业维度用 COUNT全局用 DISTINCT openid禁止拉全表 id
if ($enterpriseId) {
$enterpriseUserIds = Db::name('wechat_users')
->where('enterpriseId', $enterpriseId)
->column('id');
$enterpriseUserIds = array_values(array_filter($enterpriseUserIds));
}
// 总用户数wechat_users.enterpriseId = 本企业
if ($enterpriseId) {
$totalUsers = count($enterpriseUserIds);
$totalUsers = (int) Db::name('wechat_users')->where('enterpriseId', $enterpriseId)->count();
} else {
try {
$totalUsers = (int) Db::name('wechat_users')->count('openid', true);
$ru = Db::name('wechat_users')->field('COUNT(DISTINCT openid) AS c')->find();
$totalUsers = (int) ($ru['c'] ?? 0);
} catch (\Throwable $e) {
$totalUsers = (int) Db::name('wechat_users')->count();
}
@@ -77,20 +69,18 @@ class Dashboard extends BaseController
$testsCompleted = (int) Db::name('test_results')->count();
}
// 今日活跃用户数
// 今日活跃用户数(库内 COUNT DISTINCT禁止拉全量 userId
$todayStart = strtotime(date('Y-m-d 00:00:00'));
$todayEnd = strtotime(date('Y-m-d 23:59:59'));
$activeQuery = Db::name('test_results')
$activeRow = Db::name('test_results')
->where('createdAt', '>=', $todayStart)
->where('createdAt', '<=', $todayEnd);
if ($enterpriseId) {
$activeQuery->where('enterpriseId', $enterpriseId);
$activeIds = $activeQuery->distinct(true)->column('userId');
$activeToday = count(array_filter($activeIds));
} else {
$activeIds = $activeQuery->distinct(true)->column('userId');
$activeToday = count(array_filter($activeIds));
}
->where('createdAt', '<=', $todayEnd)
->when((bool) $enterpriseId, static function ($q) use ($enterpriseId) {
$q->where('enterpriseId', $enterpriseId);
})
->field('COUNT(DISTINCT userId) AS c')
->find();
$activeToday = (int) ($activeRow['c'] ?? 0);
// 待审核暂返回0
$pendingReviews = 0;
@@ -151,7 +141,7 @@ class Dashboard extends BaseController
}
}
$topTestUsers = $this->buildTopTestUsers($enterpriseId, 10);
$topTestUsers = $this->buildTopTestUsers($enterpriseId, 20);
$testCatalog = $this->buildTestCatalog($enterpriseId);
$distributionMbti = $this->aggregateTestLabels($enterpriseId, 'mbti', 14);
@@ -180,7 +170,7 @@ class Dashboard extends BaseController
/**
* 按测试完成次数排序,取前 N 名小程序用户与列表页口径一致test_results 按企业过滤)
*/
private function buildTopTestUsers(?int $enterpriseId, int $limit = 10): array
private function buildTopTestUsers(?int $enterpriseId, int $limit = 20): array
{
$limit = min(max($limit, 1), 50);
$q = Db::name('test_results')->field('userId, COUNT(*) as cnt')->group('userId')->order('cnt', 'desc')->limit($limit);
@@ -215,31 +205,74 @@ class Dashboard extends BaseController
$userMap[(int) $u['id']] = $u;
}
$trQuery = Db::name('test_results')->whereIn('userId', $uids);
if ($enterpriseId) {
$trQuery->where('enterpriseId', $enterpriseId);
}
$testRows = $trQuery
->field('userId, testType, resultData, createdAt')
->order('createdAt', 'desc')
->select()
->toArray();
// 每人每类测评仅取最新一条MAX id禁止拉取 TopN 用户历史上全部 test_results
$testsByUser = [];
foreach ($testRows as $row) {
$uid = (int) ($row['userId'] ?? 0);
if ($uid <= 0) {
continue;
try {
$aggSub = Db::name('test_results')
->whereIn('userId', $uids)
->whereIn('testType', ['face', 'mbti', 'disc', 'pdp']);
if ($enterpriseId) {
$aggSub->where('enterpriseId', $enterpriseId);
}
if (!isset($testsByUser[$uid])) {
$testsByUser[$uid] = [];
$aggSql = $aggSub
->field('userId, testType, MAX(id) AS mid')
->group('userId, testType')
->buildSql(true);
$testRows = 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.userId, t.testType, t.resultData, t.createdAt')
->select()
->toArray();
foreach ($testRows as $row) {
$uid = (int) ($row['userId'] ?? 0);
if ($uid <= 0) {
continue;
}
if (!isset($testsByUser[$uid])) {
$testsByUser[$uid] = [];
}
$raw = $row['resultData'] ?? '';
$testsByUser[$uid][] = [
'testType' => $row['testType'] ?? '',
'result' => is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE),
'createdAt' => (int) ($row['createdAt'] ?? 0),
];
}
} catch (\Throwable $e) {
$trQuery = Db::name('test_results')->whereIn('userId', $uids);
if ($enterpriseId) {
$trQuery->where('enterpriseId', $enterpriseId);
}
$testRows = $trQuery
->field('userId, testType, resultData, createdAt')
->order('createdAt', 'desc')
->limit(5000)
->select()
->toArray();
foreach ($testRows as $row) {
$uid = (int) ($row['userId'] ?? 0);
if ($uid <= 0) {
continue;
}
if (!isset($testsByUser[$uid])) {
$testsByUser[$uid] = [];
}
$tt = strtolower((string) ($row['testType'] ?? ''));
foreach ($testsByUser[$uid] as $ex) {
if (strtolower((string) ($ex['testType'] ?? '')) === $tt) {
continue 2;
}
}
$raw = $row['resultData'] ?? '';
$testsByUser[$uid][] = [
'testType' => $row['testType'] ?? '',
'result' => is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE),
'createdAt' => (int) ($row['createdAt'] ?? 0),
];
}
$raw = $row['resultData'] ?? '';
$testsByUser[$uid][] = [
'testType' => $row['testType'] ?? '',
'result' => is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE),
'createdAt' => (int) ($row['createdAt'] ?? 0),
];
}
$out = [];
@@ -283,24 +316,34 @@ class Dashboard extends BaseController
['key' => 'disc', 'label' => 'DISC'],
['key' => 'pdp', 'label' => 'PDP'],
];
$q = Db::name('test_results')
->whereIn('testType', ['face', 'mbti', 'disc', 'pdp']);
if ($enterpriseId) {
$q->where('enterpriseId', $enterpriseId);
}
$rows = $q->field('testType, COUNT(*) AS records, COUNT(DISTINCT userId) AS uniqueUsers')
->group('testType')
->select()
->toArray();
$byType = [];
foreach ($rows as $r) {
$byType[$r['testType'] ?? ''] = [
'records' => (int) ($r['records'] ?? 0),
'uniqueUsers' => (int) ($r['uniqueUsers'] ?? 0),
];
}
$out = [];
foreach ($defs as $def) {
$tt = $def['key'];
$q = Db::name('test_results')->where('testType', $tt);
if ($enterpriseId) {
$q->where('enterpriseId', $enterpriseId);
}
$records = (int) $q->count();
$q2 = Db::name('test_results')->where('testType', $tt);
if ($enterpriseId) {
$q2->where('enterpriseId', $enterpriseId);
}
$uniqueUsers = (int) $q2->distinct(true)->count('userId');
$st = $byType[$tt] ?? ['records' => 0, 'uniqueUsers' => 0];
$out[] = [
'key' => $tt,
'label' => $def['label'],
'records' => $records,
'uniqueUsers' => $uniqueUsers,
'records' => $st['records'],
'uniqueUsers' => $st['uniqueUsers'],
];
}
@@ -313,6 +356,90 @@ class Dashboard extends BaseController
* @return array<int, array{label:string,count:int}>
*/
private function aggregateTestLabels(?int $enterpriseId, string $testType, int $topN): array
{
try {
return $this->aggregateTestLabelsWithSql($enterpriseId, $testType, $topN);
} catch (\Throwable $e) {
return $this->aggregateTestLabelsChunked($enterpriseId, $testType, $topN);
}
}
/**
* 使用 MySQL JSON 函数在库内分组统计(避免全表 chunk
*
* @return array<int, array{label:string,count:int}>
*/
private function aggregateTestLabelsWithSql(?int $enterpriseId, string $testType, int $topN): array
{
if (!in_array($testType, ['mbti', 'disc', 'pdp'], true)) {
throw new \InvalidArgumentException('unsupported testType');
}
$table = $this->testResultsTableName();
$entClause = '';
$bind = [$testType];
if ($enterpriseId) {
$entClause = ' AND `enterpriseId` = ? ';
$bind[] = $enterpriseId;
}
if ($testType === 'mbti') {
$expr = "CASE
WHEN JSON_VALID(`resultData`) = 0 OR `resultData` IS NULL OR `resultData` = '' THEN '未识别'
ELSE COALESCE(
NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.mbtiType'))), ''),
IF(JSON_TYPE(JSON_EXTRACT(`resultData`, '$.type')) = 'STRING', NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.type'))), ''), NULL),
IF(JSON_TYPE(JSON_EXTRACT(`resultData`, '$.result')) = 'STRING', NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.result'))), ''), NULL),
'未识别'
)
END";
} elseif ($testType === 'disc') {
$expr = "CASE
WHEN JSON_VALID(`resultData`) = 0 OR `resultData` IS NULL OR `resultData` = '' THEN '未识别'
ELSE COALESCE(
IF(JSON_TYPE(JSON_EXTRACT(`resultData`, '$.description.type')) = 'STRING', NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.description.type'))), ''), NULL),
IF(JSON_TYPE(JSON_EXTRACT(`resultData`, '$.dominantType')) = 'STRING', NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.dominantType'))), ''), NULL),
NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.disc.primary'))), ''),
IF(JSON_TYPE(JSON_EXTRACT(`resultData`, '$.disc')) = 'STRING', NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.disc'))), ''), NULL),
IF(JSON_TYPE(JSON_EXTRACT(`resultData`, '$.type')) = 'STRING', NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.type'))), ''), NULL),
'未识别'
)
END";
} else {
$expr = "CASE
WHEN JSON_VALID(`resultData`) = 0 OR `resultData` IS NULL OR `resultData` = '' THEN '未识别'
ELSE COALESCE(
IF(JSON_TYPE(JSON_EXTRACT(`resultData`, '$.description.type')) = 'STRING', NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.description.type'))), ''), NULL),
IF(JSON_TYPE(JSON_EXTRACT(`resultData`, '$.dominantType')) = 'STRING', NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.dominantType'))), ''), NULL),
NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.pdp.primary'))), ''),
IF(JSON_TYPE(JSON_EXTRACT(`resultData`, '$.pdp')) = 'STRING', NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.pdp'))), ''), NULL),
IF(JSON_TYPE(JSON_EXTRACT(`resultData`, '$.type')) = 'STRING', NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.type'))), ''), NULL),
'未识别'
)
END";
}
$sql = "SELECT `label`, COUNT(*) AS `cnt` FROM (
SELECT ({$expr}) AS `label`
FROM `{$table}`
WHERE `testType` = ? {$entClause}
) `t`
GROUP BY `label`";
$rows = Db::query($sql, $bind);
$counts = [];
foreach ($rows as $r) {
$counts[(string) ($r['label'] ?? '')] = (int) ($r['cnt'] ?? 0);
}
arsort($counts);
return $this->countsToTopNWithOther($counts, $topN);
}
/**
* @return array<int, array{label:string,count:int}>
*/
private function aggregateTestLabelsChunked(?int $enterpriseId, string $testType, int $topN): array
{
$counts = [];
$query = Db::name('test_results')
@@ -321,7 +448,7 @@ class Dashboard extends BaseController
if ($enterpriseId) {
$query->where('enterpriseId', $enterpriseId);
}
$query->chunk(400, function ($rows) use (&$counts, $testType) {
$query->chunk(800, function ($rows) use (&$counts, $testType) {
foreach ($rows as $row) {
$raw = $row['resultData'] ?? '';
$label = $this->labelFromResultRow($testType, $raw);
@@ -342,6 +469,87 @@ class Dashboard extends BaseController
* @return array{mbti:array,disc:array,pdp:array}
*/
private function aggregateFaceSubtypeHints(?int $enterpriseId, int $topN): array
{
try {
return $this->aggregateFaceSubtypeHintsWithSql($enterpriseId, $topN);
} catch (\Throwable $e) {
return $this->aggregateFaceSubtypeHintsChunked($enterpriseId, $topN);
}
}
/**
* @return array{mbti:array,disc:array,pdp:array}
*/
private function aggregateFaceSubtypeHintsWithSql(?int $enterpriseId, int $topN): array
{
$table = $this->testResultsTableName();
$entClause = '';
$bind = [];
if ($enterpriseId) {
$entClause = ' AND `enterpriseId` = ? ';
$bind[] = $enterpriseId;
}
$sqlFaceMbti = "SELECT `label`, COUNT(*) AS `cnt` FROM (
SELECT COALESCE(
NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.mbti.type'))), ''),
NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.mbtiType'))), ''),
''
) AS `label`
FROM `{$table}`
WHERE `testType` = 'face' AND JSON_VALID(`resultData`) {$entClause}
) `t`
WHERE `label` <> ''
GROUP BY `label`";
$sqlFaceDisc = "SELECT `label`, COUNT(*) AS `cnt` FROM (
SELECT COALESCE(
NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.disc.primary'))), ''),
IF(JSON_TYPE(JSON_EXTRACT(`resultData`, '$.disc')) = 'STRING', NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.disc'))), ''), ''),
''
) AS `label`
FROM `{$table}`
WHERE `testType` = 'face' AND JSON_VALID(`resultData`) {$entClause}
) `t`
WHERE `label` <> ''
GROUP BY `label`";
$sqlFacePdp = "SELECT `label`, COUNT(*) AS `cnt` FROM (
SELECT COALESCE(
NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.pdp.primary'))), ''),
IF(JSON_TYPE(JSON_EXTRACT(`resultData`, '$.pdp')) = 'STRING', NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(`resultData`, '$.pdp'))), ''), ''),
''
) AS `label`
FROM `{$table}`
WHERE `testType` = 'face' AND JSON_VALID(`resultData`) {$entClause}
) `t`
WHERE `label` <> ''
GROUP BY `label`";
$out = [];
foreach (
[
'mbti' => $sqlFaceMbti,
'disc' => $sqlFaceDisc,
'pdp' => $sqlFacePdp,
] as $k => $sql
) {
$rows = Db::query($sql, $bind);
$counts = [];
foreach ($rows as $r) {
$counts[(string) ($r['label'] ?? '')] = (int) ($r['cnt'] ?? 0);
}
arsort($counts);
$out[$k] = $this->countsToTopNWithOther($counts, $topN);
}
return $out;
}
/**
* @return array{mbti:array,disc:array,pdp:array}
*/
private function aggregateFaceSubtypeHintsChunked(?int $enterpriseId, int $topN): array
{
$subMaps = ['mbti' => [], 'disc' => [], 'pdp' => []];
$query = Db::name('test_results')
@@ -350,7 +558,7 @@ class Dashboard extends BaseController
if ($enterpriseId) {
$query->where('enterpriseId', $enterpriseId);
}
$query->chunk(400, function ($rows) use (&$subMaps) {
$query->chunk(800, function ($rows) use (&$subMaps) {
foreach ($rows as $row) {
$raw = $row['resultData'] ?? '';
$str = is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE);
@@ -376,6 +584,11 @@ class Dashboard extends BaseController
return $out;
}
private function testResultsTableName(): string
{
return Db::name('test_results')->getTable();
}
/**
* @param array<string,int> $counts
* @return array<int, array{label:string,count:int}>

View File

@@ -11,6 +11,65 @@ use think\facade\Request;
*/
class Distribution extends BaseController
{
/**
* 企业管理员 enterpriseIdJWT 可能为空,需与 users 表对齐(与订单等接口一致)
*/
private function resolveEnterpriseIdForAdmin(?array $user): int
{
if (!$user || !in_array($user['role'] ?? '', ['admin', 'enterprise_admin'], true)) {
return 0;
}
$eid = $user['enterpriseId'] ?? null;
if (is_array($eid)) {
$eid = null;
}
$enterpriseId = $eid !== null && $eid !== '' ? (int) $eid : 0;
if ($enterpriseId <= 0) {
$adminRow = Db::name('users')->where('id', (int) ($user['userId'] ?? 0))->find();
$e2 = $adminRow['enterpriseId'] ?? null;
$enterpriseId = ($e2 !== null && $e2 !== '') ? (int) $e2 : 0;
}
return $enterpriseId > 0 ? $enterpriseId : 0;
}
/**
* 本企业维度的「推荐人 id」集合佣金表 + 企业绑定表 + 历史仅填 agentId 的佣金行
*
* @return int[]
*/
private function distributorUserIdsForEnterprise(int $enterpriseId): array
{
if ($enterpriseId <= 0) {
return [];
}
$a = Db::name('commission_records')
->where('enterpriseId', $enterpriseId)
->where('inviterId', '>', 0)
->distinct(true)
->column('inviterId');
$b = Db::name('distribution_bindings')
->where('enterpriseId', $enterpriseId)
->where('inviterId', '>', 0)
->distinct(true)
->column('inviterId');
$c = Db::name('commission_records')
->where('enterpriseId', $enterpriseId)
->where(function ($q) {
$q->whereNull('inviterId')->whereOr('inviterId', 0);
})
->where('agentId', '>', 0)
->distinct(true)
->column('agentId');
$merged = array_merge($a ?: [], $b ?: [], $c ?: []);
return array_values(array_unique(array_filter(array_map('intval', $merged), static function ($v) {
return $v > 0;
})));
}
// ─────────────────────────────────────────────────────────────
// GET distribution/overview
// ─────────────────────────────────────────────────────────────
@@ -21,7 +80,10 @@ class Distribution extends BaseController
return error('无权限', 403);
}
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
$enterpriseId = $this->resolveEnterpriseIdForAdmin($user);
if ($enterpriseId <= 0) {
return error('未绑定企业或企业无效', 400);
}
$todayStart = strtotime(date('Y-m-d 00:00:00'));
$days = 7;
$trendStart = strtotime(date('Y-m-d 00:00:00', strtotime('-' . ($days - 1) . ' days')));
@@ -105,83 +167,102 @@ class Distribution extends BaseController
return error('无权限', 403);
}
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
$enterpriseId = $this->resolveEnterpriseIdForAdmin($user);
if ($enterpriseId <= 0) {
return error('未绑定企业或企业无效', 400);
}
$search = trim((string) Request::param('search', ''));
$page = max(1, (int) Request::param('page', 1));
$pageSize = min(100, (int) Request::param('pageSize', 20));
try {
// 找出与本企业关联的所有有过邀请行为的用户(不限 scopeenterpriseId 筛选)
$query = Db::name('distribution_bindings')
->where('enterpriseId', $enterpriseId)
->distinct(true)
->field('inviterId')
->buildSql();
$poolIds = $this->distributorUserIdsForEnterprise($enterpriseId);
if (empty($poolIds)) {
return success(['list' => [], 'total' => 0, 'page' => $page, 'pageSize' => $pageSize]);
}
$inviterQuery = Db::name('wechat_users')
->alias('u')
->whereRaw("u.id IN {$query}")
->whereIn('u.id', $poolIds)
->field('u.id, u.nickname, u.avatar, u.createdAt');
if ($search !== '') {
$inviterQuery->where(function ($q) use ($search) {
$q->where('u.nickname', 'like', "%{$search}%")
->whereOr('u.id', '=', is_numeric($search) ? (int)$search : -1);
->whereOr('u.id', '=', is_numeric($search) ? (int) $search : -1);
});
}
$total = (clone $inviterQuery)->count();
$inviters = $inviterQuery->page($page, $pageSize)->select()->toArray();
$inviterQuery->order('u.id', 'desc');
$inviterIds = array_column($inviters, 'id');
$total = (int) (clone $inviterQuery)->count();
$inviters = $inviterQuery->page($page, $pageSize)->select()->toArray();
$inviterIds = array_values(array_filter(array_map('intval', array_column($inviters, 'id'))));
$commStats = [];
$withdrawnMap = [];
$teamMap = [];
// 各邀请人的累计佣金与可提现佣金
$commStats = [];
if (!empty($inviterIds)) {
$rows = Db::name('commission_records')
->whereIn('inviterId', $inviterIds)
$inStr = implode(',', $inviterIds);
$rows = Db::name('commission_records')
->where('enterpriseId', $enterpriseId)
->field('inviterId,
SUM(IF(status IN ("paid","frozen"), commissionFen, 0)) as totalFen,
SUM(IF(status = "paid", commissionFen, 0)) as paidFen')
->group('inviterId')
->select()->toArray();
->whereRaw('COALESCE(inviterId, agentId) IN (' . $inStr . ')')
->fieldRaw("COALESCE(inviterId, agentId) AS inviterKey, SUM(IF(status IN ('paid','frozen'), commissionFen, 0)) AS totalFen, SUM(IF(status = 'paid', commissionFen, 0)) AS paidFen")
->group('inviterKey')
->select()
->toArray();
foreach ($rows as $r) {
$commStats[$r['inviterId']] = $r;
$kid = (int) ($r['inviterKey'] ?? 0);
if ($kid > 0) {
$commStats[$kid] = $r;
}
}
// 已提现金额
$withdrawnRows = Db::name('distribution_withdrawals')
->whereIn('userId', $inviterIds)
// 提现金额统计0=审核中,2=待收款,3=已收款
->whereIn('status', [0, 2, 3])
->field('userId, SUM(amountFen) as withdrawnFen')
->group('userId')
->select()->toArray();
$withdrawnMap = [];
->alias('w')
->join('wechat_users u', 'w.userId = u.id')
->whereIn('w.userId', $inviterIds)
->where('u.enterpriseId', $enterpriseId)
->whereIn('w.status', [0, 2, 3])
->field('w.userId, SUM(w.amountFen) AS withdrawnFen')
->group('w.userId')
->select()
->toArray();
foreach ($withdrawnRows as $r) {
$withdrawnMap[$r['userId']] = (int)$r['withdrawnFen'];
$withdrawnMap[(int) ($r['userId'] ?? 0)] = (int) ($r['withdrawnFen'] ?? 0);
}
// 团队人数(绑定人数,不限 scope
$now = time();
$teamRows = Db::name('distribution_bindings')
->whereIn('inviterId', $inviterIds)
->where('enterpriseId', $enterpriseId)
->field('inviterId, COUNT(DISTINCT inviteeId) as teamCount')
->group('inviterId')
->select()->toArray();
$teamMap = [];
->alias('b')
->leftJoin('wechat_users w', 'w.id = b.inviteeId')
->whereIn('b.inviterId', $inviterIds)
->where(function ($q) use ($enterpriseId) {
$q->where('b.enterpriseId', $enterpriseId)
->whereOr(function ($q2) use ($enterpriseId) {
$q2->whereNull('b.enterpriseId')->where('w.enterpriseId', $enterpriseId);
});
})
->where('b.status', 'active')
->where('b.expireAt', '>', $now)
->fieldRaw('b.inviterId, COUNT(DISTINCT b.inviteeId) AS teamCount')
->group('b.inviterId')
->select()
->toArray();
foreach ($teamRows as $r) {
$teamMap[$r['inviterId']] = (int)$r['teamCount'];
$teamMap[(int) ($r['inviterId'] ?? 0)] = (int) ($r['teamCount'] ?? 0);
}
}
$list = [];
foreach ($inviters as $inv) {
$uid = $inv['id'];
$totalFen = (int)($commStats[$uid]['totalFen'] ?? 0);
$paidFen = (int)($commStats[$uid]['paidFen'] ?? 0);
$withdrawn = $withdrawnMap[$uid] ?? 0;
$uid = (int) ($inv['id'] ?? 0);
$totalFen = (int) ($commStats[$uid]['totalFen'] ?? 0);
$paidFen = (int) ($commStats[$uid]['paidFen'] ?? 0);
$withdrawn = (int) ($withdrawnMap[$uid] ?? 0);
$avail = max(0, $paidFen - $withdrawn);
$list[] = [
'id' => $uid,
@@ -189,7 +270,7 @@ class Distribution extends BaseController
'avatar' => $inv['avatar'] ?? '',
'totalCommission' => number_format($totalFen / 100, 2, '.', ''),
'availableCommission'=> number_format($avail / 100, 2, '.', ''),
'teamCount' => $teamMap[$uid] ?? 0,
'teamCount' => (int) ($teamMap[$uid] ?? 0),
'teamPerformance' => '-',
'inviteCode' => '-',
'level' => '-',
@@ -213,7 +294,11 @@ class Distribution extends BaseController
return error('无权限', 403);
}
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
$enterpriseId = $this->resolveEnterpriseIdForAdmin($user);
if ($enterpriseId <= 0) {
return error('未绑定企业或企业无效', 400);
}
$page = max(1, (int) Request::param('page', 1));
$pageSize = min(100, (int) Request::param('pageSize', 20));
$status = Request::param('status', '');
@@ -226,7 +311,16 @@ class Distribution extends BaseController
->leftJoin('wechat_users invt', 'b.inviteeId = invt.id')
->field('b.*, inv.nickname as inviterName, inv.avatar as inviterAvatar,
invt.nickname as inviteeName, invt.avatar as inviteeAvatar')
->where('b.enterpriseId', $enterpriseId);
->where(function ($q) use ($enterpriseId) {
$q->where('b.enterpriseId', $enterpriseId)
->whereOr(function ($q2) use ($enterpriseId) {
$q2->whereNull('b.enterpriseId')
->where(function ($q3) use ($enterpriseId) {
$q3->where('inv.enterpriseId', $enterpriseId)
->whereOr('invt.enterpriseId', $enterpriseId);
});
});
});
if ($inviterId > 0) {
$query->where('b.inviterId', $inviterId);
@@ -264,7 +358,11 @@ class Distribution extends BaseController
return error('无权限', 403);
}
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
$enterpriseId = $this->resolveEnterpriseIdForAdmin($user);
if ($enterpriseId <= 0) {
return error('未绑定企业或企业无效', 400);
}
$page = max(1, (int) Request::param('page', 1));
$pageSize = min(100, (int) Request::param('pageSize', 20));
$status = Request::param('status', '');
@@ -279,7 +377,7 @@ class Distribution extends BaseController
->where('c.enterpriseId', $enterpriseId);
if ($inviterId > 0) {
$query->where('c.inviterId', $inviterId);
$query->whereRaw('COALESCE(c.inviterId, c.agentId) = ?', [$inviterId]);
}
if ($status) {
$query->where('c.status', $status);
@@ -356,7 +454,11 @@ class Distribution extends BaseController
return error('无权限', 403);
}
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
$enterpriseId = $this->resolveEnterpriseIdForAdmin($user);
if ($enterpriseId <= 0) {
return error('未绑定企业或企业无效', 400);
}
$page = max(1, (int) Request::param('page', 1));
$pageSize = min(100, (int) Request::param('pageSize', 20));
$status = Request::param('status', '');
@@ -426,7 +528,11 @@ class Distribution extends BaseController
return error('无权限', 403);
}
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
$enterpriseId = $this->resolveEnterpriseIdForAdmin($user);
if ($enterpriseId <= 0) {
return error('未绑定企业或企业无效', 400);
}
$note = Request::param('note', '');
$now = time();
@@ -497,7 +603,11 @@ class Distribution extends BaseController
return error('无权限', 403);
}
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
$enterpriseId = $this->resolveEnterpriseIdForAdmin($user);
if ($enterpriseId <= 0) {
return error('未绑定企业或企业无效', 400);
}
$note = Request::param('note', '');
$now = time();
@@ -549,7 +659,10 @@ class Distribution extends BaseController
return error('无权限', 403);
}
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
$enterpriseId = $this->resolveEnterpriseIdForAdmin($user);
if ($enterpriseId <= 0) {
return error('未绑定企业或企业无效', 400);
}
try {
$config = Db::name('system_config')
@@ -593,7 +706,11 @@ class Distribution extends BaseController
return error('无权限', 403);
}
$enterpriseId = (int) ($user['enterpriseId'] ?? 0);
$enterpriseId = $this->resolveEnterpriseIdForAdmin($user);
if ($enterpriseId <= 0) {
return error('未绑定企业或企业无效', 400);
}
$settings = Request::only(['enabled', 'promoCenterTitle', 'bindingDays', 'testSettings']);
$promoTitle = trim((string)($settings['promoCenterTitle'] ?? ''));

View File

@@ -69,6 +69,10 @@ class Order extends BaseController
}
}
// 与列表相同筛选条件下的全量统计(非当前页):已支付/已完成单数、实收金额(分)
$paidCompletedCount = (int) (clone $query)->whereIn('status', ['paid', 'completed'])->count();
$totalRevenueFen = (int) (clone $query)->whereIn('status', ['paid', 'completed'])->sum('amount');
$query->order('createdAt', 'desc');
$total = (int) (clone $query)->count();
$list = (clone $query)->page($page, $pageSize)->select()->toArray();
@@ -122,7 +126,16 @@ class Order extends BaseController
$row['testData'] = $testsByOrder[$row['id']] ?? [];
}
return paginate_response($list, $total, $page, $pageSize);
return success([
'list' => $list,
'total' => $total,
'page' => $page,
'pageSize' => $pageSize,
'hasMore' => ($page * $pageSize) < $total,
// 看板卡片:全量口径(随 keyword / status / productType / 企业 筛选变化,与 total 一致)
'paidCompletedCount' => $paidCompletedCount,
'totalRevenueFen' => $totalRevenueFen,
]);
}
/**

View File

@@ -2,6 +2,7 @@
namespace app\controller\superadmin;
use app\BaseController;
use app\controller\admin\concern\ExtractsTestResults;
use think\facade\Db;
use think\facade\Request;
@@ -11,6 +12,8 @@ use think\facade\Request;
*/
class AppUser extends BaseController
{
use ExtractsTestResults;
/**
* 名称包含「存客宝」的企业(取 id 最小的一条),用于合并个人侧无归属测试数据
*/
@@ -24,9 +27,32 @@ class AppUser extends BaseController
}
}
/**
* 从 test_results.resultData 中解析 MBTI 四字母(与概览统计口径一致)
*/
private function parseOverviewMbtiTypeFromResultData($raw): string
{
$dec = is_string($raw) ? json_decode($raw, true) : (is_array($raw) ? $raw : null);
if (!is_array($dec)) {
return '';
}
$type = '';
if (isset($dec['mbtiType'])) {
$type = $dec['mbtiType'];
} elseif (isset($dec['mbti']['type'])) {
$type = $dec['mbti']['type'];
} elseif (isset($dec['type'])) {
$type = $dec['type'];
}
return strtoupper(trim((string) $type));
}
/**
* 概览用户统计、卡片、MBTI 分布
* GET /api/v1/superadmin/app-users/overview
*
* 性能openid 去重与各统计均在库内 JOIN/聚合完成,禁止拉全表 mid、全表 distinct userId、全量 MBTI 行。
*/
public function overview()
{
@@ -35,9 +61,136 @@ class AppUser extends BaseController
return error('无权限访问', 403);
}
// ========== 统计全部基于 wechat_users.enterpriseId而非 test_results.enterpriseId ==========
$last30d = time() - 30 * 86400;
// ========== 统计全部基于 wechat_users.enterpriseId而非 test_results.enterpriseId ==========
try {
$dedupSql = Db::name('wechat_users')
->alias('w2')
->field('w2.openid, MAX(w2.id) AS mid')
->group('w2.openid')
->buildSql(true);
$trTestedSql = Db::name('test_results')->distinct(true)->field('userId')->buildSql(true);
$trActiveSql = Db::name('test_results')
->where('createdAt', '>=', $last30d)
->distinct(true)
->field('userId')
->buildSql(true);
$totalUsers = (int) Db::name('wechat_users')->alias('w')
->join([$dedupSql => 'd'], 'w.id = d.mid')
->count();
$testedRow = Db::name('test_results')->alias('tr')
->join([$dedupSql => 'd'], 'tr.userId = d.mid')
->field('COUNT(DISTINCT tr.userId) AS c')
->find();
$testedUsers = (int) ($testedRow['c'] ?? 0);
$activeRow = Db::name('test_results')->alias('tr')
->join([$dedupSql => 'd'], 'tr.userId = d.mid')
->where('tr.createdAt', '>=', $last30d)
->field('COUNT(DISTINCT tr.userId) AS c')
->find();
$activeUsers = (int) ($activeRow['c'] ?? 0);
$eidExpr = "CASE WHEN w.enterpriseId IS NULL OR w.enterpriseId = '' OR w.enterpriseId = 0 THEN 0 ELSE w.enterpriseId END";
$aggRows = Db::name('wechat_users')->alias('w')
->join([$dedupSql => 'ded'], 'w.id = ded.mid')
->leftJoin([$trTestedSql => 'tt'], 'tt.userId = w.id')
->leftJoin([$trActiveSql => 'ta'], 'ta.userId = w.id')
->field($eidExpr . ' AS eid, COUNT(*) AS total, SUM(IF(tt.userId IS NOT NULL, 1, 0)) AS tested, SUM(IF(ta.userId IS NOT NULL, 1, 0)) AS active')
->group($eidExpr)
->select()
->toArray();
$byEid = [];
foreach ($aggRows as $row) {
$byEid[(int) ($row['eid'] ?? 0)] = [
'total' => (int) ($row['total'] ?? 0),
'tested' => (int) ($row['tested'] ?? 0),
'active' => (int) ($row['active'] ?? 0),
];
}
$userCards = [
[
'type' => 'all',
'name' => '全部用户',
'total' => $totalUsers,
'active' => $activeUsers,
'tested' => $testedUsers,
],
];
$enterprises = Db::name('enterprises')->field('id,name')->select()->toArray();
foreach ($enterprises as $e) {
$eid = (int) $e['id'];
$st = $byEid[$eid] ?? ['total' => 0, 'active' => 0, 'tested' => 0];
$userCards[] = [
'type' => 'enterprise',
'enterpriseId' => $eid,
'name' => $e['name'] ?? ('企业' . $eid),
'total' => $st['total'],
'active' => $st['active'],
'tested' => $st['tested'],
];
}
$ind = $byEid[0] ?? null;
if ($ind !== null && ($ind['total'] ?? 0) > 0) {
$userCards[] = [
'type' => 'individual',
'name' => '个人用户(无企业)',
'total' => $ind['total'],
'active' => $ind['active'],
'tested' => $ind['tested'],
];
}
// MBTI每人仅最新一条MAX(id)),且须在 openid 去重后的用户集合内
$mbtiTypes = [];
try {
$mbtiLatestSub = Db::name('test_results')
->where('testType', 'mbti')
->field('userId, MAX(id) AS mid')
->group('userId')
->buildSql(true);
$mbtiRows = Db::name('test_results')->alias('t')
->join([$mbtiLatestSub => 'lm'], 't.userId = lm.userId AND t.id = lm.mid')
->join([$dedupSql => 'd'], 't.userId = d.mid')
->column('t.resultData');
foreach ($mbtiRows as $raw) {
$type = $this->parseOverviewMbtiTypeFromResultData($raw);
if ($type === '') {
continue;
}
$mbtiTypes[$type] = ($mbtiTypes[$type] ?? 0) + 1;
}
} catch (\Throwable $e) {
$mbtiTypes = [];
}
$mbtiDistribution = [];
foreach ($mbtiTypes as $type => $count) {
$mbtiDistribution[] = ['type' => $type, 'count' => $count];
}
return success([
'totalUsers' => $totalUsers,
'testedUsers' => $testedUsers,
'activeUsers' => $activeUsers,
'userCards' => $userCards,
'mbtiDistribution' => $mbtiDistribution,
]);
} catch (\Throwable $e) {
// 极少数环境无法 buildSql / JOIN 时降级(仍可能较慢,但保证有数据)
}
// openid 去重:每个 openid 只保留 id 最大的一条
try {
$dedupIds = Db::name('wechat_users')->field('openid, MAX(id) as mid')->group('openid')->column('mid');
$dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
@@ -46,14 +199,11 @@ class AppUser extends BaseController
$dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
}
$totalUsers = count($dedupIds);
$last30d = time() - 30 * 86400;
// 已测试用户(在 test_results 有记录的 userId 与 dedupIds 取交集)
$testedUserIds = Db::name('test_results')->distinct(true)->column('userId');
$testedUserIds = array_values(array_unique(array_filter(array_map('intval', $testedUserIds))));
$testedUsers = count(array_intersect($testedUserIds, $dedupIds));
// 近 30 天活跃用户
$activeUserIds = Db::name('test_results')
->where('createdAt', '>=', $last30d)
->distinct(true)
@@ -71,10 +221,8 @@ class AppUser extends BaseController
]
];
// 按 wechat_users.enterpriseId 分组统计
$userEidMap = Db::name('wechat_users')->where('id', 'in', $dedupIds)->column('enterpriseId', 'id');
// 按企业统计:从注册表统计 total再交叉 test_results 统计 active/tested
$enterprises = Db::name('enterprises')->field('id,name')->select()->toArray();
foreach ($enterprises as $e) {
$eid = (int) $e['id'];
@@ -95,7 +243,6 @@ class AppUser extends BaseController
];
}
// 无企业归属的个人用户
$individualUsers = array_keys(array_filter($userEidMap, function ($v) {
return $v === null || $v === '' || (int) $v === 0;
}));
@@ -112,7 +259,6 @@ class AppUser extends BaseController
];
}
// MBTI 类型分布:按用户去重,每人只计其最新一次 MBTI 结果
$mbtiTypes = [];
try {
$rows = Db::name('test_results')
@@ -127,21 +273,7 @@ class AppUser extends BaseController
if ($uid <= 0 || isset($seenUserIds[$uid])) {
continue;
}
$raw = $r['resultData'] ?? '';
$dec = is_string($raw) ? json_decode($raw, true) : (is_array($raw) ? $raw : null);
if (!is_array($dec)) {
$seenUserIds[$uid] = true;
continue;
}
$type = '';
if (isset($dec['mbtiType'])) {
$type = $dec['mbtiType'];
} elseif (isset($dec['mbti']['type'])) {
$type = $dec['mbti']['type'];
} elseif (isset($dec['type'])) {
$type = $dec['type'];
}
$type = strtoupper(trim((string) $type));
$type = $this->parseOverviewMbtiTypeFromResultData($r['resultData'] ?? '');
$seenUserIds[$uid] = true;
if ($type === '') {
continue;
@@ -184,52 +316,56 @@ class AppUser extends BaseController
$enterpriseId = Request::param('enterpriseId', '');
$mbti = trim(Request::param('mbti', ''));
$where = [];
if ($keyword !== '') {
$where[] = ['nickname|phone|city|province', 'like', '%' . $keyword . '%'];
}
// 按 openid 去重:每个 openid 只保留 id 最大的一条
// 与管理后台一致openid 去重在库内用子查询 JOIN禁止把全表 mid 拉进 PHP 再 whereIn
try {
$dedupIds = Db::name('wechat_users')->field('openid, MAX(id) as mid')->group('openid')->column('mid');
$dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
$dedupSql = Db::name('wechat_users')
->alias('w2')
->field('w2.openid, MAX(w2.id) AS mid')
->group('w2.openid')
->buildSql(true);
} 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);
$baseQuery = Db::name('wechat_users')->alias('w')
->join([$dedupSql => 'd'], 'w.id = d.mid');
// 含表别名时用 whereLike+whereOr 链式会在部分 ThinkPHP 版本报「查询表达式错误:W.PHONE」改 whereRaw 绑定
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]
);
}
// 池筛选:直接基于 wechat_users.enterpriseId
if ($pool === 'individual') {
$baseQuery->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', '')->whereOr('enterpriseId', 0);
$q->whereNull('w.enterpriseId')->whereOr('w.enterpriseId', '')->whereOr('w.enterpriseId', 0);
});
} elseif ($pool === 'enterprise' && $enterpriseId !== '') {
$baseQuery->where('enterpriseId', (int) $enterpriseId);
$baseQuery->where('w.enterpriseId', (int) $enterpriseId);
}
// MBTI 筛选:保留旧逻辑,从 test_results 取有 mbti 测试的用户
// MBTI 筛选与历史行为一致params.mbti 非空则只保留「有过 MBTI 测评」的用户;在库内 JOIN 替代全表 distinct userId
if ($mbti !== '') {
$mbtiUserIds = Db::name('test_results')->where('testType', 'mbti')->distinct(true)->column('userId');
$mbtiUserIds = array_values(array_unique(array_filter($mbtiUserIds)));
if (!empty($mbtiUserIds)) {
$baseQuery->where('id', 'in', $mbtiUserIds);
} else {
try {
$hasMbtiSql = Db::name('test_results')
->where('testType', 'mbti')
->field('userId')
->group('userId')
->buildSql(true);
$baseQuery->join([$hasMbtiSql => 'hm'], 'w.id = hm.userId');
} catch (\Throwable $e) {
return paginate_response([], 0, $page, $pageSize);
}
}
$total = $baseQuery->count();
$total = (int) (clone $baseQuery)->count();
$list = (clone $baseQuery)
->field('id,openid,nickname,avatar,phone,gender,country,province,city,status,lastLoginAt,createdAt')
->order('createdAt', 'desc')
->field('w.id,w.openid,w.nickname,w.avatar,w.phone,w.gender,w.country,w.province,w.city,w.status,w.lastLoginAt,w.createdAt,w.enterpriseId')
->order('w.id', 'desc')
->page($page, $pageSize)
->select()
->toArray();
@@ -238,46 +374,75 @@ class AppUser extends BaseController
$testCounts = [];
$lastTestAt = [];
$testTypes = [];
$userEnterprise = [];
$payStats = [];
$enterpriseNames = [];
if (!empty($ids)) {
$counts = Db::name('test_results')->where('userId', 'in', $ids)->group('userId')->column('COUNT(*) as cnt', 'userId');
$testCounts = $counts ?: [];
$lastRows = Db::name('test_results')
->where('userId', 'in', $ids)
->field('id, userId, testType, resultData, createdAt')
->order('createdAt', 'desc')
->select();
$trBase = Db::name('test_results')->where('userId', 'in', $ids);
$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)),与管理端一致,禁止拉全量 test_results
try {
$aggSub = Db::name('test_results')->where('userId', 'in', $ids);
$aggSql = $aggSub
->field('userId, testType, MAX(createdAt) as mc, MAX(id) as mid')
->group('userId, testType')
->buildSql(true);
$lastRows = 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')
->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($lastTestAt[$uid])) {
$lastTestAt[$uid] = $row['createdAt'];
}
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'],
'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);
// 所属企业直接从 wechat_users.enterpriseId 读取
$userEids = Db::name('wechat_users')->where('id', 'in', $ids)->column('enterpriseId', 'id');
$allEids = array_values(array_unique(array_filter(array_map('intval', $userEids))));
$enterpriseNames = [];
$allEids = [];
foreach ($list as $lr) {
$eid = isset($lr['enterpriseId']) ? (int) $lr['enterpriseId'] : 0;
if ($eid > 0) {
$allEids[] = $eid;
}
}
$allEids = array_values(array_unique($allEids));
if (!empty($allEids)) {
$enterpriseNames = Db::name('enterprises')->where('id', 'in', $allEids)->column('name', 'id');
}
foreach ($ids as $uid) {
$eid = isset($userEids[$uid]) ? (int) $userEids[$uid] : 0;
if ($eid > 0 && isset($enterpriseNames[$eid])) {
$userEnterprise[$uid] = $enterpriseNames[$eid];
} else {
$userEnterprise[$uid] = '个人用户(无企业)';
}
}
// 从用户画像表汇总支付统计(付款次数与总金额)
try {
@@ -316,7 +481,10 @@ class AppUser extends BaseController
$row['faceMbtiType'] = $this->extractFaceSubType($testsForUser, 'mbti');
$row['faceDiscType'] = $this->extractFaceSubType($testsForUser, 'disc');
$row['facePdpType'] = $this->extractFaceSubType($testsForUser, 'pdp');
$row['enterprise'] = $userEnterprise[$id] ?? '个人用户(无企业)';
$eidRow = isset($row['enterpriseId']) ? (int) $row['enterpriseId'] : 0;
$row['enterprise'] = ($eidRow > 0 && isset($enterpriseNames[$eidRow]))
? $enterpriseNames[$eidRow]
: '个人用户(无企业)';
$pay = $payStats[$id] ?? null;
$totalPaidFen = $pay ? (int) ($pay['totalPaidAmount'] ?? 0) : 0;
@@ -363,13 +531,15 @@ class AppUser extends BaseController
$tests = Db::name('test_results')
->where('userId', $id)
->field('id, testType, resultData, createdAt, requiresPayment, isPaid, paidAmount, paidAt, orderId')
->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);
@@ -392,6 +562,50 @@ class AppUser extends BaseController
return success($data);
}
/**
* 单条测试记录详情(与 testList 中单条结构一致)
* GET /api/v1/superadmin/test-records/:id
*/
public function testRecord($id)
{
$user = $this->request->user ?? null;
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
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);
}
$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);
}
/**
* 按企业测评池内与用户 MBTI/PDP/DISC 的同质比例推荐企业,并附带登记负责人联系方式。
* 仅超级管理后台使用;无测评维度时按池内活跃人数近似排序。
@@ -561,116 +775,4 @@ class AppUser extends BaseController
return mb_strtolower($s, 'UTF-8');
}
private function parseMbtiFromResult($result): string
{
if (!is_string($result)) return '';
$dec = json_decode($result, true);
if (is_array($dec)) {
return (string) ($dec['type'] ?? $dec['result'] ?? $dec['mbtiType'] ?? '');
}
return trim($result);
}
private function extractResultType(array $tests, string $type): string
{
$targetType = strtolower($type);
foreach ($tests as $t) {
if (strtolower($t['testType'] ?? '') !== $targetType) {
continue;
}
$result = $t['result'] ?? '';
if (!is_string($result)) {
continue;
}
$dec = json_decode($result, true);
if (!is_array($dec)) {
// 无法解析 JSON 时,直接返回原始字符串
return $targetType === 'face' ? '人脸分析' : trim($result);
}
// 人脸分析:有记录就返回固定标签
if ($targetType === 'face') {
return '人脸分析';
}
// MBTI直接读 mbtiType/type
if ($targetType === 'mbti') {
return (string) ($dec['mbtiType'] ?? $dec['type'] ?? $dec['result'] ?? '');
}
// DISC优先 description.type然后 dominantType
if ($targetType === 'disc') {
$desc = $dec['description']['type'] ?? null;
if (is_string($desc) && $desc !== '') {
return $desc;
}
if (!empty($dec['dominantType'])) {
return (string) $dec['dominantType'];
}
return (string) ($dec['disc'] ?? '');
}
// PDP优先 description.type然后 dominantType
if ($targetType === 'pdp') {
$desc = $dec['description']['type'] ?? null;
if (is_string($desc) && $desc !== '') {
return $desc;
}
if (!empty($dec['dominantType'])) {
return (string) $dec['dominantType'];
}
return (string) ($dec['pdp'] ?? '');
}
// 兜底:尝试常见字段
return (string) ($dec['type'] ?? $dec['result'] ?? '');
}
return '';
}
/**
* 从人脸分析结果中提取对应的 MBTI / DISC / PDP 文本
*/
private function extractFaceSubType(array $tests, string $subType): string
{
$target = strtolower($subType);
foreach ($tests as $t) {
if (strtolower($t['testType'] ?? '') !== 'face') {
continue;
}
$result = $t['result'] ?? '';
if (!is_string($result)) {
continue;
}
$dec = json_decode($result, true);
if (!is_array($dec)) {
continue;
}
if ($target === 'mbti') {
if (!empty($dec['mbti']['type'])) {
return (string) $dec['mbti']['type'];
}
if (!empty($dec['mbtiType'])) {
return (string) $dec['mbtiType'];
}
} elseif ($target === 'disc') {
if (!empty($dec['disc']['primary'])) {
return (string) $dec['disc']['primary'];
}
if (!empty($dec['disc'])) {
return (string) $dec['disc'];
}
} elseif ($target === 'pdp') {
if (!empty($dec['pdp']['primary'])) {
return (string) $dec['pdp']['primary'];
}
if (!empty($dec['pdp'])) {
return (string) $dec['pdp'];
}
}
}
return '';
}
}