高考版优化
审核模式优化
This commit is contained in:
100
api/app/controller/admin/Analytics.php
Normal file
100
api/app/controller/admin/Analytics.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
namespace app\controller\admin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\AnalyticsEventLabels;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 小程序埋点(企业/平台管理端,受企业用户范围约束)
|
||||
*/
|
||||
class Analytics extends BaseController
|
||||
{
|
||||
/**
|
||||
* 单用户旅程:与 superadmin.Analytics/userJourney 数据结构一致,
|
||||
* 额外校验目标 userId 属于当前管理员可见企业(user_profile)。
|
||||
*
|
||||
* GET /api/v1/admin/analytics/user-journey?userId=&days=30
|
||||
*/
|
||||
public function userJourney()
|
||||
{
|
||||
$auth = $this->request->user ?? null;
|
||||
if (!$auth) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
if (!in_array($auth['role'] ?? '', ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$userId = (int) Request::param('userId', 0);
|
||||
if ($userId <= 0) {
|
||||
return error('userId 不能为空', 400);
|
||||
}
|
||||
|
||||
$enterpriseId = $auth['enterpriseId'] ?? null;
|
||||
if (!$enterpriseId) {
|
||||
$adminRow = Db::name('users')->where('id', $auth['userId'] ?? 0)->find();
|
||||
$enterpriseId = $adminRow['enterpriseId'] ?? null;
|
||||
}
|
||||
|
||||
if ($enterpriseId) {
|
||||
$has = Db::name('user_profile')
|
||||
->where('userId', $userId)
|
||||
->where('enterpriseId', (int) $enterpriseId)
|
||||
->find();
|
||||
if (!$has) {
|
||||
return error('无权限查看该用户', 403);
|
||||
}
|
||||
}
|
||||
|
||||
$days = min(180, max(1, (int) Request::param('days', 30)));
|
||||
$since = date('Y-m-d H:i:s', time() - $days * 86400);
|
||||
$page = max(1, (int) Request::param('page', 1));
|
||||
$pageSize = min(100, max(1, (int) Request::param('pageSize', 20)));
|
||||
$offset = ($page - 1) * $pageSize;
|
||||
|
||||
try {
|
||||
$baseQ = Db::name('analytics_events')
|
||||
->where('userId', $userId)
|
||||
->where('createdAt', '>=', $since);
|
||||
$total = (int) (clone $baseQ)->count();
|
||||
$rows = (clone $baseQ)
|
||||
->order('id', 'desc')
|
||||
->limit($offset, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
foreach ($rows as &$r) {
|
||||
if (!empty($r['propsJson'])) {
|
||||
$decoded = json_decode($r['propsJson'], true);
|
||||
$r['props'] = is_array($decoded) ? $decoded : null;
|
||||
} else {
|
||||
$r['props'] = null;
|
||||
}
|
||||
unset($r['propsJson']);
|
||||
}
|
||||
unset($r);
|
||||
$rows = AnalyticsEventLabels::withCn($rows);
|
||||
|
||||
return success([
|
||||
'userId' => $userId,
|
||||
'days' => $days,
|
||||
'list' => $rows,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'pageSize' => $pageSize,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return success([
|
||||
'userId' => $userId,
|
||||
'days' => $days,
|
||||
'list' => [],
|
||||
'total' => 0,
|
||||
'page' => $page,
|
||||
'pageSize' => $pageSize,
|
||||
'tableMissing' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use app\BaseController;
|
||||
use app\common\service\ResumeUploadsAdminService;
|
||||
use app\controller\admin\concern\ExtractsTestResults;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
@@ -419,6 +420,200 @@ class AppUser extends BaseController
|
||||
return paginate_response($list, $total, $page, $pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* JOIN 后按去重用户计数(别名 w = wechat_users)
|
||||
* 不可用 fieldRaw + value(别名):ThinkPHP 的 value() 会清空 field 并把参数当成列名,聚合会丢失。
|
||||
*
|
||||
* @param \think\db\Query $query
|
||||
*/
|
||||
private function countDistinctAppUsers($query): int
|
||||
{
|
||||
try {
|
||||
// find() 在无 where 时不执行 SQL(仅 JOIN 也会被跳过),聚合恒错成 0
|
||||
$row = (clone $query)
|
||||
->whereRaw('1=1')
|
||||
->fieldRaw('COUNT(DISTINCT `w`.`id`) AS `_agg_u`')
|
||||
->find();
|
||||
|
||||
if (is_object($row) && method_exists($row, 'toArray')) {
|
||||
$row = $row->toArray();
|
||||
}
|
||||
if (!is_array($row)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) ($row['_agg_u'] ?? 0);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('[AppUser::countDistinctAppUsers] ' . $e->getMessage());
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户画像汇总卡片(与列表同一企业口径)
|
||||
* GET /api/v1/admin/app-users/stats
|
||||
*/
|
||||
public function stats()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user) {
|
||||
return error('未登录', 401);
|
||||
}
|
||||
if (!in_array($user['role'] ?? '', ['admin', 'enterprise_admin'])) {
|
||||
return error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$enterpriseId = $user['enterpriseId'] ?? null;
|
||||
if (!$enterpriseId) {
|
||||
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
|
||||
$enterpriseId = $adminRow['enterpriseId'] ?? null;
|
||||
}
|
||||
|
||||
$out = [
|
||||
'faceCount' => 0,
|
||||
'resumeCount' => 0,
|
||||
'phoneCount' => 0,
|
||||
'anyTestCount' => 0,
|
||||
'cooperationCount' => 0,
|
||||
];
|
||||
|
||||
try {
|
||||
if ($enterpriseId) {
|
||||
$eid = (int) $enterpriseId;
|
||||
$poolSql = Db::name('user_profile')
|
||||
->whereRaw('enterpriseId = ' . $eid)
|
||||
->group('userId')
|
||||
->field('userId')
|
||||
->buildSql(true);
|
||||
|
||||
$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);
|
||||
|
||||
$base = Db::name('wechat_users')->alias('w')
|
||||
->join([$poolSql => 'p'], 'w.id = p.userId')
|
||||
->join([$dedupSql => 'd'], 'w.id = d.mid');
|
||||
|
||||
$tblUp = Db::name('user_profile')->getTable();
|
||||
$tblTr = Db::name('test_results')->getTable();
|
||||
$out['faceCount'] = $this->countDistinctAppUsers(
|
||||
(clone $base)->whereRaw(
|
||||
'EXISTS (SELECT 1 FROM `' . $tblUp
|
||||
. '` up WHERE up.userId = `w`.`id` AND up.enterpriseId = ? AND (TRIM(IFNULL(up.coldFaceLevel,\'\')) <> \'\' OR up.coldFaceScore IS NOT NULL))'
|
||||
. ' OR EXISTS (SELECT 1 FROM `' . $tblTr
|
||||
. '` tf WHERE tf.userId = `w`.`id` AND tf.enterpriseId = ? AND tf.testType IN (\'face\',\'ai\'))',
|
||||
[$eid, $eid]
|
||||
)
|
||||
);
|
||||
|
||||
$out['phoneCount'] = $this->countDistinctAppUsers(
|
||||
(clone $base)->whereRaw('TRIM(IFNULL(w.phone,\'\')) <> \'\'')
|
||||
);
|
||||
|
||||
$out['anyTestCount'] = $this->countDistinctAppUsers(
|
||||
(clone $base)
|
||||
->join('test_results tr', 'tr.userId = w.id')
|
||||
->where('tr.enterpriseId', $eid)
|
||||
);
|
||||
|
||||
// 与「合作意向」Tab 一致:本企业 user_cooperation_choices 去重人数,不依赖 user_profile 池
|
||||
$coopRow = Db::name('user_cooperation_choices')
|
||||
->where('enterpriseId', $eid)
|
||||
->fieldRaw('COUNT(DISTINCT `userId`) AS `_c`')
|
||||
->find();
|
||||
$out['cooperationCount'] = (int) (is_array($coopRow) ? ($coopRow['_c'] ?? 0) : 0);
|
||||
|
||||
$cntResumeUpload = $this->countDistinctAppUsers(
|
||||
(clone $base)
|
||||
->join('enterprise_resume_uploads eru', 'eru.userId = w.id')
|
||||
->where('eru.enterpriseId', $eid)
|
||||
);
|
||||
|
||||
$cntPhoneAndTest = $this->countDistinctAppUsers(
|
||||
(clone $base)
|
||||
->whereRaw('TRIM(IFNULL(w.phone,\'\')) <> \'\'')
|
||||
->join('test_results tr2', 'tr2.userId = w.id')
|
||||
->where('tr2.enterpriseId', $eid)
|
||||
);
|
||||
|
||||
$cntBoth = $this->countDistinctAppUsers(
|
||||
(clone $base)
|
||||
->join('enterprise_resume_uploads eru', 'eru.userId = w.id')
|
||||
->where('eru.enterpriseId', $eid)
|
||||
->whereRaw('TRIM(IFNULL(w.phone,\'\')) <> \'\'')
|
||||
->join('test_results tr3', 'tr3.userId = w.id')
|
||||
->where('tr3.enterpriseId', $eid)
|
||||
);
|
||||
|
||||
$out['resumeCount'] = max(0, $cntResumeUpload + $cntPhoneAndTest - $cntBoth);
|
||||
} else {
|
||||
$dedupSql = Db::name('wechat_users')
|
||||
->alias('w2')
|
||||
->field('w2.openid, MAX(w2.id) AS mid')
|
||||
->group('w2.openid')
|
||||
->buildSql(true);
|
||||
|
||||
$base = Db::name('wechat_users')->alias('w')
|
||||
->join([$dedupSql => 'd'], 'w.id = d.mid');
|
||||
|
||||
$tblUp = Db::name('user_profile')->getTable();
|
||||
$tblTr = Db::name('test_results')->getTable();
|
||||
$out['faceCount'] = $this->countDistinctAppUsers(
|
||||
(clone $base)->whereRaw(
|
||||
'EXISTS (SELECT 1 FROM `' . $tblUp
|
||||
. '` up WHERE up.userId = `w`.`id` AND (TRIM(IFNULL(up.coldFaceLevel,\'\')) <> \'\' OR up.coldFaceScore IS NOT NULL))'
|
||||
. ' OR EXISTS (SELECT 1 FROM `' . $tblTr
|
||||
. '` tf WHERE tf.userId = `w`.`id` AND tf.testType IN (\'face\',\'ai\'))'
|
||||
)
|
||||
);
|
||||
|
||||
$out['phoneCount'] = $this->countDistinctAppUsers(
|
||||
(clone $base)->whereRaw('TRIM(IFNULL(w.phone,\'\')) <> \'\'')
|
||||
);
|
||||
|
||||
$out['anyTestCount'] = $this->countDistinctAppUsers(
|
||||
(clone $base)
|
||||
->join('test_results tr', 'tr.userId = w.id')
|
||||
);
|
||||
|
||||
// 与「合作意向」Tab 一致:全平台去重 userId(无企业归属账号)
|
||||
$coopRowAll = Db::name('user_cooperation_choices')
|
||||
->whereRaw('1=1')
|
||||
->fieldRaw('COUNT(DISTINCT `userId`) AS `_c`')
|
||||
->find();
|
||||
$out['cooperationCount'] = (int) (is_array($coopRowAll) ? ($coopRowAll['_c'] ?? 0) : 0);
|
||||
|
||||
$cntResumeUpload = $this->countDistinctAppUsers(
|
||||
(clone $base)
|
||||
->join('enterprise_resume_uploads eru', 'eru.userId = w.id')
|
||||
);
|
||||
|
||||
$cntPhoneAndTest = $this->countDistinctAppUsers(
|
||||
(clone $base)
|
||||
->whereRaw('TRIM(IFNULL(w.phone,\'\')) <> \'\'')
|
||||
->join('test_results tr2', 'tr2.userId=w.id')
|
||||
);
|
||||
|
||||
$cntBoth = $this->countDistinctAppUsers(
|
||||
(clone $base)
|
||||
->join('enterprise_resume_uploads eru', 'eru.userId=w.id')
|
||||
->whereRaw('TRIM(IFNULL(w.phone,\'\')) <> \'\'')
|
||||
->join('test_results tr3', 'tr3.userId=w.id')
|
||||
);
|
||||
|
||||
$out['resumeCount'] = max(0, $cntResumeUpload + $cntPhoneAndTest - $cntBoth);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('[AppUser::stats] ' . $e->getMessage(), ['trace' => $e->getTraceAsString()]);
|
||||
}
|
||||
|
||||
return success($out);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试用户详情:基本信息 + 测试记录列表
|
||||
* GET /api/v1/admin/app-users/:id
|
||||
|
||||
@@ -149,11 +149,31 @@ class Dashboard extends BaseController
|
||||
$distributionPdp = $this->aggregateTestLabels($enterpriseId, 'pdp', 12);
|
||||
$faceSubtypeHints = $this->aggregateFaceSubtypeHints($enterpriseId, 8);
|
||||
|
||||
// 与本企业财务/订单列表一致:已支付订单实收累计(分)
|
||||
$paidStatuses = ['paid', 'completed'];
|
||||
$incomeProductTypes = ['face', 'mbti', 'sbti', 'disc', 'pdp', 'ai_deep_report'];
|
||||
$incomeQuery = Db::name('orders')
|
||||
->whereIn('status', $paidStatuses)
|
||||
->whereIn('productType', $incomeProductTypes);
|
||||
if ($enterpriseId) {
|
||||
$incomeQuery->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$totalRevenueFen = (int) ($incomeQuery->sum('amount') ?? 0);
|
||||
|
||||
// 企业余额侧累计扣款(分),概览「AI/算力消耗」卡片与财务管理口径对齐
|
||||
$consumeQuery = Db::name('finance_records')->where('type', 'consume');
|
||||
if ($enterpriseId) {
|
||||
$consumeQuery->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$aiCostFen = (int) ($consumeQuery->sum('amount') ?? 0);
|
||||
|
||||
return success([
|
||||
'totalUsers' => $totalUsers,
|
||||
'testsCompleted' => $testsCompleted,
|
||||
'activeToday' => $activeToday,
|
||||
'pendingReviews' => $pendingReviews,
|
||||
'totalRevenue' => $totalRevenueFen,
|
||||
'aiCost' => $aiCostFen,
|
||||
'testTrends' => $trendData,
|
||||
'topTestUsers' => $topTestUsers,
|
||||
'testCatalog' => $testCatalog,
|
||||
|
||||
@@ -7,7 +7,7 @@ use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 定价管理控制器(普通管理员)
|
||||
* 支持同时配置个人版和企业版定价:
|
||||
* 支持同时配置个人版、企业版和高考版定价:
|
||||
* - 个人版:type=admin_personal + enterpriseId(企业管理员)或 enterpriseId=NULL(普通管理员)
|
||||
* - 企业版:type=admin_enterprise + enterpriseId(企业管理员)
|
||||
* 无自定义配置时回落到超管全局定价
|
||||
@@ -15,7 +15,7 @@ use think\facade\Db;
|
||||
class Pricing extends BaseController
|
||||
{
|
||||
/**
|
||||
* 获取定价配置(个人版 + 企业版)
|
||||
* 获取定价配置(个人版 + 企业版 + 高考版)
|
||||
* GET /api/v1/admin/pricing
|
||||
*/
|
||||
public function index()
|
||||
@@ -49,12 +49,24 @@ class Pricing extends BaseController
|
||||
: ($superEnterpriseConfig ? $superEnterpriseConfig->config : []);
|
||||
$isUsingSuperAdminEnterpriseConfig = !$adminEnterpriseConfig;
|
||||
|
||||
// ── 高考版定价 ──
|
||||
$adminGaokaoConfig = $enterpriseId
|
||||
? $this->queryConfig('admin_gaokao', $enterpriseId)
|
||||
: null;
|
||||
$superGaokaoConfig = PricingConfigModel::where('type', 'gaokao')->whereNull('enterpriseId')->find();
|
||||
$gaokaoConfig = $adminGaokaoConfig
|
||||
? $adminGaokaoConfig->config
|
||||
: ($superGaokaoConfig ? $superGaokaoConfig->config : []);
|
||||
$isUsingSuperAdminGaokaoConfig = !$adminGaokaoConfig;
|
||||
|
||||
return success([
|
||||
'personal' => $personalConfig,
|
||||
'enterprise' => $enterpriseConfig,
|
||||
'gaokao' => $gaokaoConfig,
|
||||
'isUsingSuperAdminConfig' => $isUsingSuperAdminPersonalConfig,
|
||||
'isUsingSuperAdminPersonalConfig' => $isUsingSuperAdminPersonalConfig,
|
||||
'isUsingSuperAdminEnterpriseConfig' => $isUsingSuperAdminEnterpriseConfig,
|
||||
'isUsingSuperAdminGaokaoConfig' => $isUsingSuperAdminGaokaoConfig,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return error('获取定价配置失败:' . $e->getMessage(), 500);
|
||||
@@ -62,9 +74,9 @@ class Pricing extends BaseController
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新定价配置(个人版 + 企业版)
|
||||
* 更新定价配置(个人版 + 企业版 + 高考版)
|
||||
* PUT /api/v1/admin/pricing
|
||||
* Body: { personalConfig: {...}, enterpriseConfig: {...} }
|
||||
* Body: { personalConfig: {...}, enterpriseConfig: {...}, gaokaoConfig: {...} }
|
||||
* 兼容旧格式:{ config: {...} } → 仅更新个人版
|
||||
*/
|
||||
public function update()
|
||||
@@ -89,8 +101,9 @@ class Pricing extends BaseController
|
||||
// 兼容旧版仅传 config 的情况
|
||||
$personalConfig = $input['personalConfig'] ?? $input['config'] ?? null;
|
||||
$enterpriseConfig = $input['enterpriseConfig'] ?? null;
|
||||
$gaokaoConfig = $input['gaokaoConfig'] ?? null;
|
||||
|
||||
if ($personalConfig === null && $enterpriseConfig === null) {
|
||||
if ($personalConfig === null && $enterpriseConfig === null && $gaokaoConfig === null) {
|
||||
return error('配置数据不能为空', 400);
|
||||
}
|
||||
|
||||
@@ -104,7 +117,7 @@ class Pricing extends BaseController
|
||||
if (!is_array($personalConfig)) {
|
||||
return error('个人版定价格式错误', 400);
|
||||
}
|
||||
foreach (['face', 'mbti', 'disc', 'pdp', 'sbti', 'gaokao'] as $field) {
|
||||
foreach (['face', 'mbti', 'disc', 'pdp', 'sbti'] as $field) {
|
||||
if (!array_key_exists($field, $personalConfig)) {
|
||||
return error("个人版定价缺少字段:{$field}", 400);
|
||||
}
|
||||
@@ -131,7 +144,7 @@ class Pricing extends BaseController
|
||||
if (!is_array($enterpriseConfig)) {
|
||||
return error('企业版定价格式错误', 400);
|
||||
}
|
||||
foreach (['face', 'mbti', 'disc', 'pdp', 'sbti', 'gaokao'] as $field) {
|
||||
foreach (['face', 'mbti', 'disc', 'pdp', 'sbti'] as $field) {
|
||||
if (!array_key_exists($field, $enterpriseConfig)) {
|
||||
return error("企业版定价缺少字段:{$field}", 400);
|
||||
}
|
||||
@@ -150,6 +163,33 @@ class Pricing extends BaseController
|
||||
$result['enterprise'] = $cfg->config;
|
||||
}
|
||||
|
||||
// ── 保存高考版定价(仅企业管理员)──
|
||||
if ($gaokaoConfig !== null) {
|
||||
if (!$enterpriseId) {
|
||||
return error('仅企业管理员可设置高考版定价', 403);
|
||||
}
|
||||
if (!is_array($gaokaoConfig)) {
|
||||
return error('高考版定价格式错误', 400);
|
||||
}
|
||||
foreach (['face', 'mbti', 'disc', 'pdp', 'sbti', 'gaokao'] as $field) {
|
||||
if (!array_key_exists($field, $gaokaoConfig)) {
|
||||
return error("高考版定价缺少字段:{$field}", 400);
|
||||
}
|
||||
}
|
||||
$cfg = $this->queryConfig('admin_gaokao', $enterpriseId);
|
||||
if (!$cfg) {
|
||||
$cfg = PricingConfigModel::create([
|
||||
'type' => 'admin_gaokao',
|
||||
'enterpriseId' => $enterpriseId,
|
||||
'config' => $gaokaoConfig,
|
||||
]);
|
||||
} else {
|
||||
$cfg->config = $gaokaoConfig;
|
||||
$cfg->save();
|
||||
}
|
||||
$result['gaokao'] = $cfg->config;
|
||||
}
|
||||
|
||||
return success($result, '定价配置已保存');
|
||||
} catch (\Exception $e) {
|
||||
return error('保存失败:' . $e->getMessage(), 500);
|
||||
|
||||
Reference in New Issue
Block a user