feat: 管理端聚合页、小程序/抖音埋点与统计、飞书线索 webhook、API 迁移与路由

- admin:OrdersHub/UsersHub、Commerce/Ops/Enterprise Hub、MpAnalytics、Feishu/小程序配置面板、鉴权存储
- api:Analytics、DataMigration、FeishuLeadWebhook、mp 事件迁移 SQL
- 微信/抖音小程序:analytics 上报与相关页面调整
- 开发文档与 scripts 补充

Made-with: Cursor
This commit is contained in:
卡若
2026-03-27 17:18:25 +08:00
parent 9601b6955b
commit aca2e263bb
188 changed files with 41937 additions and 27625 deletions

View File

@@ -0,0 +1,100 @@
<?php
namespace app\controller\superadmin;
use app\BaseController;
use think\facade\Db;
use think\facade\Request;
/**
* 小程序埋点统计(仅超级管理员)
*/
class Analytics extends BaseController
{
/**
* GET /api/v1/superadmin/analytics/summary?days=7
*/
public function summary()
{
$days = min(90, max(1, (int) Request::param('days', 7)));
$since = date('Y-m-d H:i:s', time() - $days * 86400);
try {
$list = Db::name('analytics_events')
->field('eventName, COUNT(*) AS cnt')
->where('createdAt', '>=', $since)
->group('eventName')
->order('cnt', 'desc')
->select()
->toArray();
$total = Db::name('analytics_events')
->where('createdAt', '>=', $since)
->count();
return success([
'days' => $days,
'total' => (int) $total,
'list' => $list,
'tableMissing' => false,
]);
} catch (\Throwable $e) {
return success([
'days' => $days,
'total' => 0,
'list' => [],
'tableMissing' => true,
]);
}
}
/**
* GET /api/v1/superadmin/analytics/events?days=7&page=1&pageSize=50
*/
public function events()
{
$days = min(90, max(1, (int) Request::param('days', 7)));
$since = date('Y-m-d H:i:s', time() - $days * 86400);
$page = max(1, (int) Request::param('page', 1));
$pageSize = min(100, max(10, (int) Request::param('pageSize', 50)));
try {
$total = (int) Db::name('analytics_events')
->where('createdAt', '>=', $since)
->count();
$offset = ($page - 1) * $pageSize;
$rows = Db::name('analytics_events')
->where('createdAt', '>=', $since)
->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);
return success([
'list' => $rows,
'total' => $total,
'page' => $page,
'pageSize' => $pageSize,
'tableMissing' => false,
]);
} catch (\Throwable $e) {
return success([
'list' => [],
'total' => 0,
'page' => $page,
'pageSize' => $pageSize,
'tableMissing' => true,
]);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,393 @@
<?php
namespace app\controller\superadmin;
use app\BaseController;
use think\facade\Db;
use think\facade\Request;
/**
* 将「平台侧」无企业归属的订单/测试/用户画像归并到指定企业,供企业后台与概览统计一致展示。
* 仅超级管理员可调用;默认 dryRun 预览,正式执行需 confirm=true。
*/
class DataMigration extends BaseController
{
/**
* POST /api/v1/superadmin/data-migration/attach-orphan-orders
*
* Body JSON:
* - targetEnterpriseId (int, 必填) 目标企业 ID
* - dryRun (bool, 默认 true) true 只统计不写入
* - confirm (bool, 默认 false) 与 dryRun=false 同时为真时才写入
* - orderIds (int[], 可选) 仅处理这些订单 id仍须满足当前无企业归属
* - userIds (int[], 可选) 仅处理这些小程序用户 id 名下的无归属订单
* - syncPersonalTestResults (bool, 默认 true) 是否把同用户下 enterpriseId 为空的 personal 测试记录一并标到目标企业
* - syncWechatUsers (bool, 默认 true) 是否将 wechat_users.enterpriseId 为空的用户标到目标企业
* - clonePersonalProfile (bool, 默认 true) 若无 (userId,enterprise,enterprise) 画像行,则从 personal 行复制一条 enterprise 画像(便于「用户运营」列表出现)
*/
public function attachOrphanOrders()
{
$actor = $this->request->user ?? null;
if (!$actor || ($actor['role'] ?? '') !== 'superadmin') {
return error('仅超级管理员可操作', 403);
}
$body = Request::post();
if (!is_array($body)) {
$body = [];
}
$targetEnterpriseId = (int) ($body['targetEnterpriseId'] ?? 0);
if ($targetEnterpriseId <= 0) {
return error('targetEnterpriseId 无效', 400);
}
$ent = Db::name('enterprises')->where('id', $targetEnterpriseId)->find();
if (!$ent) {
return error('目标企业不存在', 404);
}
$dryRun = array_key_exists('dryRun', $body) ? (bool) $body['dryRun'] : true;
$confirm = !empty($body['confirm']);
$syncPersonalTestResults = array_key_exists('syncPersonalTestResults', $body) ? (bool) $body['syncPersonalTestResults'] : true;
$syncWechatUsers = array_key_exists('syncWechatUsers', $body) ? (bool) $body['syncWechatUsers'] : true;
$clonePersonalProfile = array_key_exists('clonePersonalProfile', $body) ? (bool) $body['clonePersonalProfile'] : true;
$orderIdsFilter = $this->normalizeIdList($body['orderIds'] ?? null);
$userIdsFilter = $this->normalizeIdList($body['userIds'] ?? null);
$orderQuery = Db::name('orders')->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
});
if (!empty($orderIdsFilter)) {
$orderQuery->whereIn('id', $orderIdsFilter);
}
if (!empty($userIdsFilter)) {
$orderQuery->whereIn('userId', $userIdsFilter);
}
$orderRows = $orderQuery->field('id,userId,orderNo,enterpriseId,status,amount')->select()->toArray();
$affectedOrderIds = array_values(array_unique(array_filter(array_column($orderRows, 'id'))));
$userIdsFromOrders = array_values(array_unique(array_filter(array_column($orderRows, 'userId'))));
$testByOrderCount = 0;
if (!empty($affectedOrderIds)) {
$testByOrderCount = (int) Db::name('test_results')
->whereIn('orderId', $affectedOrderIds)
->count();
}
$personalTestExtraCount = 0;
if ($syncPersonalTestResults && !empty($userIdsFromOrders)) {
$personalTestExtraCount = (int) Db::name('test_results')
->whereIn('userId', $userIdsFromOrders)
->where('testScope', 'personal')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
})
->count();
}
$wechatPatchCount = 0;
if ($syncWechatUsers && !empty($userIdsFromOrders)) {
$wechatPatchCount = (int) Db::name('wechat_users')
->whereIn('id', $userIdsFromOrders)
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
})
->count();
}
$profileCloneCount = 0;
if ($clonePersonalProfile && !empty($userIdsFromOrders)) {
foreach ($userIdsFromOrders as $uid) {
$hasEnt = Db::name('user_profile')
->where('userId', $uid)
->where('userType', 'enterprise')
->where('enterpriseId', $targetEnterpriseId)
->find();
if (!$hasEnt) {
$profileCloneCount++;
}
}
}
$preview = [
'targetEnterpriseId' => $targetEnterpriseId,
'enterpriseName' => $ent['name'] ?? '',
'ordersMatched' => count($orderRows),
'orderIds' => $affectedOrderIds,
'distinctUserIds' => $userIdsFromOrders,
'testResultsByOrder' => $testByOrderCount,
'testResultsPersonalExtra' => $personalTestExtraCount,
'wechatUsersToPatch' => $wechatPatchCount,
'userProfilesToClone' => $profileCloneCount,
'dryRun' => $dryRun,
];
if ($dryRun || !$confirm) {
$preview['hint'] = $dryRun
? '当前为预览dryRun=true。若要执行写入请传 dryRun=false 且 confirm=true。'
: '未执行写入:请同时传 dryRun=false 与 confirm=true。';
return success($preview);
}
$now = time();
Db::startTrans();
try {
if (!empty($affectedOrderIds)) {
Db::name('orders')
->whereIn('id', $affectedOrderIds)
->update([
'enterpriseId' => $targetEnterpriseId,
'updatedAt' => $now,
]);
Db::name('test_results')
->whereIn('orderId', $affectedOrderIds)
->update([
'enterpriseId' => $targetEnterpriseId,
'testScope' => 'enterprise',
'updatedAt' => $now,
]);
}
if ($syncPersonalTestResults && !empty($userIdsFromOrders)) {
Db::name('test_results')
->whereIn('userId', $userIdsFromOrders)
->where('testScope', 'personal')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
})
->update([
'enterpriseId' => $targetEnterpriseId,
'testScope' => 'enterprise',
'updatedAt' => $now,
]);
}
if ($syncWechatUsers && !empty($userIdsFromOrders)) {
Db::name('wechat_users')
->whereIn('id', $userIdsFromOrders)
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
})
->update([
'enterpriseId' => $targetEnterpriseId,
]);
}
if ($clonePersonalProfile && !empty($userIdsFromOrders)) {
foreach ($userIdsFromOrders as $uid) {
$this->ensureEnterpriseProfileFromPersonal((int) $uid, $targetEnterpriseId, $now);
}
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return error('迁移失败:' . $e->getMessage(), 500);
}
$preview['executed'] = true;
$preview['hint'] = '已写入。企业管理员刷新「订单运营 / 概览 / 用户运营」即可看到归属数据。超管仍可见全平台订单。';
return success($preview, '迁移完成');
}
/**
* @param mixed $raw
* @return int[]
*/
private function normalizeIdList($raw): array
{
if (!is_array($raw) || $raw === []) {
return [];
}
$out = [];
foreach ($raw as $v) {
$n = (int) $v;
if ($n > 0) {
$out[] = $n;
}
}
return array_values(array_unique($out));
}
private function ensureEnterpriseProfileFromPersonal(int $userId, int $enterpriseId, int $now): void
{
if ($userId <= 0 || $enterpriseId <= 0) {
return;
}
$exists = Db::name('user_profile')
->where('userId', $userId)
->where('userType', 'enterprise')
->where('enterpriseId', $enterpriseId)
->find();
if ($exists) {
return;
}
$personal = Db::name('user_profile')
->where('userId', $userId)
->where('userType', 'personal')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
})
->order('id', 'desc')
->find();
$base = [
'userId' => $userId,
'userType' => 'enterprise',
'enterpriseId' => $enterpriseId,
'testsTotal' => 0,
'testsMbti' => 0,
'testsDisc' => 0,
'testsPdp' => 0,
'testsFace' => 0,
'ordersTotal' => 0,
'paidOrders' => 0,
'totalPaidAmount' => 0,
'lastTestResultId' => null,
'lastTestType' => null,
'lastTestAt' => null,
'lastMbtiResultId' => null,
'lastDiscResultId' => null,
'lastPdpResultId' => null,
'lastFaceResultId' => null,
'createdAt' => $now,
'updatedAt' => $now,
];
if ($personal) {
$copyFields = [
'testsTotal', 'testsMbti', 'testsDisc', 'testsPdp', 'testsFace',
'ordersTotal', 'paidOrders', 'totalPaidAmount',
'lastTestResultId', 'lastTestType', 'lastTestAt',
'lastMbtiResultId', 'lastDiscResultId', 'lastPdpResultId', 'lastFaceResultId',
];
foreach ($copyFields as $f) {
if (array_key_exists($f, $personal) && $personal[$f] !== null) {
$base[$f] = $personal[$f];
}
}
}
Db::name('user_profile')->insert($base);
}
/**
* 将全平台「无 enterpriseId」的 test_results 与 wechat_users 归属到存客宝(或指定企业)
* POST /api/v1/superadmin/data-migration/attach-orphans-to-cunkbao
*
* Body: targetEnterpriseId (可选)、dryRun (默认 true)、confirm、clonePersonalProfile (默认 true)
*/
public function attachOrphansToCunkbao()
{
$actor = $this->request->user ?? null;
if (!$actor || ($actor['role'] ?? '') !== 'superadmin') {
return error('仅超级管理员可操作', 403);
}
$body = Request::post();
if (!is_array($body)) {
$body = [];
}
$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) {
$row = Db::name('enterprises')->where('name', 'like', '%存客宝%')->order('id', 'asc')->find();
if (!$row) {
return error('未找到名称包含「存客宝」的企业,请先在企业管理中创建或传入 targetEnterpriseId', 404);
}
$targetEnterpriseId = (int) $row['id'];
}
$ent = Db::name('enterprises')->where('id', $targetEnterpriseId)->find();
if (!$ent) {
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);
})
->column('id');
$distinctUserIds = array_values(array_unique(array_filter(array_merge($userIdsFromTests, $userIdsFromWechat))));
$preview = [
'targetEnterpriseId' => $targetEnterpriseId,
'enterpriseName' => $ent['name'] ?? '',
'testResultsRows' => $testAffected,
'wechatUsersRows' => $wechatAffected,
'distinctUserIds' => $distinctUserIds,
'dryRun' => $dryRun,
];
if ($dryRun || !$confirm) {
$preview['hint'] = $dryRun
? '当前为预览。写入请传 dryRun=false 且 confirm=true。'
: '未写入:请同时传 dryRun=false 与 confirm=true。';
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,
]);
Db::name('wechat_users')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
})
->update([
'enterpriseId' => $targetEnterpriseId,
]);
if ($clonePersonalProfile && !empty($distinctUserIds)) {
foreach ($distinctUserIds as $uid) {
$this->ensureEnterpriseProfileFromPersonal((int) $uid, $targetEnterpriseId, $now);
}
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return error('归并失败:' . $e->getMessage(), 500);
}
$preview['executed'] = true;
$preview['hint'] = '已写入。无企业归属的测试与用户已归属到目标企业。';
return success($preview, '归并完成');
}
}

View File

@@ -1,437 +1,551 @@
<?php
namespace app\controller\superadmin;
use app\BaseController;
use app\model\Enterprise as EnterpriseModel;
use think\facade\Request;
use think\facade\Db;
/**
* 企业管理控制器(超管专用)
*/
class Enterprise extends BaseController
{
/**
* 获取企业列表
* @return \think\response\Json
*/
public function index()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$page = Request::param('page', 1);
$pageSize = Request::param('pageSize', 20);
$keyword = Request::param('keyword', '');
$status = Request::param('status', '');
$where = [];
// 搜索条件
if ($keyword) {
$where[] = ['name|contactName|contactPhone|code', 'like', '%' . $keyword . '%'];
}
// 状态筛选
if ($status !== '') {
$where['status'] = $status;
}
// 查询企业列表
$list = EnterpriseModel::where($where)
->order('createdAt', 'desc')
->page($page, $pageSize)
->select()
->toArray();
// 统计每个企业的用户数和测试用量
foreach ($list as &$item) {
// 统计用户数(只统计未删除的用户)
$item['userCount'] = Db::name('users')
->where('enterpriseId', $item['id'])
->where('deletedAt', null)
->count();
// 统计测试用量(测试结果数)- 通过企业下的用户ID统计只统计未删除的用户
$userIds = Db::name('users')
->where('enterpriseId', $item['id'])
->where('deletedAt', null)
->column('id');
if (!empty($userIds)) {
$item['testUsage'] = Db::name('test_results')
->where('userId', 'in', $userIds)
->count();
} else {
$item['testUsage'] = 0;
}
}
$total = EnterpriseModel::where($where)->count();
// 统计活跃企业数status为operating
$activeCount = EnterpriseModel::where('status', 'operating')->count();
return success([
'list' => $list,
'total' => $total,
'page' => $page,
'pageSize' => $pageSize,
'activeCount' => $activeCount
]);
}
/**
* 获取企业详情
* @param int $id
* @return \think\response\Json
*/
public function detail($id = null)
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
// 如果路由参数没有传递,尝试从请求参数获取
if (empty($id)) {
$id = Request::param('id');
}
if (empty($id)) {
return error('企业ID不能为空', 400);
}
$enterprise = EnterpriseModel::find($id);
if (!$enterprise) {
return error('企业不存在', 404);
}
$data = $enterprise->toArray();
// 获取企业下的所有用户ID只统计未删除的用户
$userIds = Db::name('users')
->where('enterpriseId', $id)
->where('deletedAt', null)
->column('id');
// 统计用户数
$data['userCount'] = count($userIds);
// 获取管理员账号列表(企业管理员角色,只获取未删除的)
$adminAccounts = Db::name('users')
->where('enterpriseId', $id)
->where('role', 'enterprise_admin')
->where('deletedAt', null)
->field('id,username,email,phone,role,status,createdAt,lastLoginTime')
->select()
->toArray();
$data['adminAccounts'] = $adminAccounts;
// 获取用户列表(排除管理员,只获取未删除的)
$users = Db::name('users')
->where('enterpriseId', $id)
->where('role', '<>', 'enterprise_admin')
->where('deletedAt', null)
->field('id,username,email,phone,mbtiType,status,createdAt')
->limit(50) // 限制返回数量
->select()
->toArray();
$data['users'] = $users;
// 获取测试结果列表
$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,u.username')
->order('tr.createdAt', 'desc')
->limit(50) // 限制返回数量
->select()
->toArray();
}
$data['testResults'] = $testResults;
// 统计测试用量
if (!empty($userIds)) {
$data['testUsage'] = Db::name('test_results')
->where('userId', 'in', $userIds)
->count();
} else {
$data['testUsage'] = 0;
}
return success($data);
}
/**
* 创建企业
* @return \think\response\Json
*/
public function create()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$data = Request::post();
// 验证必填字段
if (empty($data['name'])) {
return error('企业名称不能为空', 400);
}
// 验证管理员账号信息
if (empty($data['adminUsername'])) {
return error('管理员用户名不能为空', 400);
}
if (empty($data['adminPassword'])) {
return error('管理员密码不能为空', 400);
}
if (strlen($data['adminPassword']) < 6) {
return error('密码长度至少6位', 400);
}
// 检查企业代码是否重复(如果提供了代码)
if (!empty($data['code'])) {
if (EnterpriseModel::where('code', $data['code'])->find()) {
return error('企业代码已存在', 400);
}
}
// 检查管理员用户名是否已存在
if (Db::name('users')->where('username', $data['adminUsername'])->find()) {
return error('管理员用户名已存在', 400);
}
// 状态映射前端使用operating/trial/disabled
$status = $data['status'] ?? 'operating';
if (!in_array($status, ['operating', 'trial', 'disabled'])) {
$status = 'operating';
}
// 验证试用到期时间
if ($status === 'trial') {
if (empty($data['trialExpireAt'])) {
return error('选择试用状态时,必须设置试用到期时间', 400);
}
// 确保到期时间大于当前时间
if ($data['trialExpireAt'] <= time()) {
return error('试用到期时间必须大于当前时间', 400);
}
}
// 开启事务
Db::startTrans();
try {
// 创建企业
$enterprise = new EnterpriseModel();
$enterprise->name = $data['name'];
$enterprise->code = $data['code'] ?? null;
$enterprise->contactName = $data['contactName'] ?? null;
$enterprise->contactPhone = $data['contactPhone'] ?? null;
$enterprise->contactEmail = $data['contactEmail'] ?? null;
$enterprise->balance = $data['balance'] ?? 0.00;
$enterprise->status = $status;
$enterprise->trialExpireAt = ($status === 'trial' && isset($data['trialExpireAt'])) ? $data['trialExpireAt'] : null;
$enterprise->save();
$enterpriseId = $enterprise->id;
// 创建企业管理员账号
$adminUser = [
'username' => $data['adminUsername'],
'password' => password_hash($data['adminPassword'], PASSWORD_DEFAULT),
'email' => $data['contactEmail'] ?? null,
'phone' => $data['contactPhone'] ?? null,
'role' => 'enterprise_admin',
'enterpriseId' => $enterpriseId,
'status' => 1,
'createdAt' => time(),
'updatedAt' => time()
];
Db::name('users')->insert($adminUser);
// 提交事务
Db::commit();
$enterpriseData = $enterprise->toArray();
$enterpriseData['userCount'] = 1; // 刚创建的企业管理员
$enterpriseData['testUsage'] = 0;
return success($enterpriseData, '企业创建成功,管理员账号已创建');
} catch (\Exception $e) {
// 回滚事务
Db::rollback();
return error('创建失败:' . $e->getMessage(), 500);
}
}
/**
* 更新企业
* @param int $id
* @return \think\response\Json
*/
public function update($id)
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$enterprise = EnterpriseModel::find($id);
if (!$enterprise) {
return error('企业不存在', 404);
}
$data = Request::put();
$oldBalance = (float) ($enterprise->balance ?? 0);
// 如果更新企业代码,检查是否重复
if (isset($data['code']) && $data['code'] != $enterprise->code) {
if (EnterpriseModel::where('code', $data['code'])->find()) {
return error('企业代码已存在', 400);
}
}
// 状态验证
if (isset($data['status']) && !in_array($data['status'], ['operating', 'trial', 'disabled'])) {
return error('状态值无效', 400);
}
// 验证试用到期时间
$status = $data['status'] ?? $enterprise->status;
if ($status === 'trial') {
if (empty($data['trialExpireAt'])) {
return error('选择试用状态时,必须设置试用到期时间', 400);
}
// 确保到期时间大于当前时间
if ($data['trialExpireAt'] <= time()) {
return error('试用到期时间必须大于当前时间', 400);
}
$enterprise->trialExpireAt = $data['trialExpireAt'];
} else {
// 如果不是试用状态,清空到期时间
$enterprise->trialExpireAt = null;
}
$enterprise->save($data);
$newBalance = (float) ($enterprise->balance ?? 0);
if ($newBalance > $oldBalance) {
try {
\app\controller\api\Distribution::unfreezeCommissions((int) $id);
} catch (\Throwable $e) {
// 余额已更新成功,解冻失败不阻断主流程
}
}
$enterpriseData = $enterprise->toArray();
// 统计用户数和测试用量(只统计未删除的用户)
$enterpriseData['userCount'] = Db::name('users')
->where('enterpriseId', $id)
->where('deletedAt', null)
->count();
$userIds = Db::name('users')
->where('enterpriseId', $id)
->where('deletedAt', null)
->column('id');
if (!empty($userIds)) {
$enterpriseData['testUsage'] = Db::name('test_results')
->where('userId', 'in', $userIds)
->count();
} else {
$enterpriseData['testUsage'] = 0;
}
return success($enterpriseData, '更新成功');
}
/**
* 删除企业(软删除)
* @param int $id
* @return \think\response\Json
*/
public function delete($id)
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$enterprise = EnterpriseModel::find($id);
if (!$enterprise) {
return error('企业不存在', 404);
}
// 检查是否已删除
if ($enterprise->deletedAt) {
return error('企业已被删除', 400);
}
// 检查是否有用户关联(只检查未删除的用户)
$userCount = Db::name('users')
->where('enterpriseId', $id)
->where('deletedAt', null)
->count();
if ($userCount > 0) {
return error('该企业下还有用户,无法删除', 400);
}
// 软删除(设置 deletedAt 时间戳)
$enterprise->delete();
return success(null, '删除成功');
}
/**
* 启用/禁用企业
* @param int $id
* @return \think\response\Json
*/
public function toggleStatus($id)
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$enterprise = EnterpriseModel::find($id);
if (!$enterprise) {
return error('企业不存在', 404);
}
// 切换状态operating <-> disabled
if ($enterprise->status === 'operating') {
$enterprise->status = 'disabled';
} else {
$enterprise->status = 'operating';
}
$enterprise->save();
return success($enterprise, '操作成功');
}
}
<?php
namespace app\controller\superadmin;
use app\BaseController;
use app\model\Enterprise as EnterpriseModel;
use app\model\SystemConfig as SystemConfigModel;
use think\facade\Request;
use think\facade\Db;
/**
* 企业管理控制器(超管专用)
*/
class Enterprise extends BaseController
{
/**
* 获取企业列表
* @return \think\response\Json
*/
public function index()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$page = Request::param('page', 1);
$pageSize = Request::param('pageSize', 20);
$keyword = Request::param('keyword', '');
$status = Request::param('status', '');
$where = [];
// 搜索条件
if ($keyword) {
$where[] = ['name|contactName|contactPhone|code', 'like', '%' . $keyword . '%'];
}
// 状态筛选
if ($status !== '') {
$where['status'] = $status;
}
// 查询企业列表
$list = EnterpriseModel::where($where)
->order('createdAt', 'desc')
->page($page, $pageSize)
->select()
->toArray();
// 统计每个企业的用户数和测试用量
foreach ($list as &$item) {
// 统计用户数(只统计未删除的用户)
$item['userCount'] = Db::name('users')
->where('enterpriseId', $item['id'])
->where('deletedAt', null)
->count();
// 统计测试用量(测试结果数)- 通过企业下的用户ID统计只统计未删除的用户
$userIds = Db::name('users')
->where('enterpriseId', $item['id'])
->where('deletedAt', null)
->column('id');
if (!empty($userIds)) {
$item['testUsage'] = Db::name('test_results')
->where('userId', 'in', $userIds)
->count();
} else {
$item['testUsage'] = 0;
}
}
$total = EnterpriseModel::where($where)->count();
// 统计活跃企业数status为operating
$activeCount = EnterpriseModel::where('status', 'operating')->count();
return success([
'list' => $list,
'total' => $total,
'page' => $page,
'pageSize' => $pageSize,
'activeCount' => $activeCount
]);
}
/**
* 获取企业详情
* @param int $id
* @return \think\response\Json
*/
public function detail($id = null)
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
// 如果路由参数没有传递,尝试从请求参数获取
if (empty($id)) {
$id = Request::param('id');
}
if (empty($id)) {
return error('企业ID不能为空', 400);
}
$enterprise = EnterpriseModel::find($id);
if (!$enterprise) {
return error('企业不存在', 404);
}
$data = $enterprise->toArray();
// 获取企业下的所有用户ID只统计未删除的用户
$userIds = Db::name('users')
->where('enterpriseId', $id)
->where('deletedAt', null)
->column('id');
// 统计用户数
$data['userCount'] = count($userIds);
// 获取管理员账号列表(企业管理员角色,只获取未删除的)
$adminAccounts = Db::name('users')
->where('enterpriseId', $id)
->where('role', 'enterprise_admin')
->where('deletedAt', null)
->field('id,username,email,phone,role,status,createdAt,lastLoginTime')
->select()
->toArray();
$data['adminAccounts'] = $adminAccounts;
// 获取用户列表(排除管理员,只获取未删除的)
$users = Db::name('users')
->where('enterpriseId', $id)
->where('role', '<>', 'enterprise_admin')
->where('deletedAt', null)
->field('id,username,email,phone,mbtiType,status,createdAt')
->limit(50) // 限制返回数量
->select()
->toArray();
$data['users'] = $users;
// 获取测试结果列表
$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,u.username')
->order('tr.createdAt', 'desc')
->limit(50) // 限制返回数量
->select()
->toArray();
}
$data['testResults'] = $testResults;
// 统计测试用量
if (!empty($userIds)) {
$data['testUsage'] = Db::name('test_results')
->where('userId', 'in', $userIds)
->count();
} else {
$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 = [];
}
$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'] = [];
}
}
// 该企业下、带 enterpriseId 的小程序测试记录
$data['miniprogramTestResults'] = [];
try {
$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')
->order('tr.createdAt', 'desc')
->limit(60)
->select()
->toArray();
} 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),
];
$data['recentOrders'] = Db::name('orders')
->where('enterpriseId', $id)
->order('createdAt', 'desc')
->limit(25)
->field('id,orderNo,status,amount,productType,userId,createdAt')
->select()
->toArray();
} catch (\Throwable $e) {
$data['orderStats'] = [
'totalCount' => 0,
'paidCount' => 0,
'paidAmountFen' => 0,
];
$data['recentOrders'] = [];
}
// 埋点:近 30 天,归属该企业的小程序用户
$data['analyticsStats'] = [
'eventTotal' => 0,
'pageViewCount' => 0,
'byEvent' => [],
'hint' => null,
'windowDays' => 30,
];
if (empty($wechatIds)) {
$data['analyticsStats']['hint'] = '暂无 enterpriseId 归属该企业的微信小程序用户,无法按企业聚合埋点';
} else {
try {
$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)
->count();
$data['analyticsStats']['pageViewCount'] = (int) Db::name('analytics_events')
->where('userId', 'in', $wechatIds)
->where('createdAt', '>=', $since)
->where('eventName', 'page_view')
->count();
$byEvent = Db::name('analytics_events')
->where('userId', 'in', $wechatIds)
->where('createdAt', '>=', $since)
->field('eventName, COUNT(*) AS cnt')
->group('eventName')
->order('cnt', 'desc')
->limit(20)
->select()
->toArray();
$data['analyticsStats']['byEvent'] = $byEvent ?: [];
} catch (\Throwable $e) {
$data['analyticsStats']['hint'] = '埋点表未就绪或查询失败(请确认已建 analytics_events 表)';
}
}
// 全局通知策略(超管在系统设置中配置,影响余额类提醒等)
$data['notificationPolicy'] = null;
try {
$nc = SystemConfigModel::where('key', 'notification')->where('enterprise_id', 0)->find();
if ($nc) {
$val = $nc->getAttr('value');
$data['notificationPolicy'] = is_array($val) ? $val : null;
}
} catch (\Throwable $e) {
$data['notificationPolicy'] = null;
}
return success($data);
}
/**
* 创建企业
* @return \think\response\Json
*/
public function create()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$data = Request::post();
// 验证必填字段
if (empty($data['name'])) {
return error('企业名称不能为空', 400);
}
// 验证管理员账号信息
if (empty($data['adminUsername'])) {
return error('管理员用户名不能为空', 400);
}
if (empty($data['adminPassword'])) {
return error('管理员密码不能为空', 400);
}
if (strlen($data['adminPassword']) < 6) {
return error('密码长度至少6位', 400);
}
// 检查企业代码是否重复(如果提供了代码)
if (!empty($data['code'])) {
if (EnterpriseModel::where('code', $data['code'])->find()) {
return error('企业代码已存在', 400);
}
}
// 检查管理员用户名是否已存在
if (Db::name('users')->where('username', $data['adminUsername'])->find()) {
return error('管理员用户名已存在', 400);
}
// 状态映射前端使用operating/trial/disabled
$status = $data['status'] ?? 'operating';
if (!in_array($status, ['operating', 'trial', 'disabled'])) {
$status = 'operating';
}
// 验证试用到期时间
if ($status === 'trial') {
if (empty($data['trialExpireAt'])) {
return error('选择试用状态时,必须设置试用到期时间', 400);
}
// 确保到期时间大于当前时间
if ($data['trialExpireAt'] <= time()) {
return error('试用到期时间必须大于当前时间', 400);
}
}
// 开启事务
Db::startTrans();
try {
// 创建企业
$enterprise = new EnterpriseModel();
$enterprise->name = $data['name'];
$enterprise->code = $data['code'] ?? null;
$enterprise->contactName = $data['contactName'] ?? null;
$enterprise->contactPhone = $data['contactPhone'] ?? null;
$enterprise->contactEmail = $data['contactEmail'] ?? null;
$enterprise->balance = $data['balance'] ?? 0.00;
$enterprise->status = $status;
$enterprise->trialExpireAt = ($status === 'trial' && isset($data['trialExpireAt'])) ? $data['trialExpireAt'] : null;
$enterprise->save();
$enterpriseId = $enterprise->id;
// 创建企业管理员账号
$adminUser = [
'username' => $data['adminUsername'],
'password' => password_hash($data['adminPassword'], PASSWORD_DEFAULT),
'email' => $data['contactEmail'] ?? null,
'phone' => $data['contactPhone'] ?? null,
'role' => 'enterprise_admin',
'enterpriseId' => $enterpriseId,
'status' => 1,
'createdAt' => time(),
'updatedAt' => time()
];
Db::name('users')->insert($adminUser);
// 提交事务
Db::commit();
$enterpriseData = $enterprise->toArray();
$enterpriseData['userCount'] = 1; // 刚创建的企业管理员
$enterpriseData['testUsage'] = 0;
return success($enterpriseData, '企业创建成功,管理员账号已创建');
} catch (\Exception $e) {
// 回滚事务
Db::rollback();
return error('创建失败:' . $e->getMessage(), 500);
}
}
/**
* 更新企业
* @param int $id
* @return \think\response\Json
*/
public function update($id)
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$enterprise = EnterpriseModel::find($id);
if (!$enterprise) {
return error('企业不存在', 404);
}
$data = Request::put();
$oldBalance = (float) ($enterprise->balance ?? 0);
// 如果更新企业代码,检查是否重复
if (isset($data['code']) && $data['code'] != $enterprise->code) {
if (EnterpriseModel::where('code', $data['code'])->find()) {
return error('企业代码已存在', 400);
}
}
// 状态验证
if (isset($data['status']) && !in_array($data['status'], ['operating', 'trial', 'disabled'])) {
return error('状态值无效', 400);
}
// 验证试用到期时间
$status = $data['status'] ?? $enterprise->status;
if ($status === 'trial') {
if (empty($data['trialExpireAt'])) {
return error('选择试用状态时,必须设置试用到期时间', 400);
}
// 确保到期时间大于当前时间
if ($data['trialExpireAt'] <= time()) {
return error('试用到期时间必须大于当前时间', 400);
}
$enterprise->trialExpireAt = $data['trialExpireAt'];
} else {
// 如果不是试用状态,清空到期时间
$enterprise->trialExpireAt = null;
}
$enterprise->save($data);
$newBalance = (float) ($enterprise->balance ?? 0);
if ($newBalance > $oldBalance) {
try {
\app\controller\api\Distribution::unfreezeCommissions((int) $id);
} catch (\Throwable $e) {
// 余额已更新成功,解冻失败不阻断主流程
}
}
$enterpriseData = $enterprise->toArray();
// 统计用户数和测试用量(只统计未删除的用户)
$enterpriseData['userCount'] = Db::name('users')
->where('enterpriseId', $id)
->where('deletedAt', null)
->count();
$userIds = Db::name('users')
->where('enterpriseId', $id)
->where('deletedAt', null)
->column('id');
if (!empty($userIds)) {
$enterpriseData['testUsage'] = Db::name('test_results')
->where('userId', 'in', $userIds)
->count();
} else {
$enterpriseData['testUsage'] = 0;
}
return success($enterpriseData, '更新成功');
}
/**
* 删除企业(软删除)
* @param int $id
* @return \think\response\Json
*/
public function delete($id)
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$enterprise = EnterpriseModel::find($id);
if (!$enterprise) {
return error('企业不存在', 404);
}
// 检查是否已删除
if ($enterprise->deletedAt) {
return error('企业已被删除', 400);
}
// 检查是否有用户关联(只检查未删除的用户)
$userCount = Db::name('users')
->where('enterpriseId', $id)
->where('deletedAt', null)
->count();
if ($userCount > 0) {
return error('该企业下还有用户,无法删除', 400);
}
// 软删除(设置 deletedAt 时间戳)
$enterprise->delete();
return success(null, '删除成功');
}
/**
* 启用/禁用企业
* @param int $id
* @return \think\response\Json
*/
public function toggleStatus($id)
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$enterprise = EnterpriseModel::find($id);
if (!$enterprise) {
return error('企业不存在', 404);
}
// 切换状态operating <-> disabled
if ($enterprise->status === 'operating') {
$enterprise->status = 'disabled';
} else {
$enterprise->status = 'operating';
}
$enterprise->save();
return success($enterprise, '操作成功');
}
}

View File

@@ -2,6 +2,7 @@
namespace app\controller\superadmin;
use app\BaseController;
use app\common\service\FeishuLeadWebhookService;
use app\model\SystemConfig as SystemConfigModel;
use app\model\User as UserModel;
use app\model\Enterprise as EnterpriseModel;
@@ -276,6 +277,69 @@ class Settings extends BaseController
}
}
/**
* 飞书获客 Webhook与 admin 共用配置)
*/
public function getFeishuLeadConfig()
{
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$cfg = FeishuLeadWebhookService::getConfig();
return success([
'enabled' => !empty($cfg['enabled']),
'webhookUrl' => (string) ($cfg['webhookUrl'] ?? ''),
'contactPerson' => (string) ($cfg['contactPerson'] ?? '运营'),
]);
}
public function updateFeishuLeadConfig()
{
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$raw = $this->request->getContent();
$input = $raw ? json_decode($raw, true) : [];
if (!is_array($input)) {
$input = [];
}
$enabled = !empty($input['enabled']);
$webhookUrl = trim((string) ($input['webhookUrl'] ?? ''));
$contactPerson = trim((string) ($input['contactPerson'] ?? '运营'));
if ($contactPerson === '') {
$contactPerson = '运营';
}
if ($enabled && $webhookUrl !== '' && stripos($webhookUrl, 'http') !== 0) {
return error('Webhook 须以 http(s) 开头', 400);
}
$json = json_encode([
'enabled' => $enabled,
'webhookUrl' => $webhookUrl,
'contactPerson' => $contactPerson,
], JSON_UNESCAPED_UNICODE);
$now = time();
$key = FeishuLeadWebhookService::CONFIG_KEY;
$exists = Db::name('system_config')->where('key', $key)->where('enterprise_id', 0)->find();
if ($exists) {
Db::name('system_config')
->where('key', $key)
->where('enterprise_id', 0)
->update(['value' => $json, 'updatedAt' => $now]);
} else {
Db::name('system_config')->insert([
'key' => $key,
'enterprise_id' => 0,
'value' => $json,
'description' => '飞书获客 Webhook',
'createdAt' => $now,
'updatedAt' => $now,
]);
}
return success(null, '已保存');
}
/**
* 更新超管账户信息
* @return \think\response\Json