feat: 小程序与管理端迭代(神仙AI、了解自己CRM、AI测试入口、报表与分润等)
Made-with: Cursor
This commit is contained in:
29
api/app/controller/superadmin/AiMonitor.php
Normal file
29
api/app/controller/superadmin/AiMonitor.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
namespace app\controller\superadmin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\AiBalanceAlertService;
|
||||
|
||||
/**
|
||||
* 超管 · 神仙 AI 监控面板
|
||||
*
|
||||
* - POST /api/v1/superadmin/ai/balance-check 手动触发余额预警扫描
|
||||
* 可直接由宝塔 cron 每 12 小时调用一次
|
||||
*/
|
||||
class AiMonitor extends BaseController
|
||||
{
|
||||
public function balanceCheck()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$r = AiBalanceAlertService::scanAndAlert();
|
||||
return success($r, "扫描完成:推送 {$r['alerted']} 条,跳过 {$r['skipped']} 条(当日去重)");
|
||||
}
|
||||
|
||||
private function ensureSuperadmin()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
|
||||
abort(403, '无权限访问');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
namespace app\controller\superadmin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\AnalyticsEventLabels;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
@@ -11,30 +12,42 @@ use think\facade\Request;
|
||||
class Analytics extends BaseController
|
||||
{
|
||||
/**
|
||||
* GET /api/v1/superadmin/analytics/summary?days=7
|
||||
* GET /api/v1/superadmin/analytics/summary?days=7&eventName=可选
|
||||
* 返回字段附加 eventNameCn(中文名)
|
||||
*/
|
||||
public function summary()
|
||||
{
|
||||
$days = min(90, max(1, (int) Request::param('days', 7)));
|
||||
$since = date('Y-m-d H:i:s', time() - $days * 86400);
|
||||
$eventFilter = trim((string) Request::param('eventName', ''));
|
||||
|
||||
try {
|
||||
$list = Db::name('analytics_events')
|
||||
$q = Db::name('analytics_events')
|
||||
->field('eventName, COUNT(*) AS cnt')
|
||||
->where('createdAt', '>=', $since)
|
||||
->where('createdAt', '>=', $since);
|
||||
if ($eventFilter !== '') {
|
||||
$q->where('eventName', $eventFilter);
|
||||
}
|
||||
$list = $q
|
||||
->group('eventName')
|
||||
->order('cnt', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$total = Db::name('analytics_events')
|
||||
->where('createdAt', '>=', $since)
|
||||
->count();
|
||||
$list = AnalyticsEventLabels::withCn($list);
|
||||
|
||||
$cntQuery = Db::name('analytics_events')
|
||||
->where('createdAt', '>=', $since);
|
||||
if ($eventFilter !== '') {
|
||||
$cntQuery->where('eventName', $eventFilter);
|
||||
}
|
||||
$total = $cntQuery->count();
|
||||
|
||||
return success([
|
||||
'days' => $days,
|
||||
'total' => (int) $total,
|
||||
'list' => $list,
|
||||
'labels' => AnalyticsEventLabels::all(),
|
||||
'tableMissing' => false,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -42,13 +55,14 @@ class Analytics extends BaseController
|
||||
'days' => $days,
|
||||
'total' => 0,
|
||||
'list' => [],
|
||||
'labels' => AnalyticsEventLabels::all(),
|
||||
'tableMissing' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/superadmin/analytics/events?days=7&page=1&pageSize=50
|
||||
* GET /api/v1/superadmin/analytics/events?days=7&page=1&pageSize=50&eventName=&userId=
|
||||
*/
|
||||
public function events()
|
||||
{
|
||||
@@ -56,14 +70,21 @@ class Analytics extends BaseController
|
||||
$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)));
|
||||
$eventFilter = trim((string) Request::param('eventName', ''));
|
||||
$userIdFilter = (int) Request::param('userId', 0);
|
||||
|
||||
try {
|
||||
$total = (int) Db::name('analytics_events')
|
||||
->where('createdAt', '>=', $since)
|
||||
->count();
|
||||
$baseQ = Db::name('analytics_events')->where('createdAt', '>=', $since);
|
||||
if ($eventFilter !== '') {
|
||||
$baseQ->where('eventName', $eventFilter);
|
||||
}
|
||||
if ($userIdFilter > 0) {
|
||||
$baseQ->where('userId', $userIdFilter);
|
||||
}
|
||||
|
||||
$total = (int) (clone $baseQ)->count();
|
||||
$offset = ($page - 1) * $pageSize;
|
||||
$rows = Db::name('analytics_events')
|
||||
->where('createdAt', '>=', $since)
|
||||
$rows = $baseQ
|
||||
->order('id', 'desc')
|
||||
->limit($offset, $pageSize)
|
||||
->select()
|
||||
@@ -80,6 +101,8 @@ class Analytics extends BaseController
|
||||
}
|
||||
unset($r);
|
||||
|
||||
$rows = AnalyticsEventLabels::withCn($rows);
|
||||
|
||||
return success([
|
||||
'list' => $rows,
|
||||
'total' => $total,
|
||||
@@ -97,4 +120,260 @@ class Analytics extends BaseController
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/superadmin/analytics/user-journey?userId=&days=30
|
||||
* 单个用户旅程:按时间倒序展示最近 200 条事件(中文名)
|
||||
*/
|
||||
public function userJourney()
|
||||
{
|
||||
$userId = (int) Request::param('userId', 0);
|
||||
if ($userId <= 0) {
|
||||
return error('userId 不能为空', 400);
|
||||
}
|
||||
$days = min(180, max(1, (int) Request::param('days', 30)));
|
||||
$since = date('Y-m-d H:i:s', time() - $days * 86400);
|
||||
|
||||
try {
|
||||
$rows = Db::name('analytics_events')
|
||||
->where('userId', $userId)
|
||||
->where('createdAt', '>=', $since)
|
||||
->order('id', 'desc')
|
||||
->limit(200)
|
||||
->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' => count($rows),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return success([
|
||||
'userId' => $userId,
|
||||
'days' => $days,
|
||||
'list' => [],
|
||||
'total' => 0,
|
||||
'tableMissing' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/superadmin/analytics/share-stats?days=30
|
||||
* 分享与邀请统计:
|
||||
* - 每位用户累计分享次数
|
||||
* - 每位用户邀请绑定人数(distribution_bindings)
|
||||
* - 每位用户累计产生分润(distribution_commissions)
|
||||
*/
|
||||
public function shareStats()
|
||||
{
|
||||
$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(10, (int) Request::param('pageSize', 30)));
|
||||
|
||||
$out = [];
|
||||
$total = 0;
|
||||
$tableMissing = false;
|
||||
|
||||
try {
|
||||
// 分享次数:share / tap_share_moment / tap_share_friend 都算
|
||||
$shareEvents = ['share', 'tap_share_moment', 'tap_share_friend'];
|
||||
$shareRows = Db::name('analytics_events')
|
||||
->field('userId, COUNT(*) AS shareCount')
|
||||
->whereIn('eventName', $shareEvents)
|
||||
->where('createdAt', '>=', $since)
|
||||
->where('userId', '>', 0)
|
||||
->group('userId')
|
||||
->order('shareCount', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($shareRows as $row) {
|
||||
$uid = (int) ($row['userId'] ?? 0);
|
||||
if ($uid <= 0) continue;
|
||||
$out[$uid] = [
|
||||
'userId' => $uid,
|
||||
'shareCount' => (int) ($row['shareCount'] ?? 0),
|
||||
'inviteBound' => 0,
|
||||
'totalCommissionFen' => 0,
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$tableMissing = true;
|
||||
}
|
||||
|
||||
// 邀请绑定人数(按 inviteeId 去重,只统计当前 active)
|
||||
try {
|
||||
$bindRows = Db::name('distribution_bindings')
|
||||
->field('inviterId AS userId, COUNT(DISTINCT inviteeId) AS cnt')
|
||||
->where('status', 'active')
|
||||
->group('inviterId')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($bindRows as $row) {
|
||||
$uid = (int) ($row['userId'] ?? 0);
|
||||
if ($uid <= 0) continue;
|
||||
if (!isset($out[$uid])) {
|
||||
$out[$uid] = [
|
||||
'userId' => $uid,
|
||||
'shareCount' => 0,
|
||||
'inviteBound' => 0,
|
||||
'totalCommissionFen' => 0,
|
||||
];
|
||||
}
|
||||
$out[$uid]['inviteBound'] = (int) ($row['cnt'] ?? 0);
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
// 分润金额(从 commission_records 聚合)
|
||||
try {
|
||||
$commRows = Db::name('commission_records')
|
||||
->field('inviterId AS userId, SUM(commissionFen) AS sumFen')
|
||||
->where('commissionFen', '>', 0)
|
||||
->group('inviterId')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($commRows as $row) {
|
||||
$uid = (int) ($row['userId'] ?? 0);
|
||||
if ($uid <= 0) continue;
|
||||
if (!isset($out[$uid])) {
|
||||
$out[$uid] = [
|
||||
'userId' => $uid,
|
||||
'shareCount' => 0,
|
||||
'inviteBound' => 0,
|
||||
'totalCommissionFen' => 0,
|
||||
];
|
||||
}
|
||||
$out[$uid]['totalCommissionFen'] = (int) ($row['sumFen'] ?? 0);
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
// 合并后按邀请人数、分润、分享次数排序
|
||||
$list = array_values($out);
|
||||
usort($list, function ($a, $b) {
|
||||
if ($a['totalCommissionFen'] !== $b['totalCommissionFen']) {
|
||||
return $b['totalCommissionFen'] <=> $a['totalCommissionFen'];
|
||||
}
|
||||
if ($a['inviteBound'] !== $b['inviteBound']) {
|
||||
return $b['inviteBound'] <=> $a['inviteBound'];
|
||||
}
|
||||
return $b['shareCount'] <=> $a['shareCount'];
|
||||
});
|
||||
|
||||
$total = count($list);
|
||||
$offset = ($page - 1) * $pageSize;
|
||||
$pageList = array_slice($list, $offset, $pageSize);
|
||||
|
||||
// 补用户资料
|
||||
$uids = array_column($pageList, 'userId');
|
||||
if (!empty($uids)) {
|
||||
try {
|
||||
$users = Db::name('wechat_users')
|
||||
->whereIn('id', $uids)
|
||||
->field('id, nickname, avatar, phone, enterpriseId, createdAt')
|
||||
->select()
|
||||
->toArray();
|
||||
$byId = [];
|
||||
foreach ($users as $u) { $byId[(int) $u['id']] = $u; }
|
||||
foreach ($pageList as &$row) {
|
||||
$u = $byId[$row['userId']] ?? null;
|
||||
$row['nickname'] = $u['nickname'] ?? '';
|
||||
$row['avatar'] = $u['avatar'] ?? '';
|
||||
$row['phone'] = $u['phone'] ?? '';
|
||||
$row['enterpriseId'] = $u['enterpriseId'] ?? null;
|
||||
$row['createdAt'] = $u['createdAt'] ?? null;
|
||||
}
|
||||
unset($row);
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
|
||||
return success([
|
||||
'days' => $days,
|
||||
'total' => $total,
|
||||
'list' => $pageList,
|
||||
'page' => $page,
|
||||
'pageSize' => $pageSize,
|
||||
'tableMissing' => $tableMissing,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/superadmin/analytics/share-funnel?days=14
|
||||
* 分享漏斗:
|
||||
* 1) 结果页访问(pages/result/* page_view)
|
||||
* 2) 分享动作(share / tap_share_moment / tap_share_friend)
|
||||
* 3) 好友登录(login_silent_success)
|
||||
* 4) 好友付费(pay_success_attribution 或 pay_success)
|
||||
* 5) 累计分润(distribution_commissions)
|
||||
*/
|
||||
public function shareFunnel()
|
||||
{
|
||||
$days = min(180, max(1, (int) Request::param('days', 14)));
|
||||
$since = date('Y-m-d H:i:s', time() - $days * 86400);
|
||||
|
||||
$out = [
|
||||
['stage' => '结果页访问', 'value' => 0],
|
||||
['stage' => '分享动作', 'value' => 0],
|
||||
['stage' => '好友登录', 'value' => 0],
|
||||
['stage' => '好友付费', 'value' => 0],
|
||||
['stage' => '累计分润(元)', 'value' => 0],
|
||||
];
|
||||
$tableMissing = false;
|
||||
|
||||
try {
|
||||
$out[0]['value'] = (int) Db::name('analytics_events')
|
||||
->where('eventName', 'page_view')
|
||||
->where('pagePath', 'like', 'pages/result/%')
|
||||
->where('createdAt', '>=', $since)
|
||||
->count();
|
||||
|
||||
$out[1]['value'] = (int) Db::name('analytics_events')
|
||||
->whereIn('eventName', ['share', 'tap_share_moment', 'tap_share_friend'])
|
||||
->where('createdAt', '>=', $since)
|
||||
->count();
|
||||
|
||||
$out[2]['value'] = (int) Db::name('analytics_events')
|
||||
->where('eventName', 'login_silent_success')
|
||||
->where('createdAt', '>=', $since)
|
||||
->count();
|
||||
|
||||
$out[3]['value'] = (int) Db::name('analytics_events')
|
||||
->whereIn('eventName', ['pay_success_attribution', 'pay_success'])
|
||||
->where('createdAt', '>=', $since)
|
||||
->count();
|
||||
} catch (\Throwable $e) {
|
||||
$tableMissing = true;
|
||||
}
|
||||
|
||||
// 分润金额(元)— commission_records.commissionFen
|
||||
// createdAt 在该表为整数秒时间戳,而不是 datetime;需要改成整数比较
|
||||
try {
|
||||
$sinceTs = time() - $days * 86400;
|
||||
$totalFen = (int) Db::name('commission_records')
|
||||
->where('createdAt', '>=', $sinceTs)
|
||||
->where('commissionFen', '>', 0)
|
||||
->sum('commissionFen');
|
||||
$out[4]['value'] = round($totalFen / 100, 2);
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
return success([
|
||||
'days' => $days,
|
||||
'funnel' => $out,
|
||||
'tableMissing' => $tableMissing,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
127
api/app/controller/superadmin/MpTabBar.php
Normal file
127
api/app/controller/superadmin/MpTabBar.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
namespace app\controller\superadmin;
|
||||
|
||||
use app\BaseController;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 超管 · 小程序 TabBar 配置管理
|
||||
*/
|
||||
class MpTabBar extends BaseController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$list = Db::name('mp_tabbar_items')
|
||||
->order('sortOrder', 'asc')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
return success(['list' => $list]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /superadmin/tabbar/save 整体覆盖保存
|
||||
* body: { items: [{id?, sortOrder, pagePath, text, iconKey, highlight, visible, badgeKey}] }
|
||||
*/
|
||||
public function save()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$items = Request::param('items/a', []);
|
||||
if (!is_array($items) || count($items) === 0) {
|
||||
return error('items 不能为空');
|
||||
}
|
||||
if (count($items) > 5) {
|
||||
return error('最多支持 5 个 Tab');
|
||||
}
|
||||
|
||||
$visibleCount = 0;
|
||||
foreach ($items as $it) {
|
||||
if ((int) ($it['visible'] ?? 1) === 1) $visibleCount++;
|
||||
}
|
||||
if ($visibleCount < 2) {
|
||||
return error('至少保留 2 个可见 Tab');
|
||||
}
|
||||
if ($visibleCount > 5) {
|
||||
return error('可见 Tab 不得超过 5 个');
|
||||
}
|
||||
|
||||
$now = time();
|
||||
Db::startTrans();
|
||||
try {
|
||||
$keepIds = [];
|
||||
foreach ($items as $idx => $it) {
|
||||
$data = [
|
||||
'sortOrder' => (int) ($it['sortOrder'] ?? ($idx + 1) * 10),
|
||||
'pagePath' => trim((string) ($it['pagePath'] ?? '')),
|
||||
'text' => trim((string) ($it['text'] ?? '')),
|
||||
'iconKey' => trim((string) ($it['iconKey'] ?? 'home')),
|
||||
'iconUrl' => isset($it['iconUrl']) && $it['iconUrl'] !== '' ? (string) $it['iconUrl'] : null,
|
||||
'visible' => (int) ($it['visible'] ?? 1) === 1 ? 1 : 0,
|
||||
'highlight' => (int) ($it['highlight'] ?? 0) === 1 ? 1 : 0,
|
||||
'badgeKey' => isset($it['badgeKey']) ? (string) $it['badgeKey'] : null,
|
||||
'updatedAt' => $now,
|
||||
];
|
||||
if ($data['pagePath'] === '' || $data['text'] === '') {
|
||||
throw new \Exception('pagePath / text 不能为空');
|
||||
}
|
||||
|
||||
if (!empty($it['id'])) {
|
||||
Db::name('mp_tabbar_items')->where('id', (int) $it['id'])->update($data);
|
||||
$keepIds[] = (int) $it['id'];
|
||||
} else {
|
||||
$data['createdAt'] = $now;
|
||||
$newId = Db::name('mp_tabbar_items')->insertGetId($data);
|
||||
$keepIds[] = (int) $newId;
|
||||
}
|
||||
}
|
||||
// 删除不在本次保存列表中的旧行
|
||||
Db::name('mp_tabbar_items')->whereNotIn('id', $keepIds)->delete();
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return error('保存失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
return success(null, '已保存 ' . count($items) . ' 个 Tab 项');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /superadmin/tabbar/reorder 仅排序
|
||||
* body: { ids: [id1,id2,id3,...] }
|
||||
*/
|
||||
public function reorder()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$ids = Request::param('ids/a', []);
|
||||
if (!is_array($ids) || empty($ids)) return error('ids 不能为空');
|
||||
$now = time();
|
||||
foreach ($ids as $idx => $id) {
|
||||
Db::name('mp_tabbar_items')->where('id', (int) $id)->update([
|
||||
'sortOrder' => ($idx + 1) * 10,
|
||||
'updatedAt' => $now,
|
||||
]);
|
||||
}
|
||||
return success(null, '排序已更新');
|
||||
}
|
||||
|
||||
public function remove()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$id = (int) Request::param('id', 0);
|
||||
if ($id <= 0) return error('id 非法');
|
||||
$left = Db::name('mp_tabbar_items')->where('visible', 1)->where('id', '<>', $id)->count();
|
||||
if ($left < 2) return error('至少保留 2 个可见 Tab,不能再删');
|
||||
Db::name('mp_tabbar_items')->where('id', $id)->delete();
|
||||
return success(null, '已删除');
|
||||
}
|
||||
|
||||
private function ensureSuperadmin()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
|
||||
abort(403, '无权限访问');
|
||||
}
|
||||
}
|
||||
}
|
||||
79
api/app/controller/superadmin/ProfitRule.php
Normal file
79
api/app/controller/superadmin/ProfitRule.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
namespace app\controller\superadmin;
|
||||
|
||||
use app\BaseController;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
class ProfitRule extends BaseController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$list = Db::name('profit_sharing_rules')->order('id', 'asc')->select()->toArray();
|
||||
foreach ($list as &$row) {
|
||||
$row['receivers'] = json_decode($row['receivers'] ?? '[]', true) ?: [];
|
||||
}
|
||||
unset($row);
|
||||
return success(['list' => $list]);
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$id = (int) Request::param('id', 0);
|
||||
$productType = trim((string) Request::param('productType', ''));
|
||||
$name = trim((string) Request::param('name', ''));
|
||||
$receivers = Request::param('receivers/a', []);
|
||||
$status = Request::param('status', 'active');
|
||||
if ($productType === '' || $name === '') return error('productType / name 必填');
|
||||
if (!is_array($receivers) || count($receivers) === 0) return error('至少一个收款人');
|
||||
|
||||
// 校验比例合计 = 1
|
||||
$sum = 0.0;
|
||||
foreach ($receivers as $r) { $sum += (float) ($r['ratio'] ?? 0); }
|
||||
if (abs($sum - 1.0) > 0.005) {
|
||||
return error('分账比例合计必须等于 100%(当前 ' . round($sum * 100, 2) . '%)');
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$data = [
|
||||
'productType' => $productType,
|
||||
'name' => $name,
|
||||
'receivers' => json_encode($receivers, JSON_UNESCAPED_UNICODE),
|
||||
'status' => $status === 'disabled' ? 'disabled' : 'active',
|
||||
'updatedAt' => $now,
|
||||
];
|
||||
|
||||
if ($id > 0) {
|
||||
Db::name('profit_sharing_rules')->where('id', $id)->update($data);
|
||||
} else {
|
||||
$dup = Db::name('profit_sharing_rules')->where('productType', $productType)->find();
|
||||
if ($dup) return error('该产品类型已存在规则,请编辑现有规则');
|
||||
$data['createdAt'] = $now;
|
||||
Db::name('profit_sharing_rules')->insert($data);
|
||||
}
|
||||
return success(null, '已保存');
|
||||
}
|
||||
|
||||
public function toggle($id)
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$row = Db::name('profit_sharing_rules')->where('id', (int) $id)->find();
|
||||
if (!$row) return error('规则不存在');
|
||||
$next = $row['status'] === 'active' ? 'disabled' : 'active';
|
||||
Db::name('profit_sharing_rules')->where('id', (int) $id)->update([
|
||||
'status' => $next,
|
||||
'updatedAt' => time(),
|
||||
]);
|
||||
return success(['status' => $next]);
|
||||
}
|
||||
|
||||
private function ensureSuperadmin()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
|
||||
abort(403, '无权限访问');
|
||||
}
|
||||
}
|
||||
}
|
||||
232
api/app/controller/superadmin/SoulArticle.php
Normal file
232
api/app/controller/superadmin/SoulArticle.php
Normal file
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
namespace app\controller\superadmin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\AiChatArticleDisplayService;
|
||||
use app\common\service\SoulArticleService;
|
||||
use app\model\SoulArticle as SoulArticleModel;
|
||||
use app\model\AiProvider as AiProviderModel;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 超管 · Soul 文章管理(采集 / 推荐 / AI 健康概览)
|
||||
*/
|
||||
class SoulArticle extends BaseController
|
||||
{
|
||||
/** POST /api/v1/superadmin/soul-articles/sync */
|
||||
public function sync()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
|
||||
$limit = (int) Request::post('limit', 10);
|
||||
$limit = max(1, min($limit, 30));
|
||||
$tag = trim((string) Request::post('tag', 'MBTI'));
|
||||
$keyword = trim((string) Request::post('keyword', ''));
|
||||
if ($tag === '') $tag = 'MBTI';
|
||||
|
||||
$r = $keyword !== ''
|
||||
? SoulArticleService::syncByKeyword($keyword, $limit, $tag)
|
||||
: SoulArticleService::syncLatest($limit, $tag);
|
||||
if (!empty($r['error'])) {
|
||||
return error($r['error'], 500);
|
||||
}
|
||||
if ($keyword !== '') {
|
||||
return success($r, "搜索并添加完成:新增 {$r['created']} 篇,更新 {$r['updated']} 篇");
|
||||
}
|
||||
return success($r, "采集完成:新增 {$r['created']} 篇,更新 {$r['updated']} 篇");
|
||||
}
|
||||
|
||||
/** GET /api/v1/superadmin/soul-articles/ai-chat-display */
|
||||
public function aiChatDisplayGet()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
return success(AiChatArticleDisplayService::getSettings());
|
||||
}
|
||||
|
||||
/** POST /api/v1/superadmin/soul-articles/ai-chat-display */
|
||||
public function aiChatDisplaySave()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$input = Request::post();
|
||||
$settings = AiChatArticleDisplayService::saveSettings(is_array($input) ? $input : []);
|
||||
return success($settings, '已保存');
|
||||
}
|
||||
|
||||
/** GET /api/v1/superadmin/soul-articles */
|
||||
public function index()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
|
||||
$page = max(1, (int) Request::get('page', 1));
|
||||
$pageSize = min(50, max(1, (int) Request::get('pageSize', 20)));
|
||||
$isReco = Request::get('isRecommended', '');
|
||||
$keyword = trim((string) Request::get('keyword', ''));
|
||||
$tag = trim((string) Request::get('tag', ''));
|
||||
$dateRange = Request::get('dateRange', '');
|
||||
|
||||
$query = SoulArticleModel::order('isRecommended', 'desc')
|
||||
->order('recommendedOrder', 'asc')
|
||||
->order('publishedAt', 'desc');
|
||||
if ($isReco === '1' || $isReco === 1) {
|
||||
$query = $query->where('isRecommended', 1);
|
||||
} elseif ($isReco === '0' || $isReco === 0) {
|
||||
$query = $query->where('isRecommended', 0);
|
||||
}
|
||||
if ($keyword !== '') {
|
||||
$query = $query->whereLike('title', "%{$keyword}%");
|
||||
}
|
||||
if ($tag !== '') {
|
||||
$query = $query->where('tag', $tag);
|
||||
}
|
||||
if (is_array($dateRange) && count($dateRange) === 2) {
|
||||
$start = (int) strtotime((string) $dateRange[0] . ' 00:00:00');
|
||||
$end = (int) strtotime((string) $dateRange[1] . ' 23:59:59');
|
||||
if ($start > 0 && $end > 0) {
|
||||
$query = $query->whereBetween('publishedAt', [$start, $end]);
|
||||
}
|
||||
}
|
||||
|
||||
$total = $query->count();
|
||||
$rows = $query->page($page, $pageSize)->select()->toArray();
|
||||
|
||||
return paginate_response($rows, $total, $page, $pageSize);
|
||||
}
|
||||
|
||||
/** POST /api/v1/superadmin/soul-articles/:id/order */
|
||||
public function setOrder()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$id = (int) Request::param('id', 0);
|
||||
$order = (int) Request::post('recommendedOrder', 0);
|
||||
if ($id <= 0) return error('无效 id', 400);
|
||||
if ($order < 0) return error('排序值不合法', 400);
|
||||
$article = SoulArticleModel::find($id);
|
||||
if (!$article) return error('文章不存在', 404);
|
||||
if ((int) $article->isRecommended !== 1) {
|
||||
return error('仅推荐中的文章可调整排序', 400);
|
||||
}
|
||||
$article->recommendedOrder = $order;
|
||||
$article->save();
|
||||
return success(['id' => $id, 'recommendedOrder' => $order], '排序已更新');
|
||||
}
|
||||
|
||||
/** POST /api/v1/superadmin/soul-articles/reorder-normalize */
|
||||
public function normalizeOrder()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$rows = SoulArticleModel::where('isRecommended', 1)
|
||||
->order('recommendedOrder', 'asc')
|
||||
->order('publishedAt', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
$idx = 1;
|
||||
foreach ($rows as $row) {
|
||||
$m = SoulArticleModel::find((int) $row['id']);
|
||||
if (!$m) continue;
|
||||
$m->recommendedOrder = $idx;
|
||||
$m->save();
|
||||
$idx++;
|
||||
}
|
||||
return success(['count' => count($rows)], '推荐权重已归一化');
|
||||
}
|
||||
|
||||
/** POST /api/v1/superadmin/soul-articles/:id/recommend */
|
||||
public function recommend()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$id = (int) Request::param('id', 0);
|
||||
if ($id <= 0) return error('无效 id', 400);
|
||||
|
||||
$r = SoulArticleService::recommend($id);
|
||||
if (empty($r['ok'])) {
|
||||
return error($r['message'] ?? '操作失败', 400);
|
||||
}
|
||||
return success($r, $r['message']);
|
||||
}
|
||||
|
||||
/** POST /api/v1/superadmin/soul-articles/:id/delete */
|
||||
public function remove()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$id = (int) Request::param('id', 0);
|
||||
if ($id <= 0) return error('无效 id', 400);
|
||||
$article = SoulArticleModel::find($id);
|
||||
if (!$article) return error('文章不存在', 404);
|
||||
$article->delete();
|
||||
return success(null, '已删除');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/superadmin/ai/health
|
||||
* AI 健康小条:各服务商可用 / 余额 / 最后检查时间
|
||||
*/
|
||||
public function health()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
|
||||
$rows = AiProviderModel::whereRaw('(visible IS NULL OR visible = 1)')
|
||||
->order('sortWeight', 'asc')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$list = [];
|
||||
foreach ($rows as $r) {
|
||||
$balance = isset($r['lastBalance']) ? (float) $r['lastBalance'] : null;
|
||||
$threshold = isset($r['balanceAlertThreshold']) ? (float) $r['balanceAlertThreshold'] : 0.0;
|
||||
$alertOn = (int) ($r['balanceAlertEnabled'] ?? 0) === 1;
|
||||
$hasKey = !empty($r['apiKey']);
|
||||
$enabled = (int) ($r['enabled'] ?? 0) === 1;
|
||||
|
||||
$status = 'unknown';
|
||||
if (!$enabled) {
|
||||
$status = 'disabled';
|
||||
} elseif (!$hasKey) {
|
||||
$status = 'no-key';
|
||||
} elseif ($alertOn && $balance !== null && $balance <= $threshold) {
|
||||
$status = 'low-balance';
|
||||
} elseif ($balance !== null) {
|
||||
$status = 'healthy';
|
||||
} else {
|
||||
$status = 'pending-check';
|
||||
}
|
||||
|
||||
$list[] = [
|
||||
'providerId' => $r['providerId'],
|
||||
'name' => $r['name'],
|
||||
'enabled' => $enabled,
|
||||
'hasKey' => $hasKey,
|
||||
'balance' => $balance,
|
||||
'currency' => $r['lastBalanceCurrency'] ?? 'CNY',
|
||||
'threshold' => $threshold,
|
||||
'balanceAlertEnabled' => $alertOn,
|
||||
'lastBalanceCheckedAt' => !empty($r['lastBalanceCheckedAt'])
|
||||
? date('Y-m-d H:i:s', (int) $r['lastBalanceCheckedAt'])
|
||||
: null,
|
||||
'sortWeight' => (int) ($r['sortWeight'] ?? 100),
|
||||
'status' => $status,
|
||||
];
|
||||
}
|
||||
|
||||
// 最近一次告警
|
||||
$lastAlert = Db::name('ai_balance_alerts')->order('alertedAt', 'desc')->find();
|
||||
return success([
|
||||
'providers' => $list,
|
||||
'lastAlert' => $lastAlert ? [
|
||||
'providerId' => $lastAlert['providerId'],
|
||||
'balance' => (float) $lastAlert['balance'],
|
||||
'threshold' => (float) $lastAlert['threshold'],
|
||||
'alertedAt' => date('Y-m-d H:i:s', (int) $lastAlert['alertedAt']),
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function ensureSuperadmin()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
|
||||
abort(403, '无权限访问');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user