feat: 管理端与用户端批量更新(订单/邀请双码/tabBar/审核与分销等)

- 管理端:用户详情、企业看板双小程序码、超管设置与用户列表等
- 后端:订单接口、邀请码企业版+个人版、分析与测试相关调整
- 微信小程序/抖音:自定义 tabBar 居中与拍摄钮上移、相机页与结果页、订单页、支付与统计
- 新增 faceResultDetail 工具与 mp 分析迁移脚本

Made-with: Cursor
This commit is contained in:
Ghost
2026-03-28 16:46:00 +08:00
parent 2bb8b979f1
commit 3317c84b5e
65 changed files with 4442 additions and 1839 deletions

View File

@@ -35,24 +35,31 @@ class AppUser extends BaseController
return error('无权限访问', 403);
}
// 用户数按 openid 去重
// ========== 统计全部基于 wechat_users.enterpriseId而非 test_results.enterpriseId ==========
// openid 去重:每个 openid 只保留 id 最大的一条
try {
$totalUsers = (int) Db::name('wechat_users')->count('openid', true);
$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) {
$totalUsers = (int) Db::name('wechat_users')->count();
$dedupIds = Db::name('wechat_users')->column('id');
$dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
}
$totalUsers = count($dedupIds);
$last30d = time() - 30 * 86400;
// 全部池:去重后的测试用户 & 近 30 天活跃用户(按 userId 去重
// 这里使用逻辑表名 test_results底层会自动加前缀生成 mbti_test_results
// 已测试用户(在 test_results 有记录的 userId 与 dedupIds 取交集
$testedUserIds = Db::name('test_results')->distinct(true)->column('userId');
$testedUsers = count(array_filter($testedUserIds));
$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)
->column('userId');
$activeUsers = count(array_filter($activeUserIds));
$activeUserIds = array_values(array_unique(array_filter(array_map('intval', $activeUserIds))));
$activeUsers = count(array_intersect($activeUserIds, $dedupIds));
$userCards = [
[
@@ -64,73 +71,44 @@ class AppUser extends BaseController
]
];
$individualTotal = 0;
$individualActive = 0;
try {
$individualIds = Db::name('test_results')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', '')->whereOr('enterpriseId', 0);
})
->distinct(true)
->column('userId');
$individualTotal = count(array_filter($individualIds));
$individualActiveIds = Db::name('test_results')
->where('createdAt', '>=', $last30d)
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', '')->whereOr('enterpriseId', 0);
})
->distinct(true)
->column('userId');
$individualActive = count(array_filter($individualActiveIds));
} catch (\Throwable $e) {
$individualTotal = 0;
$individualActive = 0;
}
$cunkbaoId = $this->resolveCunkbaoEnterpriseId();
// 按 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 = $e['id'];
try {
$ids = Db::name('test_results')
->where('enterpriseId', $eid)
->distinct(true)
->column('userId');
$total = count(array_filter($ids));
$eid = (int) $e['id'];
$eidUsers = array_keys(array_filter($userEidMap, function ($v) use ($eid) {
return (int) $v === $eid;
}));
$total = count($eidUsers);
$active = empty($eidUsers) ? 0 : count(array_intersect($activeUserIds, $eidUsers));
$tested = empty($eidUsers) ? 0 : count(array_intersect($testedUserIds, $eidUsers));
$activeIds = Db::name('test_results')
->where('enterpriseId', $eid)
->where('createdAt', '>=', $last30d)
->distinct(true)
->column('userId');
$active = count(array_filter($activeIds));
} catch (\Throwable $ex) {
$total = 0;
$active = 0;
}
if ($cunkbaoId !== null && (int) $eid === $cunkbaoId) {
$total += $individualTotal;
$active += $individualActive;
}
$userCards[] = [
'type' => 'enterprise',
'enterpriseId' => $eid,
'name' => $e['name'] ?? ('企业' . $eid),
'total' => $total,
'active' => $active,
'tested' => $total
'tested' => $tested
];
}
if ($cunkbaoId === null && ($individualTotal > 0 || $individualActive > 0)) {
// 无企业归属的个人用户
$individualUsers = array_keys(array_filter($userEidMap, function ($v) {
return $v === null || $v === '' || (int) $v === 0;
}));
$individualTotal = count($individualUsers);
if ($individualTotal > 0) {
$individualActive = count(array_intersect($activeUserIds, $individualUsers));
$individualTested = count(array_intersect($testedUserIds, $individualUsers));
$userCards[] = [
'type' => 'individual',
'name' => '个人用户(无企业)',
'total' => $individualTotal,
'active' => $individualActive,
'tested' => $individualTotal
'tested' => $individualTested
];
}
@@ -189,7 +167,7 @@ class AppUser extends BaseController
/**
* 测试用户列表分页、关键词、池筛选、MBTI 筛选
* GET /api/v1/superadmin/app-users?page=1&pageSize=20&keyword=&pool=all|individual|enterprise&enterpriseId=&mbti=
* GET /api/v1/superadmin/app-users?page=1&pageSize=20&keyword=&pool=all|individual|enterprise&enterpriseId=&mbti=&includeZeroTests=
*/
public function index()
{
@@ -206,53 +184,12 @@ class AppUser extends BaseController
$enterpriseId = Request::param('enterpriseId', '');
$mbti = trim(Request::param('mbti', ''));
$cunkbaoEnterpriseId = $this->resolveCunkbaoEnterpriseId();
if ($pool === 'individual' && $cunkbaoEnterpriseId) {
$pool = 'enterprise';
$enterpriseId = (string) $cunkbaoEnterpriseId;
}
$where = [];
if ($keyword !== '') {
$where[] = ['nickname|phone|city|province', 'like', '%' . $keyword . '%'];
}
$wechatIds = null;
if ($pool === 'individual' || ($pool === 'enterprise' && $enterpriseId !== '')) {
try {
if ($pool === 'individual') {
$trQuery = Db::name('test_results')->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', '')->whereOr('enterpriseId', 0);
});
$wechatIds = $trQuery->distinct(true)->column('userId');
} else {
$eid = (int) $enterpriseId;
if ($cunkbaoEnterpriseId !== null && $eid === $cunkbaoEnterpriseId) {
$idsEnt = Db::name('test_results')->where('enterpriseId', $eid)->distinct(true)->column('userId');
$idsOrphan = Db::name('test_results')->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', '')->whereOr('enterpriseId', 0);
})->distinct(true)->column('userId');
$wechatIds = array_values(array_unique(array_filter(array_merge($idsEnt, $idsOrphan))));
} else {
$wechatIds = Db::name('test_results')->where('enterpriseId', $eid)->distinct(true)->column('userId');
}
}
$wechatIds = array_values(array_unique(array_filter($wechatIds)));
} catch (\Throwable $e) {
$wechatIds = null;
}
}
if ($mbti !== '') {
$mbtiUserIds = Db::name('test_results')->where('testType', 'mbti')->distinct(true)->column('userId');
$mbtiUserIds = array_values(array_unique(array_filter($mbtiUserIds)));
if ($wechatIds !== null) {
$wechatIds = array_values(array_intersect($wechatIds, $mbtiUserIds));
} else {
$wechatIds = $mbtiUserIds;
}
}
// 按 openid 去重:每个 openid 只保留 id 最大的一条;失败则不去重
// 按 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)) : [];
@@ -268,15 +205,45 @@ class AppUser extends BaseController
if ($where) {
$baseQuery->where($where);
}
if ($wechatIds !== null && !empty($wechatIds)) {
$baseQuery->where('id', 'in', array_intersect($dedupIds, $wechatIds));
} elseif ($wechatIds !== null && empty($wechatIds)) {
return paginate_response([], 0, $page, $pageSize);
// 池筛选:直接基于 wechat_users.enterpriseId
if ($pool === 'individual') {
$baseQuery->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', '')->whereOr('enterpriseId', 0);
});
} elseif ($pool === 'enterprise' && $enterpriseId !== '') {
$baseQuery->where('enterpriseId', (int) $enterpriseId);
}
// 默认不展示「从未有过测试记录」的用户;?includeZeroTests=1 可显示全部(排查用)
$includeZeroTests = Request::param('includeZeroTests', '');
$showUntested = ($includeZeroTests === '1' || $includeZeroTests === 'true' || $includeZeroTests === true);
if (!$showUntested) {
$testedUserIds = Db::name('test_results')->distinct(true)->column('userId');
$testedUserIds = array_values(array_unique(array_filter(array_map('intval', $testedUserIds))));
if (empty($testedUserIds)) {
return paginate_response([], 0, $page, $pageSize);
}
$baseQuery->whereIn('id', $testedUserIds);
}
// MBTI 筛选:保留旧逻辑,从 test_results 取有 mbti 测试的用户
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 {
return paginate_response([], 0, $page, $pageSize);
}
}
$total = $baseQuery->count();
$list = (clone $baseQuery)
->field('id,openid,nickname,avatar,phone,gender,country,province,city,status,lastLoginAt,createdAt')
->field([
'id', 'openid', 'nickname', 'avatar', 'phone', 'gender',
'country', 'province', 'city', 'status', 'lastLoginAt', 'createdAt',
])
->order('createdAt', 'desc')
->page($page, $pageSize)
->select()
@@ -310,38 +277,20 @@ class AppUser extends BaseController
'createdAt' => $row['createdAt'],
];
}
try {
$trWithE = Db::name('test_results')
->where('userId', 'in', $ids)
->where('enterpriseId', '<>', null)
->where('enterpriseId', '<>', '')
->field('userId, enterpriseId')
->select();
$eids = array_unique(array_filter(array_column($trWithE, 'enterpriseId')));
$enterpriseNames = [];
if (!empty($eids)) {
$enterpriseNames = Db::name('enterprises')->where('id', 'in', $eids)->column('name', 'id');
}
foreach ($trWithE as $r) {
if (!isset($userEnterprise[$r['userId']])) {
$userEnterprise[$r['userId']] = $enterpriseNames[$r['enterpriseId']] ?? ('企业' . $r['enterpriseId']);
}
}
} catch (\Throwable $e) {
// test_results 可能无 enterpriseId 列
}
$cunkbaoName = null;
if ($cunkbaoEnterpriseId) {
$cunkbaoName = (string) (Db::name('enterprises')->where('id', $cunkbaoEnterpriseId)->value('name') ?: '存客宝');
// 所属企业直接从 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 = [];
if (!empty($allEids)) {
$enterpriseNames = Db::name('enterprises')->where('id', 'in', $allEids)->column('name', 'id');
}
foreach ($ids as $uid) {
if (!isset($userEnterprise[$uid])) {
$tc = (int) ($testCounts[$uid] ?? 0);
if ($cunkbaoName !== null && $tc > 0) {
$userEnterprise[$uid] = $cunkbaoName;
} else {
$userEnterprise[$uid] = '个人用户(无企业)';
}
$eid = isset($userEids[$uid]) ? (int) $userEids[$uid] : 0;
if ($eid > 0 && isset($enterpriseNames[$eid])) {
$userEnterprise[$uid] = $enterpriseNames[$eid];
} else {
$userEnterprise[$uid] = '个人用户(无企业)';
}
}
@@ -370,6 +319,9 @@ class AppUser extends BaseController
foreach ($list as &$row) {
$id = $row['id'];
$av = $row['avatar'] ?? '';
$row['avatar'] = is_scalar($av) ? trim((string) $av) : '';
$row['avatarUrl'] = $row['avatar'];
$testsForUser = $testTypes[$id] ?? [];
$row['username'] = $row['nickname'] ?? ('用户' . $id);
$row['testCount'] = (int) ($testCounts[$id] ?? 0);
@@ -390,6 +342,7 @@ class AppUser extends BaseController
$row['totalPaidAmount'] = $totalPaidFen;
$row['totalPaidAmountYuan'] = $totalPaidFen > 0 ? round($totalPaidFen / 100, 2) : 0;
}
unset($row);
return paginate_response($list, $total, $page, $pageSize);
}