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

@@ -78,9 +78,28 @@ class AppUser extends BaseController
$baseQuery->where($where);
}
// 默认不展示「从未有过测试记录」的用户(企业后台按本企业 test_results 判定);?includeZeroTests=1 显示全部
$includeZeroTests = Request::param('includeZeroTests', '');
$showUntested = ($includeZeroTests === '1' || $includeZeroTests === 'true' || $includeZeroTests === true);
if (!$showUntested) {
$trQ = Db::name('test_results');
if ($enterpriseId) {
$trQ->where('enterpriseId', (int) $enterpriseId);
}
$testedUserIds = $trQ->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);
}
$total = (int) $baseQuery->count();
$list = (clone $baseQuery)
->field('id,nickname,openid,avatar,phone,gender,country,province,city,status,lastLoginAt,createdAt')
->field([
'id', 'nickname', 'openid', 'avatar', 'phone', 'gender',
'country', 'province', 'city', 'status', 'lastLoginAt', 'createdAt',
])
->order('createdAt', 'desc')
->page($page, $pageSize)
->select()
@@ -155,6 +174,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);
@@ -172,6 +194,7 @@ class AppUser extends BaseController
$row['paidOrders'] = $pay ? (int) ($pay['paidOrders'] ?? 0) : 0;
$row['totalPaidAmount'] = $pay ? (int) ($pay['totalPaidAmount'] ?? 0) : 0;
}
unset($row);
return paginate_response($list, $total, $page, $pageSize);
}

View File

@@ -6,16 +6,16 @@ use app\common\service\WechatService;
use think\facade\Db;
/**
* 管理端 - 小程序邀请二维码(企业参数
* 管理端 - 小程序邀请二维码(企业版 + 个人版
*/
class Invite extends BaseController
{
/**
* 生成专属邀请小程序码scene 带企业 ID扫码进入 pages/enterprise/index 可解析
* 一次生成两枚小程序码:企业测评入口 + 个人版首页入口
* GET /api/v1/admin/invite/qrcode
* 可选:?enterpriseId=1 仅普通管理员指定企业时传;企业管理员用自身 enterpriseId
*
* 返回 data:image/png;base64,... 形式的图片地址
* 返回 enterprise / personal 各含 data:image/png;base64,... ;兼容旧字段 qrcode=企业版图
*/
public function qrcode()
{
@@ -38,26 +38,48 @@ class Invite extends BaseController
return error('无法确定企业,仅企业管理员或指定 enterpriseId 可生成邀请码', 400);
}
// 场景值e_企业ID小程序 onLoad(options.scene) 可解析
$scene = 'e_' . $enterpriseId;
$page = 'pages/enterprise/index';
$sceneEnterprise = 'e_' . $enterpriseId;
$pageEnterprise = 'pages/enterprise/index';
$result = WechatService::getWxacodeUnlimited($scene, $page, 430);
if (isset($result['errcode'])) {
return error('获取小程序码失败:' . ($result['errmsg'] ?? ''), 500);
$resultEnt = WechatService::getWxacodeUnlimited($sceneEnterprise, $pageEnterprise, 430);
if (isset($resultEnt['errcode'])) {
return error('企业版小程序码失败:' . ($resultEnt['errmsg'] ?? ''), 500);
}
$binary = $result['binary'] ?? '';
if ($binary === '') {
return error('小程序码生成失败', 500);
$binEnt = $resultEnt['binary'] ?? '';
if ($binEnt === '') {
return error('企业版小程序码生成失败', 500);
}
$b64Ent = 'data:image/png;base64,' . base64_encode($binEnt);
$base64 = 'data:image/png;base64,' . base64_encode($binary);
// 个人版首页scene 使用短串 pindex 无 eid 则留在个人版流程)
$scenePersonal = 'p';
$pagePersonal = 'pages/index/index';
$resultPer = WechatService::getWxacodeUnlimited($scenePersonal, $pagePersonal, 430);
if (isset($resultPer['errcode'])) {
return error('个人版小程序码失败:' . ($resultPer['errmsg'] ?? ''), 500);
}
$binPer = $resultPer['binary'] ?? '';
if ($binPer === '') {
return error('个人版小程序码生成失败', 500);
}
$b64Per = 'data:image/png;base64,' . base64_encode($binPer);
return success([
'qrcode' => $base64,
'scene' => $scene,
'page' => $page,
'qrcode' => $b64Ent,
'scene' => $sceneEnterprise,
'page' => $pageEnterprise,
'enterprise' => [
'qrcode' => $b64Ent,
'scene' => $sceneEnterprise,
'page' => $pageEnterprise,
'label' => '企业版',
],
'personal' => [
'qrcode' => $b64Per,
'scene' => $scenePersonal,
'page' => $pagePersonal,
'label' => '个人版',
],
]);
}
}

View File

@@ -25,11 +25,24 @@ class Analytics extends BaseController
$userId = null;
$openid = null;
$source = null;
$token = JwtService::getTokenFromRequest($this->request);
if ($token) {
$payload = JwtService::verifyToken($token);
if ($payload && ($payload['source'] ?? '') === 'wechat') {
$userId = (int) ($payload['userId'] ?? $payload['user_id'] ?? 0) ?: null;
if ($payload) {
$source = $payload['source'] ?? null;
if (in_array($source, ['wechat', 'douyin'], true)) {
$userId = (int) ($payload['userId'] ?? $payload['user_id'] ?? 0) ?: null;
}
}
}
$deviceJson = null;
$deviceRaw = $body['device'] ?? null;
if (is_array($deviceRaw) && !empty($deviceRaw)) {
$deviceJson = json_encode($deviceRaw, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
if (strlen($deviceJson) > 2000) {
$deviceJson = mb_substr($deviceJson, 0, 2000);
}
}
@@ -45,6 +58,25 @@ class Analytics extends BaseController
}
$pagePath = isset($ev['page_path']) ? mb_substr(trim((string) $ev['page_path']), 0, 255) : '';
$props = $ev['props'] ?? null;
$platform = isset($ev['platform']) ? mb_substr(trim((string) $ev['platform']), 0, 16) : ($source ?: null);
$sessionId = isset($ev['session_id']) ? mb_substr(trim((string) $ev['session_id']), 0, 64) : null;
if ($props !== null && $props !== []) {
if ($deviceJson) {
$props['_device'] = $deviceRaw;
}
if (isset($ev['network'])) {
$props['_network'] = $ev['network'];
}
if (isset($ev['scene'])) {
$props['_scene'] = $ev['scene'];
}
} else {
$props = [];
if ($deviceJson) $props['_device'] = $deviceRaw;
if (isset($ev['network'])) $props['_network'] = $ev['network'];
if (isset($ev['scene'])) $props['_scene'] = $ev['scene'];
if (empty($props)) $props = null;
}
$propsJson = null;
if ($props !== null && $props !== []) {
$propsJson = json_encode($props, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
@@ -64,6 +96,8 @@ class Analytics extends BaseController
'pagePath' => $pagePath ?: null,
'propsJson' => $propsJson,
'clientTs' => $clientTs ?: null,
'platform' => $platform,
'sessionId' => $sessionId,
'createdAt' => $now,
];
}

View File

@@ -171,12 +171,15 @@ class Analyze extends BaseController
if ($testResultId) {
UserProfileModel::recordTest($userId, 'face', $testResultId, $writeEnterpriseId, $now);
// 仅当 enterpriseId 来自请求体(企业分享链接)时才更新绑定关系
if ($enterpriseFromRequest && $enterpriseId !== null && $enterpriseId > 0) {
Db::name('wechat_users')->where('id', $userId)->update([
'enterpriseId' => $enterpriseId,
'updatedAt' => $now,
]);
// 用户尚无企业归属时,从本次测试上下文补写
if ($writeEnterpriseId !== null && (int) $writeEnterpriseId > 0) {
$curEid = Db::name('wechat_users')->where('id', $userId)->value('enterpriseId');
if ($curEid === null || $curEid === '' || (int) $curEid === 0) {
Db::name('wechat_users')->where('id', $userId)->update([
'enterpriseId' => (int) $writeEnterpriseId,
'updatedAt' => $now,
]);
}
}
// 面相分析由本接口直接写入 test_results也要补触发测试完成佣金
try {
@@ -334,12 +337,15 @@ class Analyze extends BaseController
if ($testResultId > 0) {
UserProfileModel::recordTest($userId, 'resume', $testResultId, $enterpriseId > 0 ? $enterpriseId : null, $now);
// 如果是企业分享链接进入,更新用户绑定关系
if ($enterpriseIdFromRequest && $enterpriseId > 0) {
Db::name('wechat_users')->where('id', $userId)->update([
'enterpriseId' => $enterpriseId,
'updatedAt' => $now,
]);
// 用户尚无企业归属时,从本次测试上下文补写
if ($enterpriseId > 0) {
$curEid = Db::name('wechat_users')->where('id', $userId)->value('enterpriseId');
if ($curEid === null || $curEid === '' || (int) $curEid === 0) {
Db::name('wechat_users')->where('id', $userId)->update([
'enterpriseId' => $enterpriseId,
'updatedAt' => $now,
]);
}
}
}
} catch (\Throwable $e) {

View File

@@ -230,17 +230,27 @@ class Auth extends BaseController
$now = time();
$ip = Request::ip();
$loginEnterpriseId = isset($input['enterpriseId']) && (int) $input['enterpriseId'] > 0
? (int) $input['enterpriseId']
: null;
if ($wechatUser) {
Db::name('wechat_users')->where('id', $wechatUser['id'])->update([
$updateFields = [
'sessionKey' => $sessionKey,
'unionid' => $unionid,
'lastLoginAt' => $now,
'lastLoginIp' => $ip,
'updatedAt' => $now,
]);
];
// 老用户未绑定企业时,从本次登录上下文补写
$existingEid = $wechatUser['enterpriseId'] ?? null;
if (($existingEid === null || $existingEid === '' || (int) $existingEid === 0) && $loginEnterpriseId !== null) {
$updateFields['enterpriseId'] = $loginEnterpriseId;
}
Db::name('wechat_users')->where('id', $wechatUser['id'])->update($updateFields);
$wechatUser = Db::name('wechat_users')->where('id', $wechatUser['id'])->find();
} else {
$id = Db::name('wechat_users')->insertGetId([
$insertData = [
'openid' => $openid,
'unionid' => $unionid,
'sessionKey' => $sessionKey,
@@ -256,7 +266,11 @@ class Auth extends BaseController
'lastLoginIp' => $ip,
'createdAt' => $now,
'updatedAt' => $now,
]);
];
if ($loginEnterpriseId !== null) {
$insertData['enterpriseId'] = $loginEnterpriseId;
}
$id = Db::name('wechat_users')->insertGetId($insertData);
$wechatUser = Db::name('wechat_users')->where('id', $id)->find();
}

View File

@@ -0,0 +1,119 @@
<?php
namespace app\controller\api;
use app\BaseController;
use think\facade\Db;
use think\facade\Request;
/**
* 小程序端当前登录用户的支付订单列表mbti_orders.userId = wechat_users.id
*/
class Order extends BaseController
{
/**
* GET /api/orders?page=1&pageSize=20
*/
public function index()
{
$user = $this->request->user ?? null;
if (!$user) {
return error('未登录', 401);
}
$source = $user['source'] ?? '';
if (!in_array($source, ['wechat', 'douyin'], true)) {
return error('仅支持小程序用户', 403);
}
$userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
if ($userId <= 0) {
return error('用户不存在', 400);
}
$page = (int) Request::param('page', 1);
$pageSize = (int) Request::param('pageSize', 20);
$pageSize = min(max($pageSize, 1), 100);
$query = Db::name('orders')
->where('userId', $userId)
->order('createdAt', 'desc');
$total = (int) (clone $query)->count();
$rows = (clone $query)->page($page, $pageSize)->select()->toArray();
$list = [];
foreach ($rows as $row) {
$list[] = $this->formatOrderRow($row);
}
return paginate_response($list, $total, $page, $pageSize);
}
private function formatOrderRow(array $row): array
{
$amountFen = (int) ($row['amount'] ?? 0);
$productType = (string) ($row['productType'] ?? '');
$title = trim((string) ($row['productTitle'] ?? ''));
if ($title === '') {
$title = $this->productTypeLabel($productType);
}
return [
'id' => (int) ($row['id'] ?? 0),
'orderNo' => (string) ($row['orderNo'] ?? ''),
'productType' => $productType,
'productTitle' => $title,
'amountFen' => $amountFen,
'amountYuan' => number_format($amountFen / 100, 2, '.', ''),
'status' => (string) ($row['status'] ?? ''),
'statusText' => $this->statusLabel((string) ($row['status'] ?? '')),
'payMethod' => (string) ($row['payMethod'] ?? ''),
'payTime' => isset($row['payTime']) ? (int) $row['payTime'] : null,
'payTimeStr' => $this->formatTs($row['payTime'] ?? null),
'createdAt' => isset($row['createdAt']) ? (int) $row['createdAt'] : null,
'createdAtStr' => $this->formatTs($row['createdAt'] ?? null),
];
}
private function formatTs($ts): string
{
if ($ts === null || $ts === '') {
return '';
}
$t = (int) $ts;
if ($t <= 0) {
return '';
}
return date('Y-m-d H:i', $t);
}
private function statusLabel(string $s): string
{
$map = [
'pending' => '待支付',
'paid' => '已支付',
'completed' => '已完成',
'cancelled' => '已取消',
];
return $map[$s] ?? ($s !== '' ? $s : '未知');
}
private function productTypeLabel(string $t): string
{
$map = [
'face' => '人脸分析',
'mbti' => 'MBTI测试',
'disc' => 'DISC测试',
'pdp' => 'PDP测试',
'report' => '完整报告',
'resume' => '简历分析',
'recharge' => '企业充值',
'vip' => '会员服务',
'deep_personal' => '深度服务(个人)',
'deep_team' => '深度服务(团队)',
'single_test' => '单次测试',
'test_count' => '测试次数包',
'team_analysis' => '团队分析',
];
return $map[$t] ?? ($t !== '' ? $t : '订单');
}
}

View File

@@ -495,12 +495,15 @@ class Test extends BaseController
if ($id > 0) {
UserProfileModel::recordTest($userId, $testType, $id, $writeEnterpriseId, $now);
// 仅当 enterpriseId 来自请求体(企业分享链接)时才更新绑定关系
if ($enterpriseFromRequest && $enterpriseId !== null && $enterpriseId > 0) {
Db::name('wechat_users')->where('id', $userId)->update([
'enterpriseId' => $enterpriseId,
'updatedAt' => $now,
]);
// 用户尚无企业归属时,从本次测试上下文补写(请求体带 eid 或已绑定企业回落值)
if ($writeEnterpriseId !== null && (int) $writeEnterpriseId > 0) {
$curEid = Db::name('wechat_users')->where('id', $userId)->value('enterpriseId');
if ($curEid === null || $curEid === '' || (int) $curEid === 0) {
Db::name('wechat_users')->where('id', $userId)->update([
'enterpriseId' => (int) $writeEnterpriseId,
'updatedAt' => $now,
]);
}
}
// 测试完成佣金结算(无需付款,异步不影响主流程)
try {

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);
}

View File

@@ -11,6 +11,81 @@ use think\facade\Request;
*/
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
*
@@ -92,9 +167,7 @@ class DataMigration extends BaseController
if ($syncWechatUsers && !empty($userIdsFromOrders)) {
$wechatPatchCount = (int) Db::name('wechat_users')
->whereIn('id', $userIdsFromOrders)
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
})
->whereRaw($this->sqlWechatUserUnassignedEnterprise())
->count();
}
@@ -169,11 +242,10 @@ class DataMigration extends BaseController
if ($syncWechatUsers && !empty($userIdsFromOrders)) {
Db::name('wechat_users')
->whereIn('id', $userIdsFromOrders)
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
})
->whereRaw($this->sqlWechatUserUnassignedEnterprise())
->update([
'enterpriseId' => $targetEnterpriseId,
'updatedAt' => $now,
]);
}
@@ -277,10 +349,13 @@ class DataMigration extends BaseController
}
/**
* 将全平台「无 enterpriseId」的 test_results 与 wechat_users 归属到存客宝(或指定企业)
* 存客宝归并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、clonePersonalProfile (默认 true)
* Body: targetEnterpriseId (可选)、dryRun (默认 true)、confirm
*/
public function attachOrphansToCunkbao()
{
@@ -296,7 +371,6 @@ class DataMigration extends BaseController
$dryRun = array_key_exists('dryRun', $body) ? (bool) $body['dryRun'] : true;
$confirm = !empty($body['confirm']);
$clonePersonalProfile = array_key_exists('clonePersonalProfile', $body) ? (bool) $body['clonePersonalProfile'] : true;
$targetEnterpriseId = (int) ($body['targetEnterpriseId'] ?? 0);
if ($targetEnterpriseId <= 0) {
@@ -312,71 +386,84 @@ class DataMigration extends BaseController
return error('目标企业不存在', 404);
}
$testAffected = (int) Db::name('test_results')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0)->whereOr('enterpriseId', '');
})
->count();
$wechatAffected = (int) Db::name('wechat_users')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
})
->count();
$userIdsFromTests = Db::name('test_results')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0)->whereOr('enterpriseId', '');
})
->distinct(true)
->column('userId');
$userIdsFromWechat = Db::name('wechat_users')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
})
$orphanUserIds = Db::name('wechat_users')
->whereRaw($this->sqlWechatUserUnassignedEnterprise())
->column('id');
$distinctUserIds = array_values(array_unique(array_filter(array_merge($userIdsFromTests, $userIdsFromWechat))));
$orphanUserIds = array_values(array_unique(array_filter(array_map('intval', $orphanUserIds))));
$wechatAffected = count($orphanUserIds);
$boundNow = Db::name('wechat_users')
->where('enterpriseId', $targetEnterpriseId)
->column('id');
$boundNow = array_values(array_unique(array_filter(array_map('intval', $boundNow))));
$usersInScope = array_values(array_unique(array_merge($boundNow, $orphanUserIds)));
$testResultsAffected = empty($usersInScope) ? 0 : (int) Db::name('test_results')
->whereIn('userId', $usersInScope)
->whereRaw($this->sqlTestResultUnassignedEnterprise())
->count();
$userProfilePersonalRows = empty($usersInScope) ? 0 : (int) Db::name('user_profile')
->where('userType', 'personal')
->whereRaw('(`enterpriseId` IS NULL OR `enterpriseId` = 0)')
->whereIn('userId', $usersInScope)
->count();
$preview = [
'targetEnterpriseId' => $targetEnterpriseId,
'enterpriseName' => $ent['name'] ?? '',
'testResultsRows' => $testAffected,
'wechatUsersRows' => $wechatAffected,
'distinctUserIds' => $distinctUserIds,
'dryRun' => $dryRun,
'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'] = $dryRun
? '当前为预览。写入请传 dryRun=false 且 confirm=true。'
: '未写入:请同时传 dryRun=false 与 confirm=true。';
$preview['hint'] = $nothingToDo
? '当前无需归并:归属用户、测试记录与画像均无待处理项。'
: ($dryRun
? '当前为预览。写入请传 dryRun=false 且 confirm=true。'
: '未写入:请同时传 dryRun=false 与 confirm=true。');
return success($preview);
}
if ($nothingToDo) {
$preview['hint'] = '无需写入。';
return success($preview, '无需归并');
}
$now = time();
Db::startTrans();
try {
Db::name('test_results')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0)->whereOr('enterpriseId', '');
})
->update([
'enterpriseId' => $targetEnterpriseId,
'testScope' => 'enterprise',
'updatedAt' => $now,
]);
if (!empty($orphanUserIds)) {
Db::name('wechat_users')
->whereIn('id', $orphanUserIds)
->whereRaw($this->sqlWechatUserUnassignedEnterprise())
->update([
'enterpriseId' => $targetEnterpriseId,
'updatedAt' => $now,
]);
}
Db::name('wechat_users')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
})
->update([
'enterpriseId' => $targetEnterpriseId,
]);
$boundAfter = Db::name('wechat_users')
->where('enterpriseId', $targetEnterpriseId)
->column('id');
$boundAfter = array_values(array_unique(array_filter(array_map('intval', $boundAfter))));
if ($clonePersonalProfile && !empty($distinctUserIds)) {
foreach ($distinctUserIds as $uid) {
$this->ensureEnterpriseProfileFromPersonal((int) $uid, $targetEnterpriseId, $now);
if (!empty($boundAfter)) {
Db::name('test_results')
->whereIn('userId', $boundAfter)
->whereRaw($this->sqlTestResultUnassignedEnterprise())
->update([
'enterpriseId' => $targetEnterpriseId,
'testScope' => 'enterprise',
'updatedAt' => $now,
]);
foreach ($boundAfter as $uid) {
$this->mergePersonalUserProfileIntoEnterprise((int) $uid, $targetEnterpriseId, $now);
}
}
@@ -387,7 +474,7 @@ class DataMigration extends BaseController
}
$preview['executed'] = true;
$preview['hint'] = '已写入。无企业归属的测试与用户已归属到目标企业。';
$preview['hint'] = '已写入:小程序用户归属、无企业测试记录、画像 personal 行合并已完成。';
return success($preview, '归并完成');
}
}

View File

@@ -113,6 +113,13 @@ class Enterprise extends BaseController
return error('企业不存在', 404);
}
$wechatPage = max(1, (int) Request::param('wechatPage', 1));
$wechatPageSize = min(100, max(1, (int) Request::param('wechatPageSize', 10)));
$testPage = max(1, (int) Request::param('testPage', 1));
$testPageSize = min(100, max(1, (int) Request::param('testPageSize', 10)));
$orderPage = max(1, (int) Request::param('orderPage', 1));
$orderPageSize = min(100, max(1, (int) Request::param('orderPageSize', 10)));
$data = $enterprise->toArray();
// 获取企业下的所有用户ID只统计未删除的用户
@@ -152,13 +159,14 @@ class Enterprise extends BaseController
->alias('tr')
->leftJoin('users u', 'tr.userId = u.id')
->where('tr.userId', 'in', $userIds)
->field('tr.id,tr.testType,tr.createdAt,u.username')
->field('tr.id,tr.testType,tr.createdAt,tr.userId,tr.resultData,u.username')
->order('tr.createdAt', 'desc')
->limit(50) // 限制返回数量
->select()
->toArray();
}
$data['testResults'] = $testResults;
$this->attachResultSummaries($data['testResults']);
// 统计测试用量
if (!empty($userIds)) {
@@ -177,39 +185,46 @@ class Enterprise extends BaseController
} catch (\Throwable $e) {
$wechatIds = [];
}
$data['wechatUserCount'] = count($wechatIds);
$data['wechatUsers'] = [];
if (!empty($wechatIds)) {
try {
$data['wechatUsers'] = Db::name('wechat_users')
->where('id', 'in', $wechatIds)
->field('id,openid,nickname,phone,avatar,status,lastLoginAt,createdAt')
->order('createdAt', 'desc')
->limit(120)
->select()
->toArray();
} catch (\Throwable $e) {
$data['wechatUsers'] = [];
}
$wechatUserTotalCount = 0;
try {
$wechatUserTotalCount = (int) Db::name('wechat_users')->where('enterpriseId', $id)->count();
} catch (\Throwable $e) {
$wechatUserTotalCount = 0;
}
$data['wechatUserCount'] = $wechatUserTotalCount;
$data['wechatUsersTotal'] = $wechatUserTotalCount;
$data['wechatUsers'] = [];
try {
$data['wechatUsers'] = Db::name('wechat_users')
->where('enterpriseId', $id)
->field('id,openid,nickname,phone,avatar,status,lastLoginAt,createdAt')
->order('createdAt', 'desc')
->page($wechatPage, $wechatPageSize)
->select()
->toArray();
} catch (\Throwable $e) {
$data['wechatUsers'] = [];
}
// 该企业下、带 enterpriseId 的小程序测试记录
$data['miniprogramTestResults'] = [];
$data['miniprogramTestResults'] = [];
$data['miniprogramTestResultsTotal'] = 0;
try {
$data['miniprogramTestResultsTotal'] = (int) Db::name('test_results')->where('enterpriseId', $id)->count();
$data['miniprogramTestResults'] = Db::name('test_results')
->alias('tr')
->leftJoin('wechat_users w', 'tr.userId = w.id')
->where('tr.enterpriseId', $id)
->field('tr.id,tr.testType,tr.createdAt,tr.userId,w.nickname as wechatNickname')
->field('tr.id,tr.testType,tr.createdAt,tr.userId,tr.resultData,w.nickname as wechatNickname')
->order('tr.createdAt', 'desc')
->limit(60)
->page($testPage, $testPageSize)
->select()
->toArray();
$this->attachResultSummaries($data['miniprogramTestResults']);
} catch (\Throwable $e) {
$data['miniprogramTestResults'] = [];
}
// 订单与消耗(金额分)
// 订单与消耗(金额分)
$paidStatuses = ['paid', 'completed'];
try {
$data['orderStats'] = [
@@ -217,10 +232,11 @@ class Enterprise extends BaseController
'paidCount' => (int) Db::name('orders')->where('enterpriseId', $id)->whereIn('status', $paidStatuses)->count(),
'paidAmountFen' => (int) (Db::name('orders')->where('enterpriseId', $id)->whereIn('status', $paidStatuses)->sum('amount') ?? 0),
];
$data['recentOrdersTotal'] = (int) Db::name('orders')->where('enterpriseId', $id)->count();
$data['recentOrders'] = Db::name('orders')
->where('enterpriseId', $id)
->order('createdAt', 'desc')
->limit(25)
->page($orderPage, $orderPageSize)
->field('id,orderNo,status,amount,productType,userId,createdAt')
->select()
->toArray();
@@ -230,7 +246,8 @@ class Enterprise extends BaseController
'paidCount' => 0,
'paidAmountFen' => 0,
];
$data['recentOrders'] = [];
$data['recentOrders'] = [];
$data['recentOrdersTotal'] = 0;
}
// 埋点:近 30 天,归属该企业的小程序用户
@@ -547,5 +564,74 @@ class Enterprise extends BaseController
return success($enterprise, '操作成功');
}
/**
* 为列表行附加 resultSummary并移除原始 resultData减小响应体积
*/
private function attachResultSummaries(array &$rows): void
{
foreach ($rows as &$r) {
$type = (string) ($r['testType'] ?? '');
$r['resultSummary'] = $this->summarizeTestResultForAdmin($type, $r['resultData'] ?? null);
unset($r['resultData']);
}
unset($r);
}
/**
* 从 test_results.resultData 解析超管可读短摘要
*/
private function summarizeTestResultForAdmin(string $testType, $resultData): string
{
if ($resultData === null || $resultData === '') {
return '—';
}
$decoded = is_string($resultData) ? json_decode($resultData, true) : $resultData;
if (!is_array($decoded)) {
return '—';
}
$testType = strtolower($testType);
switch ($testType) {
case 'mbti':
if (!empty($decoded['mbtiType'])) {
return (string) $decoded['mbtiType'];
}
if (!empty($decoded['mbti']['type'])) {
return (string) $decoded['mbti']['type'];
}
return '—';
case 'disc':
$d = trim((string) ($decoded['dominantType'] ?? ''));
$s = trim((string) ($decoded['secondaryType'] ?? ''));
$line = $d . ($d !== '' && $s !== '' ? ' + ' : '') . $s;
return $line !== '' ? $line : '—';
case 'pdp':
$d = trim((string) ($decoded['dominantType'] ?? ''));
return $d !== '' ? $d : '—';
case 'face':
case 'ai':
$parts = [];
if (!empty($decoded['mbti']['type'])) {
$parts[] = 'MBTI ' . $decoded['mbti']['type'];
}
if (!empty($decoded['pdp']['primary'])) {
$parts[] = 'PDP ' . $decoded['pdp']['primary'];
}
if (!empty($decoded['disc']['primary'])) {
$parts[] = 'DISC ' . $decoded['disc']['primary'];
}
if ($parts !== []) {
return implode(' · ', $parts);
}
$sum = $decoded['personalitySummary'] ?? $decoded['overview'] ?? '';
$sum = is_string($sum) ? trim($sum) : '';
if ($sum !== '') {
return mb_strlen($sum) > 48 ? mb_substr($sum, 0, 48) . '…' : $sum;
}
return '面相/智能分析';
default:
return '—';
}
}
}