高考版优化

审核模式优化
This commit is contained in:
Ghost
2026-04-29 18:01:26 +08:00
parent 59419c4ce9
commit a411bf7b21
134 changed files with 19898 additions and 3371 deletions

View 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,
]);
}
}
}

View File

@@ -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

View File

@@ -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,

View File

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