feat: 企业权限上限与迁移脚本;管理端/超管概览与财务;双端小程序 tab/订单/历史/测评选择等同步

Made-with: Cursor
This commit is contained in:
Ghost
2026-03-30 17:12:59 +08:00
parent f589b5d50c
commit 3af7bb35dd
49 changed files with 2570 additions and 920 deletions

View File

@@ -45,46 +45,77 @@ class AppUser extends BaseController
$enterpriseId = $adminRow['enterpriseId'] ?? null;
}
// 若有企业ID先从 user_profile 中取出属于本企业的 userId 列表(以画像为主表)
$profileUserIds = [];
$baseQuery = null;
// 企业场景:禁止 column 全量 userId + 超大 whereIn改为「画像池子 + openid 去重」子查询 JOIN全在库内完成
if ($enterpriseId) {
$profileUserIds = Db::name('user_profile')
->where('enterpriseId', $enterpriseId)
->column('userId');
$profileUserIds = $profileUserIds ? array_values(array_unique(array_filter($profileUserIds))) : [];
if (empty($profileUserIds)) {
$eid = (int) $enterpriseId;
// 子查询会在 SQL 中出现两次,条件用字面量避免占位符绑定重复/错位的风险
$poolSql = Db::name('user_profile')
->whereRaw('enterpriseId = ' . $eid)
->group('userId')
->field('userId')
->buildSql(true);
try {
$dedupSql = Db::name('wechat_users')
->alias('w2')
->join([$poolSql => 'p2'], 'w2.id = p2.userId')
->field('w2.openid, MAX(w2.id) AS mid')
->group('w2.openid')
->buildSql(true);
} catch (\Throwable $e) {
return paginate_response([], 0, $page, $pageSize);
}
$baseQuery = Db::name('wechat_users')->alias('w')
->join([$poolSql => 'p'], 'w.id = p.userId')
->join([$dedupSql => 'd'], 'w.id = d.mid');
if ($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);
});
}
} else {
// 无企业归属(极少):沿用全表 openid 去重 + IN 列表
try {
$dedupIds = Db::name('wechat_users')->field('openid, MAX(id) as mid')->group('openid')->column('mid');
$dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
} catch (\Throwable $e) {
$dedupIds = Db::name('wechat_users')->column('id');
$dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
}
if (empty($dedupIds)) {
return paginate_response([], 0, $page, $pageSize);
}
$baseQuery = Db::name('wechat_users')->where('id', 'in', $dedupIds);
if ($where) {
$baseQuery->where($where);
}
}
// 按 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)) : [];
} catch (\Throwable $e) {
$dedupIds = Db::name('wechat_users')->column('id');
$dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
if ($enterpriseId) {
$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')
->page($page, $pageSize)
->select()
->toArray();
} else {
$total = (int) (clone $baseQuery)->count();
$list = (clone $baseQuery)
->field('id,nickname,openid,avatar,phone,gender,country,province,city,status,lastLoginAt,createdAt')
->order('createdAt', 'desc')
->page($page, $pageSize)
->select()
->toArray();
}
if (empty($dedupIds)) {
return paginate_response([], 0, $page, $pageSize);
}
$baseQuery = Db::name('wechat_users')->where('id', 'in', $dedupIds);
// 若从画像表中筛出了当前企业的用户池,则仅保留这些 userId
if (!empty($profileUserIds)) {
$baseQuery->whereIn('id', $profileUserIds);
}
if ($where) {
$baseQuery->where($where);
}
$total = (int) $baseQuery->count();
$list = (clone $baseQuery)
->field('id,nickname,openid,avatar,phone,gender,country,province,city,status,lastLoginAt,createdAt')
->order('createdAt', 'desc')
->page($page, $pageSize)
->select()
->toArray();
// 为每条用户附加测试统计test_results.userId 对应 wechat_users.id
$ids = array_column($list, 'id');
@@ -103,20 +134,49 @@ class AppUser extends BaseController
if ($enterpriseId) {
$trBase->where('enterpriseId', $enterpriseId);
}
$counts = (clone $trBase)
// 次数与最近测试时间一次 GROUP 查完,减少往返
$trAggRows = (clone $trBase)
->field('userId, COUNT(*) AS cnt, MAX(createdAt) AS lastAt')
->group('userId')
->column('COUNT(*) as cnt', 'userId');
$testCounts = $counts ?: [];
->select()
->toArray();
foreach ($trAggRows as $r) {
$uid = (int) ($r['userId'] ?? 0);
if ($uid > 0) {
$testCounts[$uid] = (int) ($r['cnt'] ?? 0);
$lastTestAt[$uid] = $r['lastAt'];
}
}
// 每人每种 testType 仅取最新一条(含 MAX(id) 消解同一时间戳多行),禁止 select 全量记录
try {
$aggSub = Db::name('test_results')->where('userId', 'in', $ids);
if ($enterpriseId) {
$aggSub->where('enterpriseId', $enterpriseId);
}
$aggSql = $aggSub
->field('userId, testType, MAX(createdAt) as mc, MAX(id) as mid')
->group('userId, testType')
->buildSql(true);
$lastDetailQuery = Db::name('test_results')->alias('t')
->join([$aggSql => 'agg'], 't.userId = agg.userId AND t.testType = agg.testType AND t.id = agg.mid')
->field('t.id, t.userId, t.testType, t.resultData, t.createdAt, t.enterpriseId as testEnterpriseId');
if ($enterpriseId) {
$lastDetailQuery->where('t.enterpriseId', $enterpriseId);
}
$lastRows = $lastDetailQuery->select()->toArray();
} catch (\Throwable $e) {
$lastRows = (clone $trBase)
->field('id, userId, testType, resultData, createdAt, enterpriseId as testEnterpriseId')
->order('createdAt', 'desc')
->limit(2000)
->select()
->toArray();
}
$lastRows = (clone $trBase)
->field('id, userId, testType, resultData, createdAt, enterpriseId as testEnterpriseId')
->order('createdAt', 'desc')
->select();
foreach ($lastRows as $row) {
$uid = $row['userId'];
if (!isset($lastTestAt[$uid])) {
$lastTestAt[$uid] = $row['createdAt'];
}
if (!isset($testTypes[$uid])) {
$testTypes[$uid] = [];
}
@@ -127,6 +187,12 @@ class AppUser extends BaseController
'testScope' => !empty($row['testEnterpriseId']) ? 'enterprise' : 'personal',
];
}
foreach ($testTypes as $uid => &$tlist) {
usort($tlist, static function ($a, $b) {
return (int) ($b['createdAt'] ?? 0) <=> (int) ($a['createdAt'] ?? 0);
});
}
unset($tlist);
// 付款统计user_profile按当前企业过滤
try {
$profilesQuery = Db::name('user_profile')

View File

@@ -32,9 +32,20 @@ class Dashboard extends BaseController
try {
// admin / enterprise_admin 均只统计本企业数据
$enterpriseId = $user['enterpriseId'] ?? null;
if (is_array($enterpriseId)) {
$enterpriseId = null;
}
$enterpriseId = $enterpriseId !== null && $enterpriseId !== '' ? (int) $enterpriseId : null;
if ($enterpriseId !== null && $enterpriseId <= 0) {
$enterpriseId = null;
}
if (!$enterpriseId) {
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
$enterpriseId = $adminRow['enterpriseId'] ?? null;
$eid = $adminRow['enterpriseId'] ?? null;
$enterpriseId = ($eid !== null && $eid !== '') ? (int) $eid : null;
if ($enterpriseId !== null && $enterpriseId <= 0) {
$enterpriseId = null;
}
}
// 企业用户 ID 集合(用于后续统计个人版测试)

View File

@@ -0,0 +1,88 @@
<?php
namespace app\controller\admin;
use app\BaseController;
use app\model\Enterprise as EnterpriseModel;
use think\facade\Db;
use think\facade\Request;
/**
* 企业管理员在超管授权上限内配置各功能开关PUT 不可突破 permissionsCeiling
*/
class EnterprisePermissions extends BaseController
{
public function index()
{
$user = $this->request->user ?? null;
if (!$user || ($user['role'] ?? '') !== 'enterprise_admin') {
return error('仅企业管理员可配置', 403);
}
$eid = $this->resolveEnterpriseId($user);
if (!$eid || $eid <= 0) {
return error('未绑定企业', 403);
}
$enterprise = EnterpriseModel::find($eid);
if (!$enterprise) {
return error('企业不存在', 404);
}
$ceiling = EnterpriseModel::normalizedPermissionsCeiling($enterprise);
$effective = EnterpriseModel::normalizePermissionsValue($enterprise->permissions ?? null);
return success([
'permissions' => $effective,
'permissionsCeiling' => $ceiling,
]);
}
public function update()
{
$user = $this->request->user ?? null;
if (!$user || ($user['role'] ?? '') !== 'enterprise_admin') {
return error('仅企业管理员可配置', 403);
}
$eid = $this->resolveEnterpriseId($user);
if (!$eid || $eid <= 0) {
return error('未绑定企业', 403);
}
$enterprise = EnterpriseModel::find($eid);
if (!$enterprise) {
return error('企业不存在', 404);
}
$body = Request::put();
if (!is_array($body)) {
$body = [];
}
$incoming = $body['permissions'] ?? null;
if (!is_array($incoming)) {
return error('请提交 permissions 对象', 400);
}
$ceiling = EnterpriseModel::normalizedPermissionsCeiling($enterprise);
$effective = EnterpriseModel::clampPermissionsToCeiling($ceiling, $incoming);
$enterprise->permissions = $effective;
$enterprise->save();
return success([
'permissions' => $effective,
'permissionsCeiling' => $ceiling,
], '已保存');
}
private function resolveEnterpriseId(array $user): ?int
{
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
$eid = $adminRow['enterpriseId'] ?? null;
if ($eid === null || $eid === '') {
return null;
}
return (int) $eid;
}
}

View File

@@ -19,14 +19,23 @@ class Invite extends BaseController
public function qrcode()
{
$admin = $this->request->user ?? null;
if (!$admin || !in_array($admin['role'] ?? '', ['admin', 'enterprise_admin'])) {
$role = $admin['role'] ?? '';
if (!$admin || !in_array($role, ['admin', 'enterprise_admin', 'superadmin'], true)) {
return error('无权限访问', 403);
}
$enterpriseId = null;
if (($admin['role'] ?? '') === 'enterprise_admin') {
if ($role === 'enterprise_admin') {
$row = Db::name('users')->where('id', (int) ($admin['userId'] ?? 0))->find();
$enterpriseId = isset($row['enterpriseId']) ? (int) $row['enterpriseId'] : null;
} elseif ($role === 'superadmin') {
$enterpriseId = (int) $this->request->param('enterpriseId', 0);
if ($enterpriseId <= 0) {
$enterpriseId = self::resolveDefaultEnterpriseIdForPlatform();
}
if ($enterpriseId <= 0) {
return error('请传 enterpriseId或在系统设置中配置默认企业', 400);
}
} else {
$enterpriseId = (int) $this->request->param('enterpriseId', 0);
if ($enterpriseId <= 0) {
@@ -80,5 +89,46 @@ class Invite extends BaseController
],
]);
}
/**
* 超管生成企业版太阳码时:请求未带 enterpriseId 则按系统 defaultEnterpriseId → 首家运营企业
*/
private static function resolveDefaultEnterpriseIdForPlatform(): int
{
try {
$systemRow = Db::name('system_config')->where('key', 'system')->where('enterprise_id', 0)->find();
if ($systemRow && !empty($systemRow['value'])) {
$sysVal = is_string($systemRow['value']) ? json_decode($systemRow['value'], true) : $systemRow['value'];
if (is_array($sysVal) && !empty($sysVal['defaultEnterpriseId'])) {
$de = (int) $sysVal['defaultEnterpriseId'];
if ($de > 0) {
return $de;
}
}
}
} catch (\Throwable $e) {
// ignore
}
try {
$first = Db::name('enterprises')
->whereNull('deletedAt')
->where('status', 'operating')
->order('id', 'asc')
->value('id');
if ($first) {
return (int) $first;
}
} catch (\Throwable $e) {
// ignore
}
try {
$any = Db::name('enterprises')->whereNull('deletedAt')->order('id', 'asc')->value('id');
return $any ? (int) $any : 0;
} catch (\Throwable $e) {
return 0;
}
}
}

View File

@@ -6,6 +6,38 @@ namespace app\controller\admin\concern;
*/
trait ExtractsTestResults
{
/**
* 结果字段可能为数组/对象,禁止直接 (string) 强转导致 Array to string conversion
*/
private function coerceResultLabel($value): string
{
if ($value === null || $value === '') {
return '';
}
if (is_string($value)) {
return trim($value);
}
if (is_numeric($value)) {
return (string) $value;
}
if (is_array($value)) {
if (isset($value['type']) && is_string($value['type'])) {
return trim($value['type']);
}
if (isset($value['primary']) && is_string($value['primary'])) {
return trim($value['primary']);
}
foreach (['name', 'label', 'code'] as $k) {
if (isset($value[$k]) && is_string($value[$k]) && $value[$k] !== '') {
return trim($value[$k]);
}
}
return '';
}
return '';
}
private function extractResultType(array $tests, string $type): string
{
$targetType = strtolower($type);
@@ -27,7 +59,13 @@ trait ExtractsTestResults
}
if ($targetType === 'mbti') {
return (string) ($dec['mbtiType'] ?? $dec['type'] ?? $dec['result'] ?? '');
foreach (['mbtiType', 'type', 'result'] as $k) {
$s = $this->coerceResultLabel($dec[$k] ?? null);
if ($s !== '') {
return $s;
}
}
return '';
}
if ($targetType === 'disc') {
@@ -35,10 +73,11 @@ trait ExtractsTestResults
if (is_string($desc) && $desc !== '') {
return $desc;
}
if (!empty($dec['dominantType'])) {
return (string) $dec['dominantType'];
$dom = $this->coerceResultLabel($dec['dominantType'] ?? null);
if ($dom !== '') {
return $dom;
}
return (string) ($dec['disc'] ?? '');
return $this->coerceResultLabel($dec['disc'] ?? null);
}
if ($targetType === 'pdp') {
@@ -46,13 +85,18 @@ trait ExtractsTestResults
if (is_string($desc) && $desc !== '') {
return $desc;
}
if (!empty($dec['dominantType'])) {
return (string) $dec['dominantType'];
$dom = $this->coerceResultLabel($dec['dominantType'] ?? null);
if ($dom !== '') {
return $dom;
}
return (string) ($dec['pdp'] ?? '');
return $this->coerceResultLabel($dec['pdp'] ?? null);
}
return (string) ($dec['type'] ?? $dec['result'] ?? '');
$fallback = $this->coerceResultLabel($dec['type'] ?? null);
if ($fallback !== '') {
return $fallback;
}
return $this->coerceResultLabel($dec['result'] ?? null);
}
return '';
}
@@ -82,17 +126,25 @@ trait ExtractsTestResults
}
} elseif ($target === 'disc') {
if (!empty($dec['disc']['primary'])) {
return (string) $dec['disc']['primary'];
return $this->coerceResultLabel($dec['disc']['primary']);
}
if (!empty($dec['disc'])) {
return (string) $dec['disc'];
$d = $dec['disc'] ?? null;
if (is_array($d)) {
$s = $this->coerceResultLabel($d);
if ($s !== '') {
return $s;
}
}
} elseif ($target === 'pdp') {
if (!empty($dec['pdp']['primary'])) {
return (string) $dec['pdp']['primary'];
return $this->coerceResultLabel($dec['pdp']['primary']);
}
if (!empty($dec['pdp'])) {
return (string) $dec['pdp'];
$p = $dec['pdp'] ?? null;
if (is_array($p)) {
$s = $this->coerceResultLabel($p);
if ($s !== '') {
return $s;
}
}
}
}

View File

@@ -2,6 +2,7 @@
namespace app\controller\api;
use app\BaseController;
use app\model\Enterprise as EnterpriseModel;
use app\model\PricingConfig as PricingConfigModel;
use app\model\AiProvider as AiProviderModel;
use app\common\service\JwtService;
@@ -39,16 +40,22 @@ class AppConfig extends BaseController
}
}
}
if ($scope !== 'personal' && $user && ($user['source'] ?? '') === 'wechat') {
// 绑定企业 ID任意 scope 都要能读到(个人首页也是 scope=personal否则 enterprisePermissions 永远为 null
$userBoundEnterpriseId = null;
if ($user && ($user['source'] ?? '') === 'wechat') {
$userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
if ($userId > 0) {
$row = Db::name('wechat_users')->where('id', $userId)->field('enterpriseId')->find();
if (!empty($row['enterpriseId'])) {
$enterpriseId = (int) $row['enterpriseId'];
$pricingType = 'enterprise';
$userBoundEnterpriseId = (int) $row['enterpriseId'];
}
}
}
// 定价/文案用的 enterpriseId仅在非个人 scope 时走企业定价(与个人页 scope=personal 仍可返回企业权限)
if ($scope !== 'personal' && $userBoundEnterpriseId !== null && $userBoundEnterpriseId > 0) {
$enterpriseId = $userBoundEnterpriseId;
$pricingType = 'enterprise';
}
// 系统配置:审核模式、默认企业(无带参入口时小程序回落)
$maintenanceMode = false;
@@ -161,6 +168,20 @@ class AppConfig extends BaseController
// 与 maintenanceMode 同源:小程序旧逻辑读 reviewMode
$reviewMode = $maintenanceMode;
// 企业功能权限:与定价 scope 解耦;库中 permissions 为空/异常时仍返回默认值,避免前端判空异常
$eidForPermissions = ($enterpriseId > 0) ? $enterpriseId : (($userBoundEnterpriseId ?? 0) > 0 ? $userBoundEnterpriseId : null);
$enterprisePermissions = null;
if ($eidForPermissions > 0) {
try {
$ent = Db::name('enterprises')->where('id', $eidForPermissions)->field('permissions')->find();
if (is_array($ent)) {
$enterprisePermissions = EnterpriseModel::normalizePermissionsValue($ent['permissions'] ?? null);
}
} catch (\Throwable $e) {
$enterprisePermissions = null;
}
}
return success([
'pricingType' => $pricingType,
'pricing' => $pricing,
@@ -174,6 +195,7 @@ class AppConfig extends BaseController
'maintenanceMode' => $maintenanceMode,
'reviewMode' => $reviewMode,
'defaultEnterpriseId' => $defaultEnterpriseId,
'enterprisePermissions' => $enterprisePermissions,
]);
}

View File

@@ -2,6 +2,7 @@
namespace app\controller\api;
use app\BaseController;
use app\model\Enterprise as EnterpriseModel;
use app\model\PricingConfig as PricingConfigModel;
use app\model\UserProfile as UserProfileModel;
use think\facade\Db;
@@ -28,24 +29,45 @@ class Test extends BaseController
return error('未登录', 401);
}
$type = Request::param('type', 'all'); // all|mbti|disc|pdp|face
$type = Request::param('type', 'all'); // all|mbti|disc|pdp|face|ai|resume
$scope = Request::param('scope', 'all'); // all|personal|enterprise
$page = max(1, (int) Request::param('page', 1));
$pageSize = (int) Request::param('pageSize', 0);
if ($pageSize <= 0) {
$pageSize = 500;
$pageSize = 10;
}
$pageSize = min(500, max(1, $pageSize));
$pageSize = min(100, max(1, $pageSize));
$allowedTestTypes = $this->wechatAllowedTestTypes($userId);
// 列表只展示摘要:禁止 tr.* 拉全表 resultData单条可达数 MB×500=卡死);仅截取 JSON 前若干字符用于解析标题
$listJsonSliceLen = 32768;
$base = Db::name('test_results')
->alias('tr')
->leftJoin('wechat_users wu', 'tr.userId = wu.id')
->leftJoin('enterprises e_tr', 'tr.enterpriseId = e_tr.id')
->leftJoin('enterprises e_wu', 'wu.enterpriseId = e_wu.id')
->where('tr.userId', $userId)
->field('tr.*, e_tr.name as enterpriseName, wu.enterpriseId as bindEnterpriseId, e_wu.name as bindEnterpriseName')
->field(
'tr.id,tr.testType,tr.createdAt,tr.enterpriseId,tr.requiresPayment,tr.isPaid,tr.orderId,tr.paidAmount,' .
'e_tr.name as enterpriseName,wu.enterpriseId as bindEnterpriseId,e_wu.name as bindEnterpriseName,' .
'SUBSTRING(COALESCE(CAST(tr.resultData AS CHAR(65532)),\'\'),1,' . $listJsonSliceLen . ') as resultDataLite'
)
->order('tr.createdAt', 'desc');
$profileIncomplete = !self::isWechatProfileComplete($userId);
if ($allowedTestTypes === []) {
return success([
'list' => [],
'total' => 0,
'page' => $page,
'pageSize' => $pageSize,
'hasMore' => false,
]);
}
$base->whereIn('tr.testType', $allowedTestTypes);
if ($type !== 'all') {
if (in_array($type, ['face', 'ai'], true)) {
$base->whereIn('tr.testType', ['face', 'ai']);
@@ -82,9 +104,8 @@ class Test extends BaseController
$orderId = isset($row['orderId']) ? (int) $row['orderId'] : null;
$paidAmountRow = isset($row['paidAmount']) ? (int) $row['paidAmount'] : 0;
$needPayUnlock = $requiresPayment && !$isPaid && $paidAmountRow > 0;
$profileIncomplete = !self::isWechatProfileComplete($userId);
$raw = $row['resultData'] ?? ($row['result'] ?? null);
$raw = $row['resultDataLite'] ?? ($row['resultData'] ?? null);
$data = null;
if ($raw !== null && $raw !== '') {
$decoded = json_decode($raw, true);
@@ -107,37 +128,37 @@ class Test extends BaseController
'enterpriseName' => $enterpriseName,
];
// 映射为小程序 history 页需要的结构
// 映射为小程序 history 页列表结构;完整报告走 GET /api/test/detail?id= 减轻响应体积
switch ($testType) {
case 'mbti':
$mbtiType = $data['mbtiType'] ?? $data['mbti'] ?? '未知';
$mbtiType = is_array($data) ? ($data['mbtiType'] ?? $data['mbti'] ?? '未知') : '未知';
$list[] = array_merge([
'id' => $id,
'type' => 'mbti',
'key' => 'mbti_' . $id,
'emoji' => '🧠',
'typeName' => 'MBTI性格测试',
'resultText'=> $mbtiType,
'resultText'=> is_string($mbtiType) || is_numeric($mbtiType) ? (string) $mbtiType : '未知',
'testTime' => $timeLabel,
'data' => $data,
'data' => null,
], $paymentFields);
break;
case 'disc':
$discType = $data['dominantType'] ?? $data['disc'] ?? '未知';
$discType = is_array($data) ? ($data['dominantType'] ?? $data['disc'] ?? '未知') : '未知';
$list[] = array_merge([
'id' => $id,
'type' => 'disc',
'key' => 'disc_' . $id,
'emoji' => '📊',
'typeName' => 'DISC性格测试',
'resultText'=> $discType . '型',
'resultText'=> (is_string($discType) || is_numeric($discType) ? (string) $discType : '未知') . '型',
'testTime' => $timeLabel,
'data' => $data,
'data' => null,
], $paymentFields);
break;
case 'pdp':
$primary = $data['description']['type'] ?? $data['pdp'] ?? '未知';
$emoji = $data['description']['emoji'] ?? '🦁';
$primary = is_array($data) ? ($data['description']['type'] ?? $data['pdp'] ?? '未知') : '未知';
$emoji = (is_array($data) && isset($data['description']['emoji'])) ? $data['description']['emoji'] : '🦁';
$list[] = array_merge([
'id' => $id,
'type' => 'pdp',
@@ -146,7 +167,7 @@ class Test extends BaseController
'typeName' => 'PDP行为偏好测试',
'resultText'=> $primary,
'testTime' => $timeLabel,
'data' => $data,
'data' => null,
], $paymentFields);
break;
case 'face':
@@ -167,7 +188,7 @@ class Test extends BaseController
'typeName' => '面相分析',
'resultText'=> $mbtiShort ?: '未知',
'testTime' => $timeLabel,
'data' => $data,
'data' => null,
], $paymentFields);
break;
case 'resume':
@@ -186,7 +207,7 @@ class Test extends BaseController
'typeName' => '简历综合分析',
'resultText'=> $summary ?: '简历综合分析',
'testTime' => $timeLabel,
'data' => $data,
'data' => null,
], $paymentFields);
break;
default:
@@ -222,20 +243,28 @@ class Test extends BaseController
$scope = Request::param('scope', 'all'); // all|personal|enterprise
$allowedAll = $this->wechatAllowedTestTypes($userId);
// 「我的」卡片不含简历,但 totalCount 与列表需与 history 权限一致
$allowedForRecent = array_values(array_intersect($allowedAll, ['mbti', 'pdp', 'disc', 'face', 'ai']));
if ($allowedForRecent === []) {
return success([
'records' => new \stdClass(),
'totalCount' => 0,
]);
}
$records = [];
// 优化:一次性查询所有需要的最新记录,减少数据库连接和查询次数
$query = Db::name('test_results')
->where('userId', $userId);
->where('userId', $userId)
->whereIn('testType', $allowedForRecent);
if ($scope === 'personal') {
$query->whereNull('enterpriseId');
} elseif ($scope === 'enterprise') {
$query->whereNotNull('enterpriseId');
}
// 使用子查询或 Union 可能更复杂,这里采用分组取最新的优化思路
// 但 ThinkPHP 中最简单有效的优化是先查出所有类型,再处理
$allRows = $query->order('createdAt', 'desc')->select()->toArray();
$foundTypes = [];
@@ -264,6 +293,50 @@ class Test extends BaseController
]);
}
/**
* 微信用户当前允许的 test_results.testType绑定企业 permissions + 审核模式;简历仅绑定企业可见)
* @return string[]
*/
protected function wechatAllowedTestTypes(int $userId): array
{
$bindRow = Db::name('wechat_users')->where('id', $userId)->field('enterpriseId')->find();
$boundEnterpriseId = (int) ($bindRow['enterpriseId'] ?? 0);
$enterprisePerms = EnterpriseModel::permissionDefaults();
if ($boundEnterpriseId > 0) {
$entPermRow = Db::name('enterprises')->where('id', $boundEnterpriseId)->field('permissions')->find();
if (is_array($entPermRow)) {
$enterprisePerms = EnterpriseModel::normalizePermissionsValue($entPermRow['permissions'] ?? null);
}
}
$reviewMode = false;
$systemRow = Db::name('system_config')->where('key', 'system')->find();
if ($systemRow && !empty($systemRow['value'])) {
$sysVal = is_string($systemRow['value']) ? json_decode($systemRow['value'], true) : $systemRow['value'];
if (is_array($sysVal) && !empty($sysVal['maintenanceMode'])) {
$reviewMode = true;
}
}
$allowed = [];
if (!empty($enterprisePerms['mbti'])) {
$allowed[] = 'mbti';
}
if (!empty($enterprisePerms['pdp'])) {
$allowed[] = 'pdp';
}
if (!empty($enterprisePerms['disc'])) {
$allowed[] = 'disc';
}
if (!empty($enterprisePerms['face']) && !$reviewMode) {
$allowed[] = 'face';
$allowed[] = 'ai';
}
if ($boundEnterpriseId > 0) {
$allowed[] = 'resume';
}
return array_values(array_unique($allowed));
}
/**
* 格式化单条记录为 recent 接口返回结构
*/

View File

@@ -16,6 +16,11 @@ class Enterprise extends BaseController
* 获取企业列表
* @return \think\response\Json
*/
private function normalizePerms($raw): array
{
return EnterpriseModel::normalizePermissionsValue($raw);
}
public function index()
{
// 验证是否为超级管理员
@@ -48,6 +53,12 @@ class Enterprise extends BaseController
->select()
->toArray();
foreach ($list as &$item) {
$item['permissions'] = $this->normalizePerms($item['permissions'] ?? null);
$item['permissionsCeiling'] = EnterpriseModel::normalizedPermissionsCeiling($item);
}
unset($item);
// 统计每个企业的用户数和测试用量
foreach ($list as &$item) {
// 统计用户数(只统计未删除的用户)
@@ -121,15 +132,16 @@ class Enterprise extends BaseController
$orderPageSize = min(100, max(1, (int) Request::param('orderPageSize', 10)));
$data = $enterprise->toArray();
// 获取企业下的所有用户ID只统计未删除的用户
$userIds = Db::name('users')
->where('enterpriseId', $id)
$data['permissionsCeiling'] = EnterpriseModel::normalizedPermissionsCeiling($enterprise);
$data['permissions'] = $this->normalizePerms($data['permissions'] ?? null);
$eid = (int) $id;
// 统计后台账号用户数(避免 column 全量 id + 巨大 whereIn
$data['userCount'] = (int) Db::name('users')
->where('enterpriseId', $eid)
->where('deletedAt', null)
->column('id');
// 统计用户数
$data['userCount'] = count($userIds);
->count();
// 获取管理员账号列表(企业管理员角色,只获取未删除的)
$adminAccounts = Db::name('users')
@@ -152,42 +164,45 @@ class Enterprise extends BaseController
->toArray();
$data['users'] = $users;
// 获取测试结果列表
// 测试结果(后台 users 归属JOIN 企业,禁止 whereIn 海量 userId
$testResults = [];
if (!empty($userIds)) {
$testResults = Db::name('test_results')
->alias('tr')
->leftJoin('users u', 'tr.userId = u.id')
->where('tr.userId', 'in', $userIds)
->field('tr.id,tr.testType,tr.createdAt,tr.userId,tr.resultData,u.username')
->order('tr.createdAt', 'desc')
->limit(50) // 限制返回数量
->select()
->toArray();
try {
if ($data['userCount'] > 0) {
$testResults = Db::name('test_results')
->alias('tr')
->join('users u', 'tr.userId = u.id')
->where('u.enterpriseId', $eid)
->where('u.deletedAt', null)
->field('tr.id,tr.testType,tr.createdAt,tr.userId,tr.resultData,u.username')
->order('tr.createdAt', 'desc')
->limit(50)
->select()
->toArray();
}
} catch (\Throwable $e) {
$testResults = [];
}
$data['testResults'] = $testResults;
$this->attachResultSummaries($data['testResults']);
// 统计测试用量
if (!empty($userIds)) {
$data['testUsage'] = Db::name('test_results')
->where('userId', 'in', $userIds)
->count();
} else {
try {
$data['testUsage'] = $data['userCount'] > 0
? (int) Db::name('test_results')
->alias('tr')
->join('users u', 'tr.userId = u.id')
->where('u.enterpriseId', $eid)
->where('u.deletedAt', null)
->count()
: 0;
} catch (\Throwable $e) {
$data['testUsage'] = 0;
}
// —— 小程序侧用户wechat_users.enterpriseId——
$wechatIds = [];
try {
$wechatIds = Db::name('wechat_users')->where('enterpriseId', $id)->column('id');
$wechatIds = array_values(array_filter($wechatIds));
} catch (\Throwable $e) {
$wechatIds = [];
}
// 禁止加载全量 wechat user id 到 PHP 再 IN(...) 查 analytics_events大数据必超时
$wechatUserTotalCount = 0;
try {
$wechatUserTotalCount = (int) Db::name('wechat_users')->where('enterpriseId', $id)->count();
$wechatUserTotalCount = (int) Db::name('wechat_users')->where('enterpriseId', $eid)->count();
} catch (\Throwable $e) {
$wechatUserTotalCount = 0;
}
@@ -196,7 +211,7 @@ class Enterprise extends BaseController
$data['wechatUsers'] = [];
try {
$data['wechatUsers'] = Db::name('wechat_users')
->where('enterpriseId', $id)
->where('enterpriseId', $eid)
->field('id,openid,nickname,phone,avatar,status,lastLoginAt,createdAt')
->order('createdAt', 'desc')
->page($wechatPage, $wechatPageSize)
@@ -210,11 +225,11 @@ class Enterprise extends BaseController
$data['miniprogramTestResults'] = [];
$data['miniprogramTestResultsTotal'] = 0;
try {
$data['miniprogramTestResultsTotal'] = (int) Db::name('test_results')->where('enterpriseId', $id)->count();
$data['miniprogramTestResultsTotal'] = (int) Db::name('test_results')->where('enterpriseId', $eid)->count();
$data['miniprogramTestResults'] = Db::name('test_results')
->alias('tr')
->leftJoin('wechat_users w', 'tr.userId = w.id')
->where('tr.enterpriseId', $id)
->where('tr.enterpriseId', $eid)
->field('tr.id,tr.testType,tr.createdAt,tr.userId,tr.resultData,w.nickname as wechatNickname')
->order('tr.createdAt', 'desc')
->page($testPage, $testPageSize)
@@ -224,17 +239,17 @@ class Enterprise extends BaseController
} catch (\Throwable $e) {
$data['miniprogramTestResults'] = [];
}
// 订单与消耗(金额分)
// 订单与消耗(金额分)
$paidStatuses = ['paid', 'completed'];
try {
$data['orderStats'] = [
'totalCount' => (int) Db::name('orders')->where('enterpriseId', $id)->count(),
'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),
'totalCount' => (int) Db::name('orders')->where('enterpriseId', $eid)->count(),
'paidCount' => (int) Db::name('orders')->where('enterpriseId', $eid)->whereIn('status', $paidStatuses)->count(),
'paidAmountFen' => (int) (Db::name('orders')->where('enterpriseId', $eid)->whereIn('status', $paidStatuses)->sum('amount') ?? 0),
];
$data['recentOrdersTotal'] = (int) Db::name('orders')->where('enterpriseId', $id)->count();
$data['recentOrdersTotal'] = (int) Db::name('orders')->where('enterpriseId', $eid)->count();
$data['recentOrders'] = Db::name('orders')
->where('enterpriseId', $id)
->where('enterpriseId', $eid)
->order('createdAt', 'desc')
->page($orderPage, $orderPageSize)
->field('id,orderNo,status,amount,productType,userId,createdAt')
@@ -258,23 +273,27 @@ class Enterprise extends BaseController
'hint' => null,
'windowDays' => 30,
];
if (empty($wechatIds)) {
if ($wechatUserTotalCount <= 0) {
$data['analyticsStats']['hint'] = '暂无 enterpriseId 归属该企业的微信小程序用户,无法按企业聚合埋点';
} else {
try {
$wechatIdSub = Db::name('wechat_users')
->whereRaw('enterpriseId = ' . $eid)
->field('id')
->buildSql(true);
$since = date('Y-m-d H:i:s', time() - 30 * 86400);
$data['analyticsStats']['eventTotal'] = (int) Db::name('analytics_events')
->where('userId', 'in', $wechatIds)
->where('createdAt', '>=', $since)
->whereRaw('userId IN ' . $wechatIdSub)
->count();
$data['analyticsStats']['pageViewCount'] = (int) Db::name('analytics_events')
->where('userId', 'in', $wechatIds)
->where('createdAt', '>=', $since)
->where('eventName', 'page_view')
->whereRaw('userId IN ' . $wechatIdSub)
->count();
$byEvent = Db::name('analytics_events')
->where('userId', 'in', $wechatIds)
->where('createdAt', '>=', $since)
->whereRaw('userId IN ' . $wechatIdSub)
->field('eventName, COUNT(*) AS cnt')
->group('eventName')
->order('cnt', 'desc')
@@ -376,6 +395,13 @@ class Enterprise extends BaseController
$enterprise->balance = $data['balance'] ?? 0.00;
$enterprise->status = $status;
$enterprise->trialExpireAt = ($status === 'trial' && isset($data['trialExpireAt'])) ? $data['trialExpireAt'] : null;
if (isset($data['permissions']) && is_array($data['permissions'])) {
$norm = $this->normalizePerms($data['permissions']);
} else {
$norm = EnterpriseModel::permissionDefaults();
}
$enterprise->permissions = $norm;
$enterprise->permissionsCeiling = $norm;
$enterprise->save();
$enterpriseId = $enterprise->id;
@@ -460,6 +486,14 @@ class Enterprise extends BaseController
$enterprise->trialExpireAt = null;
}
if (isset($data['permissions']) && is_array($data['permissions'])) {
$newCeiling = $this->normalizePerms($data['permissions']);
$oldEff = EnterpriseModel::normalizePermissionsValue($enterprise->permissions ?? null);
$merged = EnterpriseModel::clampEffectiveToNewCeiling($newCeiling, $oldEff);
$enterprise->permissions = $merged;
$enterprise->permissionsCeiling = $newCeiling;
unset($data['permissions']);
}
$enterprise->save($data);
$newBalance = (float) ($enterprise->balance ?? 0);
@@ -472,7 +506,9 @@ class Enterprise extends BaseController
}
$enterpriseData = $enterprise->toArray();
$enterpriseData['permissionsCeiling'] = EnterpriseModel::normalizedPermissionsCeiling($enterprise);
$enterpriseData['permissions'] = $this->normalizePerms($enterpriseData['permissions'] ?? null);
// 统计用户数和测试用量(只统计未删除的用户)
$enterpriseData['userCount'] = Db::name('users')
->where('enterpriseId', $id)