feat: 企业权限上限与迁移脚本;管理端/超管概览与财务;双端小程序 tab/订单/历史/测评选择等同步
Made-with: Cursor
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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 集合(用于后续统计个人版测试)
|
||||
|
||||
88
api/app/controller/admin/EnterprisePermissions.php
Normal file
88
api/app/controller/admin/EnterprisePermissions.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user