feat: 小程序与管理端迭代(神仙AI、了解自己CRM、AI测试入口、报表与分润等)
Made-with: Cursor
This commit is contained in:
@@ -31,6 +31,14 @@ class AppUser extends BaseController
|
||||
$pageSize = (int) Request::param('pageSize', 20);
|
||||
$pageSize = min(max($pageSize, 1), 100);
|
||||
$keyword = trim(Request::param('keyword', ''));
|
||||
$coldFaceLevelRaw = Request::param('coldFaceLevel', '');
|
||||
$coldFaceLevels = [];
|
||||
if (is_array($coldFaceLevelRaw)) {
|
||||
$coldFaceLevels = array_values(array_filter(array_map('strval', $coldFaceLevelRaw)));
|
||||
} elseif (is_string($coldFaceLevelRaw) && $coldFaceLevelRaw !== '') {
|
||||
$coldFaceLevels = array_values(array_filter(array_map('trim', explode(',', $coldFaceLevelRaw))));
|
||||
}
|
||||
$coldFaceLevels = array_values(array_intersect($coldFaceLevels, ['cold', 'neutral', 'warm']));
|
||||
|
||||
$where = [];
|
||||
if ($keyword !== '') {
|
||||
@@ -72,6 +80,19 @@ class AppUser extends BaseController
|
||||
->join([$poolSql => 'p'], 'w.id = p.userId')
|
||||
->join([$dedupSql => 'd'], 'w.id = d.mid');
|
||||
|
||||
if (!empty($coldFaceLevels)) {
|
||||
try {
|
||||
$cfSql = Db::name('user_profile')
|
||||
->whereRaw('enterpriseId = ' . $eid)
|
||||
->whereIn('coldFaceLevel', $coldFaceLevels)
|
||||
->field('userId')
|
||||
->buildSql(true);
|
||||
$baseQuery->join([$cfSql => 'cf'], 'w.id = cf.userId');
|
||||
} catch (\Throwable $e) {
|
||||
// coldFace 字段不存在时忽略筛选
|
||||
}
|
||||
}
|
||||
|
||||
if ($keyword !== '') {
|
||||
$like = '%' . addcslashes($keyword, '%_\\') . '%';
|
||||
$baseQuery->whereRaw(
|
||||
@@ -215,6 +236,31 @@ class AppUser extends BaseController
|
||||
} catch (\Throwable $e) {
|
||||
$payStats = [];
|
||||
}
|
||||
|
||||
// 冷脸分字段(容错:迁移未执行时 coldFace* 字段缺失,查询异常则视为全空)
|
||||
$coldFaceMap = [];
|
||||
try {
|
||||
$cfQuery = Db::name('user_profile')->where('userId', 'in', $ids);
|
||||
if ($enterpriseId) {
|
||||
$cfQuery->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$cfRows = $cfQuery
|
||||
->field('userId, coldFaceScore, coldFaceLevel, coldFaceUpdatedAt')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($cfRows as $r) {
|
||||
$uid = (int) ($r['userId'] ?? 0);
|
||||
if ($uid > 0) {
|
||||
$coldFaceMap[$uid] = [
|
||||
'score' => isset($r['coldFaceScore']) && $r['coldFaceScore'] !== null ? (int) $r['coldFaceScore'] : null,
|
||||
'level' => $r['coldFaceLevel'] ?? null,
|
||||
'updatedAt' => isset($r['coldFaceUpdatedAt']) ? (int) $r['coldFaceUpdatedAt'] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$coldFaceMap = [];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($list as &$row) {
|
||||
@@ -236,6 +282,18 @@ class AppUser extends BaseController
|
||||
$pay = $payStats[$id] ?? null;
|
||||
$row['paidOrders'] = $pay ? (int) ($pay['paidOrders'] ?? 0) : 0;
|
||||
$row['totalPaidAmount'] = $pay ? (int) ($pay['totalPaidAmount'] ?? 0) : 0;
|
||||
|
||||
$cf = $coldFaceMap[$id] ?? null;
|
||||
if ((!$cf || $cf['score'] === null) && !empty($testsForUser)) {
|
||||
$calc = $this->calcColdFace($testsForUser);
|
||||
if ($calc) {
|
||||
$this->writeColdFace((int) $id, $enterpriseId ? (int) $enterpriseId : null, $calc);
|
||||
$cf = ['score' => $calc['score'], 'level' => $calc['level'], 'updatedAt' => time()];
|
||||
}
|
||||
}
|
||||
$row['coldFaceScore'] = $cf && $cf['score'] !== null ? (int) $cf['score'] : null;
|
||||
$row['coldFaceLevel'] = $cf && !empty($cf['level']) ? (string) $cf['level'] : null;
|
||||
$row['coldFaceUpdatedAt'] = $cf ? ($cf['updatedAt'] ?? null) : null;
|
||||
}
|
||||
|
||||
return paginate_response($list, $total, $page, $pageSize);
|
||||
@@ -322,6 +380,35 @@ class AppUser extends BaseController
|
||||
$data['faceDiscType'] = $this->extractFaceSubType($tests, 'disc');
|
||||
$data['facePdpType'] = $this->extractFaceSubType($tests, 'pdp');
|
||||
|
||||
// 冷脸分字段(详情)
|
||||
$coldFace = null;
|
||||
try {
|
||||
$cfQuery = Db::name('user_profile')->where('userId', $id);
|
||||
if ($enterpriseId) {
|
||||
$cfQuery->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$cfRow = $cfQuery->field('coldFaceScore, coldFaceLevel, coldFaceUpdatedAt')->find();
|
||||
if ($cfRow) {
|
||||
$coldFace = [
|
||||
'score' => $cfRow['coldFaceScore'] !== null ? (int) $cfRow['coldFaceScore'] : null,
|
||||
'level' => $cfRow['coldFaceLevel'] ?? null,
|
||||
'updatedAt' => isset($cfRow['coldFaceUpdatedAt']) ? (int) $cfRow['coldFaceUpdatedAt'] : null,
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$coldFace = null;
|
||||
}
|
||||
if ((!$coldFace || $coldFace['score'] === null) && !empty($tests)) {
|
||||
$calc = $this->calcColdFace($tests);
|
||||
if ($calc) {
|
||||
$this->writeColdFace((int) $id, $enterpriseId ? (int) $enterpriseId : null, $calc);
|
||||
$coldFace = ['score' => $calc['score'], 'level' => $calc['level'], 'updatedAt' => time()];
|
||||
}
|
||||
}
|
||||
$data['coldFaceScore'] = $coldFace['score'] ?? null;
|
||||
$data['coldFaceLevel'] = $coldFace['level'] ?? null;
|
||||
$data['coldFaceUpdatedAt'] = $coldFace['updatedAt'] ?? null;
|
||||
|
||||
return success($data);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class Finance extends BaseController
|
||||
$baseOrderQuery = Db::name('orders')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->whereIn('status', ['paid', 'completed'])
|
||||
->whereIn('productType', ['face', 'mbti', 'sbti', 'disc', 'pdp']);
|
||||
->whereIn('productType', ['face', 'mbti', 'sbti', 'disc', 'pdp', 'ai_deep_report']);
|
||||
|
||||
$totalIncomeFen = (int) ((clone $baseOrderQuery)->sum('amount') ?? 0);
|
||||
$todayIncomeFen = (int) ((clone $baseOrderQuery)->where('payTime', '>=', $todayStart)->sum('amount') ?? 0);
|
||||
@@ -54,16 +54,44 @@ class Finance extends BaseController
|
||||
->where('status', 'frozen')
|
||||
->sum('commissionFen') ?? 0);
|
||||
|
||||
// 本月累计扣款(平台扣费 + 佣金扣减)
|
||||
$monthConsumeFen = (int) (Db::name('finance_records')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->where('type', 'consume')
|
||||
->where('createdAt', '>=', $monthStart)
|
||||
->sum('amount') ?? 0);
|
||||
|
||||
// 今日扣款
|
||||
$todayConsumeFen = (int) (Db::name('finance_records')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->where('type', 'consume')
|
||||
->where('createdAt', '>=', $todayStart)
|
||||
->sum('amount') ?? 0);
|
||||
|
||||
// 建议充值:按本月实际天数日均 × 14 天,低于 500 元按 500 元兜底;余额足够则 0
|
||||
$balanceFen = (int) ($enterprise['balance'] ?? 0);
|
||||
$daysElapsed = max(1, (int) date('j'));
|
||||
$avgDaily = $monthConsumeFen > 0 ? intval($monthConsumeFen / $daysElapsed) : 0;
|
||||
$suggestThreshold = $avgDaily * 14;
|
||||
$suggestRechargeFen = 0;
|
||||
if ($avgDaily > 0 && $balanceFen < $suggestThreshold) {
|
||||
$suggestRechargeFen = max($suggestThreshold - $balanceFen, 50000); // ≥ 500 元
|
||||
}
|
||||
|
||||
return success([
|
||||
'enterpriseId' => $enterpriseId,
|
||||
'enterpriseName' => $enterprise['name'] ?? '',
|
||||
'balanceFen' => (int) ($enterprise['balance'] ?? 0),
|
||||
'balanceFen' => $balanceFen,
|
||||
'totalIncomeFen' => $totalIncomeFen,
|
||||
'todayIncomeFen' => $todayIncomeFen,
|
||||
'monthIncomeFen' => $monthIncomeFen,
|
||||
'manualRechargeFen' => $manualRechargeFen,
|
||||
'frozenCommissionFen' => $frozenCommissionFen,
|
||||
'paidOrderCount' => $paidOrderCount,
|
||||
'monthConsumeFen' => $monthConsumeFen,
|
||||
'todayConsumeFen' => $todayConsumeFen,
|
||||
'avgDailyConsumeFen' => $avgDaily,
|
||||
'suggestRechargeFen' => $suggestRechargeFen,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return error('获取企业财务概览失败:' . $e->getMessage(), 500);
|
||||
|
||||
@@ -31,6 +31,7 @@ class Order extends BaseController
|
||||
$keyword = trim(Request::param('keyword', ''));
|
||||
$status = trim(Request::param('status', ''));
|
||||
$productType = trim(Request::param('productType', ''));
|
||||
$inviterId = (int) Request::param('inviterId', 0);
|
||||
|
||||
// 超管:全平台订单;其余管理员仅本企业
|
||||
$enterpriseId = null;
|
||||
@@ -53,6 +54,24 @@ class Order extends BaseController
|
||||
if ($productType !== '') {
|
||||
$query->where('productType', $productType);
|
||||
}
|
||||
if ($inviterId > 0) {
|
||||
// 通过分销绑定表过滤「该分销商带来的订单」:orders.userId = distribution_bindings.inviteeId,inviter=inviterId
|
||||
try {
|
||||
$inviteeIds = Db::name('distribution_bindings')
|
||||
->where('inviterId', $inviterId)
|
||||
->column('inviteeId');
|
||||
$inviteeIds = array_values(array_filter(array_map('intval', $inviteeIds ?: [])));
|
||||
if (empty($inviteeIds)) {
|
||||
return success([
|
||||
'list' => [], 'total' => 0, 'page' => $page, 'pageSize' => $pageSize,
|
||||
'hasMore' => false, 'paidCompletedCount' => 0, 'totalRevenueFen' => 0,
|
||||
]);
|
||||
}
|
||||
$query->whereIn('userId', $inviteeIds);
|
||||
} catch (\Throwable $e) {
|
||||
// 表不存在或字段差异:忽略筛选
|
||||
}
|
||||
}
|
||||
if ($keyword !== '') {
|
||||
if (is_numeric($keyword)) {
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
@@ -119,12 +138,58 @@ class Order extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
// 当前页订单的分销分润记录
|
||||
$commissionsByOrder = [];
|
||||
if (!empty($orderIds)) {
|
||||
try {
|
||||
$crRows = Db::name('commission_records')
|
||||
->where('orderId', 'in', $orderIds)
|
||||
->field('id, orderId, inviterId, commissionFen, rate, status, paidAt, createdAt')
|
||||
->order('createdAt', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
$inviterIds = array_values(array_unique(array_filter(array_column($crRows, 'inviterId'))));
|
||||
$inviterMap = [];
|
||||
if (!empty($inviterIds)) {
|
||||
$inviters = Db::name('wechat_users')
|
||||
->where('id', 'in', $inviterIds)
|
||||
->field('id, nickname, phone')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($inviters as $iv) {
|
||||
$inviterMap[(int) $iv['id']] = $iv;
|
||||
}
|
||||
}
|
||||
foreach ($crRows as $cr) {
|
||||
$oid = (int) ($cr['orderId'] ?? 0);
|
||||
if ($oid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$iv = $inviterMap[(int) ($cr['inviterId'] ?? 0)] ?? null;
|
||||
$commissionsByOrder[$oid][] = [
|
||||
'id' => (int) $cr['id'],
|
||||
'inviterId' => (int) ($cr['inviterId'] ?? 0),
|
||||
'inviterName' => $iv['nickname'] ?? null,
|
||||
'inviterPhone' => $iv['phone'] ?? null,
|
||||
'commissionFen' => (int) ($cr['commissionFen'] ?? 0),
|
||||
'rate' => isset($cr['rate']) ? (float) $cr['rate'] : null,
|
||||
'status' => (string) ($cr['status'] ?? ''),
|
||||
'paidAt' => isset($cr['paidAt']) ? (int) $cr['paidAt'] : null,
|
||||
'createdAt' => isset($cr['createdAt']) ? (int) $cr['createdAt'] : null,
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$commissionsByOrder = [];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($list as &$row) {
|
||||
$uid = (int) ($row['userId'] ?? 0);
|
||||
$u = $usersMap[$uid] ?? null;
|
||||
$row['userName'] = $u ? ($u['nickname'] ?? ('用户' . $uid)) : ('用户' . $uid);
|
||||
$row['userPhone'] = $u ? ($u['phone'] ?? '') : '';
|
||||
$row['testData'] = $testsByOrder[$row['id']] ?? [];
|
||||
$row['commissions'] = $commissionsByOrder[$row['id']] ?? [];
|
||||
}
|
||||
|
||||
return success([
|
||||
|
||||
@@ -2,12 +2,115 @@
|
||||
namespace app\controller\admin\concern;
|
||||
|
||||
use app\common\PdpDiscResultText;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 从测试记录数组中解析 MBTI / DISC / PDP / 人脸子类型(与 AppUser 逻辑一致)
|
||||
*/
|
||||
trait ExtractsTestResults
|
||||
{
|
||||
/**
|
||||
* 基于测试记录推算冷脸分值(0-100)
|
||||
* - 面相 emotionScore/emotionNeutrality/microExpression 作为主输入
|
||||
* - MBTI I/E 作为补充(I 更冷,E 更暖)
|
||||
* 返回 ['score'=>int, 'level'=>'cold|neutral|warm'];如无任何线索返回 null
|
||||
*/
|
||||
public function calcColdFace(array $tests): ?array
|
||||
{
|
||||
$score = null; // 0-100
|
||||
$mbtiType = '';
|
||||
|
||||
foreach ($tests as $t) {
|
||||
$type = strtolower($t['testType'] ?? '');
|
||||
$raw = $t['result'] ?? ($t['resultData'] ?? '');
|
||||
if (!is_string($raw) || $raw === '') {
|
||||
continue;
|
||||
}
|
||||
$dec = json_decode($raw, true);
|
||||
if (!is_array($dec)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'face' && $score === null) {
|
||||
$face = is_array($dec['face'] ?? null) ? $dec['face'] : $dec;
|
||||
$emotion = null;
|
||||
foreach (['coldFaceScore', 'coldScore', 'emotionNeutrality', 'neutrality'] as $k) {
|
||||
if (isset($face[$k]) && is_numeric($face[$k])) {
|
||||
$emotion = (float) $face[$k];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($emotion === null && isset($face['emotionScore']) && is_numeric($face['emotionScore'])) {
|
||||
// emotionScore 越高越暖 → 冷脸分取反
|
||||
$emotion = 100 - (float) $face['emotionScore'];
|
||||
}
|
||||
if ($emotion === null && isset($face['microExpression']) && is_array($face['microExpression'])) {
|
||||
$mx = $face['microExpression'];
|
||||
$happy = is_numeric($mx['happy'] ?? null) ? (float) $mx['happy'] : 0;
|
||||
$neutral = is_numeric($mx['neutral'] ?? null) ? (float) $mx['neutral'] : 0;
|
||||
$total = $happy + $neutral + 1;
|
||||
$emotion = ($neutral / $total) * 100;
|
||||
}
|
||||
if ($emotion !== null) {
|
||||
if ($emotion <= 1) {
|
||||
$emotion = $emotion * 100;
|
||||
}
|
||||
$score = max(0, min(100, (int) round($emotion)));
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'mbti' && $mbtiType === '') {
|
||||
foreach (['mbtiType', 'type', 'result'] as $k) {
|
||||
$s = $this->coerceResultLabel($dec[$k] ?? null);
|
||||
if ($s !== '') {
|
||||
$mbtiType = strtoupper($s);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($score === null && $mbtiType !== '') {
|
||||
$score = strpos($mbtiType, 'I') === 0 ? 62 : 42;
|
||||
}
|
||||
if ($score === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$level = 'neutral';
|
||||
if ($score > 65) {
|
||||
$level = 'cold';
|
||||
} elseif ($score < 35) {
|
||||
$level = 'warm';
|
||||
}
|
||||
|
||||
return ['score' => (int) $score, 'level' => $level];
|
||||
}
|
||||
|
||||
/**
|
||||
* 写回 user_profile 的冷脸字段(存在才更新,否则跳过;失败静默)
|
||||
*/
|
||||
protected function writeColdFace(int $userId, ?int $enterpriseId, array $cold): void
|
||||
{
|
||||
if (!isset($cold['score'], $cold['level'])) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$query = Db::name('user_profile')->where('userId', $userId);
|
||||
if ($enterpriseId) {
|
||||
$query->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$query->update([
|
||||
'coldFaceScore' => (int) $cold['score'],
|
||||
'coldFaceLevel' => (string) $cold['level'],
|
||||
'coldFaceUpdatedAt' => time(),
|
||||
'updatedAt' => time(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// 字段不存在(未执行迁移)时直接忽略
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 结果字段可能为数组/对象,禁止直接 (string) 强转导致 Array to string conversion
|
||||
*/
|
||||
|
||||
503
api/app/controller/api/AiChat.php
Normal file
503
api/app/controller/api/AiChat.php
Normal file
@@ -0,0 +1,503 @@
|
||||
<?php
|
||||
namespace app\controller\api;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\AiCallService;
|
||||
use app\common\service\AiChatArticleDisplayService;
|
||||
use app\common\service\SoulArticleService;
|
||||
use app\model\AiConversation as AiConversationModel;
|
||||
use app\model\AiMessage as AiMessageModel;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 神仙 AI 聊天接口(小程序侧)
|
||||
*
|
||||
* 路由:/api/ai/*
|
||||
* 鉴权:auth(微信登录后 token)
|
||||
*/
|
||||
class AiChat extends BaseController
|
||||
{
|
||||
/** 每用户每日发送上限(超出则返回 429) */
|
||||
private const DAILY_LIMIT = 20;
|
||||
|
||||
/** 单次对话最多携带的历史上下文(user+assistant 轮数) */
|
||||
private const CONTEXT_TURNS = 8;
|
||||
|
||||
/**
|
||||
* POST /api/ai/chat
|
||||
* body: { conversationId?: int, message: string }
|
||||
* 返回 { conversationId, message: {role, content, providerId, isDegraded, createdAt}, usageToday, dailyLimit }
|
||||
*/
|
||||
public function chat()
|
||||
{
|
||||
$userId = $this->currentUserId();
|
||||
if ($userId <= 0) return error('请先登录', 401);
|
||||
|
||||
$message = trim((string) Request::post('message', ''));
|
||||
if ($message === '') return error('消息不能为空', 400);
|
||||
if (mb_strlen($message, 'UTF-8') > 800) {
|
||||
return error('消息过长,请精简到 800 字以内', 400);
|
||||
}
|
||||
|
||||
$conversationId = (int) Request::post('conversationId', 0);
|
||||
|
||||
// 每日限流(必须在 try 外先取 used,失败时返回 200+降级,避免未捕获异常导致 HTTP 500)
|
||||
$used = 0;
|
||||
try {
|
||||
$used = $this->incrementAndCheckDaily($userId, self::DAILY_LIMIT);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('AiChat incrementAndCheckDaily: ' . $e->getMessage());
|
||||
|
||||
return $this->chatDegradeResponse(
|
||||
$conversationId,
|
||||
0,
|
||||
'小神仙这边计数服务抖了一下,请稍后再发一条消息~'
|
||||
);
|
||||
}
|
||||
if ($used === -1) {
|
||||
return error('今日对话次数已用完,明天再来找我呀~', 429);
|
||||
}
|
||||
|
||||
$userMsgSaved = false;
|
||||
$conversation = null;
|
||||
try {
|
||||
// 拉取/新建对话(测评档案组装失败时不阻断聊天)
|
||||
$userContext = [
|
||||
'mbtiType' => '', 'summary' => '', 'nickname' => '', 'testAppendix' => '',
|
||||
];
|
||||
try {
|
||||
$userContext = AiCallService::fetchUserContext($userId);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('AiChat fetchUserContext: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$conversation = $conversationId > 0
|
||||
? AiConversationModel::where('userId', $userId)->where('id', $conversationId)->find()
|
||||
: null;
|
||||
|
||||
$now = time();
|
||||
if (!$conversation) {
|
||||
$title = mb_substr($message, 0, 24, 'UTF-8');
|
||||
if ($title === false) {
|
||||
$title = mb_substr($message, 0, 24);
|
||||
}
|
||||
if (!is_string($title) || $title === '') {
|
||||
$title = '新对话';
|
||||
}
|
||||
$conversation = AiConversationModel::create([
|
||||
'userId' => $userId,
|
||||
'title' => $title,
|
||||
'mbtiType' => $userContext['mbtiType'] ?: '',
|
||||
'providerId' => '',
|
||||
'lastMessageAt' => $now,
|
||||
'messageCount' => 0,
|
||||
]);
|
||||
$conversationId = (int) $conversation->id;
|
||||
}
|
||||
|
||||
// 写入 user 消息
|
||||
AiMessageModel::create([
|
||||
'conversationId' => $conversationId,
|
||||
'role' => 'user',
|
||||
'content' => $message,
|
||||
'tokensIn' => 0,
|
||||
'tokensOut' => 0,
|
||||
'providerId' => '',
|
||||
'isDegraded' => 0,
|
||||
'createdAt' => $now,
|
||||
]);
|
||||
$userMsgSaved = true;
|
||||
|
||||
// 组装 messages:system + 最近 N 轮历史 + 本次 user
|
||||
try {
|
||||
$systemPrompt = AiCallService::buildSystemPrompt($userContext);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('AiChat buildSystemPrompt: ' . $e->getMessage());
|
||||
$systemPrompt = "你是「神仙 AI」,神仙团队 MBTI 小程序的专属伙伴。请亲切、简短地回答用户;禁止医疗诊断与投资建议。\n";
|
||||
}
|
||||
$history = AiMessageModel::where('conversationId', $conversationId)
|
||||
->order('id', 'desc')
|
||||
->limit(self::CONTEXT_TURNS * 2)
|
||||
->select()
|
||||
->toArray();
|
||||
$history = array_reverse($history);
|
||||
|
||||
$messages = [['role' => 'system', 'content' => $systemPrompt]];
|
||||
foreach ($history as $row) {
|
||||
if (!in_array($row['role'], ['user', 'assistant'], true)) {
|
||||
continue;
|
||||
}
|
||||
$messages[] = ['role' => $row['role'], 'content' => (string) $row['content']];
|
||||
}
|
||||
|
||||
$r = AiCallService::chat($messages, ['temperature' => 0.75, 'maxTokens' => 1024]);
|
||||
$assistantContent = $r['content'] !== '' ? $r['content'] : '(小神仙被问住了,换个问法试试?)';
|
||||
|
||||
// 写入 assistant 消息
|
||||
$msgRow = AiMessageModel::create([
|
||||
'conversationId' => $conversationId,
|
||||
'role' => 'assistant',
|
||||
'content' => $assistantContent,
|
||||
'tokensIn' => (int) ($r['tokensIn'] ?? 0),
|
||||
'tokensOut' => (int) ($r['tokensOut'] ?? 0),
|
||||
'providerId' => (string) ($r['providerId'] ?? ''),
|
||||
'isDegraded' => !empty($r['isDegraded']) ? 1 : 0,
|
||||
'createdAt' => time(),
|
||||
]);
|
||||
|
||||
// 更新对话元数据
|
||||
$conversation->lastMessageAt = time();
|
||||
$conversation->messageCount = AiMessageModel::where('conversationId', $conversationId)->count();
|
||||
$conversation->providerId = (string) ($r['providerId'] ?? '');
|
||||
$conversation->save();
|
||||
|
||||
return success([
|
||||
'conversationId' => $conversationId,
|
||||
'message' => [
|
||||
'id' => (int) $msgRow->id,
|
||||
'role' => 'assistant',
|
||||
'content' => $assistantContent,
|
||||
'providerId' => $r['providerId'] ?? '',
|
||||
'isDegraded' => (bool) ($r['isDegraded'] ?? false),
|
||||
'createdAt' => (int) $msgRow->createdAt,
|
||||
],
|
||||
'usageToday' => $used,
|
||||
'dailyLimit' => self::DAILY_LIMIT,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('AiChat::chat 异常: ' . $e->getMessage() . "\n" . $e->getTraceAsString());
|
||||
|
||||
// 用户消息已落库时:降级返回一条助手回复,避免前端只看到 500
|
||||
if ($userMsgSaved && $conversationId > 0 && $conversation) {
|
||||
try {
|
||||
$assistantContent = '小神仙这边刚刚抖了一下(已自动记录)。你可以把问题缩短一点重发,或稍后再试~';
|
||||
$msgRow = AiMessageModel::create([
|
||||
'conversationId' => $conversationId,
|
||||
'role' => 'assistant',
|
||||
'content' => $assistantContent,
|
||||
'tokensIn' => 0,
|
||||
'tokensOut' => 0,
|
||||
'providerId' => 'degrade',
|
||||
'isDegraded' => 1,
|
||||
'createdAt' => time(),
|
||||
]);
|
||||
$conversation->lastMessageAt = time();
|
||||
$conversation->messageCount = AiMessageModel::where('conversationId', $conversationId)->count();
|
||||
$conversation->providerId = 'degrade';
|
||||
$conversation->save();
|
||||
|
||||
return success([
|
||||
'conversationId' => $conversationId,
|
||||
'message' => [
|
||||
'id' => (int) $msgRow->id,
|
||||
'role' => 'assistant',
|
||||
'content' => $assistantContent,
|
||||
'providerId' => 'degrade',
|
||||
'isDegraded' => true,
|
||||
'createdAt' => (int) $msgRow->createdAt,
|
||||
],
|
||||
'usageToday' => $used,
|
||||
'dailyLimit' => self::DAILY_LIMIT,
|
||||
]);
|
||||
} catch (\Throwable $e2) {
|
||||
Log::error('AiChat::chat 降级回复写入失败: ' . $e2->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 统一不再返回业务 code=500,前端只认 code=200;避免用户看到「服务异常(500)」
|
||||
return $this->chatDegradeResponse(
|
||||
$conversationId,
|
||||
$used,
|
||||
'小神仙服务暂时不可用,请稍后再试。如反复出现可在「我的」联系客服~'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天失败时仍返回 HTTP JSON code=200,由 message.isDegraded 标记,小程序可正常展示气泡
|
||||
*
|
||||
* @param int $conversationId 当前会话 id(未知则 0)
|
||||
* @param int $usageToday 今日已计次数(失败时可能为 0)
|
||||
* @param string $assistantText 助手可见文案
|
||||
*/
|
||||
private function chatDegradeResponse(int $conversationId, int $usageToday, string $assistantText)
|
||||
{
|
||||
$now = time();
|
||||
|
||||
return success([
|
||||
'conversationId' => $conversationId,
|
||||
'message' => [
|
||||
'id' => 0,
|
||||
'role' => 'assistant',
|
||||
'content' => $assistantText,
|
||||
'providerId' => 'degrade',
|
||||
'isDegraded' => true,
|
||||
'createdAt' => $now,
|
||||
],
|
||||
'usageToday' => max(0, $usageToday),
|
||||
'dailyLimit' => self::DAILY_LIMIT,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ai/articles/recommended
|
||||
* 无需登录(也可以;这里先要求登录保持一致)
|
||||
*/
|
||||
public function recommendedArticles()
|
||||
{
|
||||
$display = AiChatArticleDisplayService::getSettings();
|
||||
$rows = [];
|
||||
if ($display['enabled']) {
|
||||
try { SoulArticleService::autoSyncIfStale(); } catch (\Throwable $e) {}
|
||||
$limit = (int) $display['maxShow'];
|
||||
$rows = SoulArticleService::getRecommended($limit);
|
||||
if (empty($rows)) {
|
||||
try { SoulArticleService::syncLatest(10, 'MBTI'); } catch (\Throwable $e) {}
|
||||
$rows = SoulArticleService::getRecommended($limit);
|
||||
}
|
||||
}
|
||||
|
||||
$resp = success([
|
||||
'list' => $rows,
|
||||
'recoCount' => count($rows),
|
||||
'display' => [
|
||||
'enabled' => $display['enabled'],
|
||||
'maxShow' => $display['maxShow'],
|
||||
'sectionExpandedDefault' => $display['sectionExpandedDefault'],
|
||||
],
|
||||
]);
|
||||
if (method_exists($resp, 'header')) {
|
||||
$resp->header([
|
||||
'Cache-Control' => 'no-store, no-cache, must-revalidate',
|
||||
'Pragma' => 'no-cache',
|
||||
]);
|
||||
}
|
||||
return $resp;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ai/articles/profile-teaser
|
||||
* 我的页底部:展示后台「当前推荐」排序第一的一篇 + 可配区块标题(无需登录)
|
||||
*/
|
||||
public function profileArticleTeaser()
|
||||
{
|
||||
$display = AiChatArticleDisplayService::getSettings();
|
||||
if (empty($display['profileRecoEnabled'])) {
|
||||
$resp = success([
|
||||
'enabled' => false,
|
||||
'sectionLabel' => $display['profileSectionLabel'] ?? '',
|
||||
'article' => null,
|
||||
]);
|
||||
} else {
|
||||
try {
|
||||
SoulArticleService::autoSyncIfStale();
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
$rows = SoulArticleService::getRecommended(1);
|
||||
if (empty($rows)) {
|
||||
try {
|
||||
SoulArticleService::syncLatest(10, 'MBTI');
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
$rows = SoulArticleService::getRecommended(1);
|
||||
}
|
||||
$article = !empty($rows[0]) ? $rows[0] : null;
|
||||
$resp = success([
|
||||
'enabled' => true,
|
||||
'sectionLabel' => $display['profileSectionLabel'] ?? '',
|
||||
'article' => $article,
|
||||
]);
|
||||
}
|
||||
if (method_exists($resp, 'header')) {
|
||||
$resp->header([
|
||||
'Cache-Control' => 'no-store, no-cache, must-revalidate',
|
||||
'Pragma' => 'no-cache',
|
||||
]);
|
||||
}
|
||||
|
||||
return $resp;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ai/quick-questions
|
||||
* 基于当前用户 MBTI 类型返回快捷提问
|
||||
*/
|
||||
public function quickQuestions()
|
||||
{
|
||||
// 公开接口:无中间件时从 Authorization 解析微信用户(可选)
|
||||
$userId = $this->currentUserId();
|
||||
if ($userId <= 0) {
|
||||
$u = $this->resolveUser();
|
||||
if ($u && ($u['source'] ?? '') === 'wechat') {
|
||||
$userId = (int) ($u['user_id'] ?? $u['userId'] ?? 0);
|
||||
}
|
||||
}
|
||||
$ctx = AiCallService::fetchUserContext($userId);
|
||||
$qs = AiCallService::filterQuickQuestions(AiCallService::quickQuestions($ctx['mbtiType']));
|
||||
return success([
|
||||
'mbtiType' => $ctx['mbtiType'],
|
||||
'nickname' => $ctx['nickname'],
|
||||
'questions' => $qs,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ai/conversations 分页取当前用户会话列表
|
||||
* query: page, pageSize
|
||||
*/
|
||||
public function conversations()
|
||||
{
|
||||
$userId = $this->currentUserId();
|
||||
if ($userId <= 0) return error('请先登录', 401);
|
||||
|
||||
$page = max(1, (int) Request::get('page', 1));
|
||||
$pageSize = min(50, max(1, (int) Request::get('pageSize', 20)));
|
||||
|
||||
$query = AiConversationModel::where('userId', $userId);
|
||||
$total = $query->count();
|
||||
$list = $query
|
||||
->order('lastMessageAt', 'desc')
|
||||
->page($page, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return paginate_response($list, $total, $page, $pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ai/conversations/:id/messages
|
||||
*/
|
||||
public function messages()
|
||||
{
|
||||
$userId = $this->currentUserId();
|
||||
if ($userId <= 0) return error('请先登录', 401);
|
||||
|
||||
$cid = (int) Request::param('id', 0);
|
||||
$conversation = AiConversationModel::where('userId', $userId)->where('id', $cid)->find();
|
||||
if (!$conversation) return error('会话不存在', 404);
|
||||
|
||||
$rows = AiMessageModel::where('conversationId', $cid)
|
||||
->order('id', 'asc')
|
||||
->limit(200)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$list = array_map(function ($r) {
|
||||
return [
|
||||
'id' => (int) $r['id'],
|
||||
'role' => $r['role'],
|
||||
'content' => $r['content'],
|
||||
'providerId' => $r['providerId'],
|
||||
'isDegraded' => (bool) $r['isDegraded'],
|
||||
'createdAt' => (int) $r['createdAt'],
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return success([
|
||||
'conversation' => [
|
||||
'id' => (int) $conversation->id,
|
||||
'title' => (string) $conversation->title,
|
||||
'mbtiType' => (string) $conversation->mbtiType,
|
||||
'messageCount' => (int) $conversation->messageCount,
|
||||
'lastMessageAt' => (int) $conversation->lastMessageAt,
|
||||
],
|
||||
'messages' => $list,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ai/transcribe
|
||||
* 语音转文字:接受前端 audio 文件上传。
|
||||
* 当前占位:若未配置语音识别服务(ai_transcribe_provider),返回 501 + 降级提示,
|
||||
* 前端会降级到"请用键盘麦克风"。接入 ASR 后在此填充 provider 调用即可。
|
||||
*/
|
||||
public function transcribe()
|
||||
{
|
||||
$userId = $this->currentUserId();
|
||||
if ($userId <= 0) return error('请先登录', 401);
|
||||
|
||||
$file = Request::file('audio');
|
||||
if (!$file) return error('未收到音频文件', 400);
|
||||
|
||||
// 读取 system_config.ai_transcribe_provider(未配置则降级)
|
||||
$cfgRow = Db::name('system_config')->where('key', 'ai_transcribe_provider')->find();
|
||||
if (!$cfgRow || empty($cfgRow['value'])) {
|
||||
return error('语音识别未开通,请使用键盘麦克风', 501);
|
||||
}
|
||||
|
||||
// TODO: 二期对接 ASR(如通义听悟/讯飞/whisper API)
|
||||
// 当前阶段:保持降级,避免伪造返回值骗用户
|
||||
return error('语音识别通道建设中,请使用键盘麦克风', 501);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ai/articles/:id/click
|
||||
* 轻量记录点击,用于分析(失败不影响用户)
|
||||
*/
|
||||
public function articleClick()
|
||||
{
|
||||
$id = (int) Request::param('id', 0);
|
||||
if ($id > 0) {
|
||||
try {
|
||||
Db::name('soul_articles')->where('id', $id)->inc('viewCount')->update();
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
}
|
||||
return success(null);
|
||||
}
|
||||
|
||||
/** 从 auth 中间件挂好的 request->user 中取出当前微信用户 id */
|
||||
private function currentUserId(): int
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || ($user['source'] ?? '') !== 'wechat') return 0;
|
||||
return (int) ($user['user_id'] ?? $user['userId'] ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自增今日用量,若已达上限返回 -1
|
||||
*/
|
||||
private function incrementAndCheckDaily(int $userId, int $limit): int
|
||||
{
|
||||
$dateStr = date('Y-m-d');
|
||||
$now = time();
|
||||
|
||||
$row = Db::name('ai_usage_daily')
|
||||
->where('userId', $userId)
|
||||
->where('dateStr', $dateStr)
|
||||
->find();
|
||||
|
||||
if (!$row) {
|
||||
try {
|
||||
Db::name('ai_usage_daily')->insert([
|
||||
'userId' => $userId,
|
||||
'dateStr' => $dateStr,
|
||||
'messageCount' => 1,
|
||||
'createdAt' => $now,
|
||||
'updatedAt' => $now,
|
||||
]);
|
||||
return 1;
|
||||
} catch (\Throwable $e) {
|
||||
// 并发首条:另一请求已插入,回读后再自增
|
||||
$row = Db::name('ai_usage_daily')
|
||||
->where('userId', $userId)
|
||||
->where('dateStr', $dateStr)
|
||||
->find();
|
||||
if (!$row) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$used = (int) $row['messageCount'];
|
||||
if ($used >= $limit) return -1;
|
||||
|
||||
Db::name('ai_usage_daily')
|
||||
->where('id', $row['id'])
|
||||
->update(['messageCount' => $used + 1, 'updatedAt' => $now]);
|
||||
|
||||
return $used + 1;
|
||||
}
|
||||
}
|
||||
84
api/app/controller/api/AiReport.php
Normal file
84
api/app/controller/api/AiReport.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
namespace app\controller\api;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\AiReportService;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* AI 深度画像报告 · 小程序 API
|
||||
*
|
||||
* POST /api/ai/report/create 创建或返回现有 pending 报告
|
||||
* GET /api/ai/report/my-latest 我最近的一份报告(判断是否已买)
|
||||
* GET /api/ai/report/:id 获取报告正文(必须属于当前用户)
|
||||
* POST /api/ai/report/:id/mark-paid-dev 调试用:跳过支付直接置已付
|
||||
* POST /api/ai/report/:id/regenerate 失败后重试生成(仅管理员/作者)
|
||||
*/
|
||||
class AiReport extends BaseController
|
||||
{
|
||||
public function create()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || empty($user['id'])) return error('请先登录', 401);
|
||||
$conversationId = (int) Request::param('conversationId', 0);
|
||||
$mbtiType = trim((string) Request::param('mbtiType', ''));
|
||||
|
||||
$r = AiReportService::createOrGetPending((int) $user['id'], $conversationId, $mbtiType);
|
||||
return success($r);
|
||||
}
|
||||
|
||||
public function myLatest()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || empty($user['id'])) return success(['status' => '']);
|
||||
$r = AiReportService::myLatest((int) $user['id']);
|
||||
if (!$r) return success(['status' => '']);
|
||||
return success($r);
|
||||
}
|
||||
|
||||
public function show($id)
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || empty($user['id'])) return error('请先登录', 401);
|
||||
$r = AiReportService::get((int) $id, (int) $user['id']);
|
||||
if (!$r) return error('报告不存在', 404);
|
||||
|
||||
// 未付费时,content 不下发,只给 summary + 解锁引导
|
||||
if ($r['status'] !== 'done') {
|
||||
unset($r['content']);
|
||||
}
|
||||
return success($r);
|
||||
}
|
||||
|
||||
public function markPaidDev($id)
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || empty($user['id'])) return error('请先登录', 401);
|
||||
$r = AiReportService::get((int) $id, (int) $user['id']);
|
||||
if (!$r) return error('报告不存在', 404);
|
||||
|
||||
// 仅超管或本地 debug 开关允许
|
||||
$role = $user['role'] ?? '';
|
||||
$isSuper = $role === 'superadmin';
|
||||
$isDebug = function_exists('env') ? (bool) env('app.ai_report_paid_dev', false) : false;
|
||||
if (!$isSuper && !$isDebug) {
|
||||
return error('仅测试模式可用', 403);
|
||||
}
|
||||
$ret = AiReportService::markPaidDev((string) $r['orderSn']);
|
||||
return success($ret);
|
||||
}
|
||||
|
||||
public function regenerate($id)
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || empty($user['id'])) return error('请先登录', 401);
|
||||
$r = AiReportService::get((int) $id, (int) $user['id']);
|
||||
if (!$r) return error('报告不存在', 404);
|
||||
if (!in_array($r['status'], ['failed', 'paid'])) {
|
||||
return error('当前状态不支持重试:' . $r['status']);
|
||||
}
|
||||
AiReportService::generate((int) $r['id']);
|
||||
$r2 = AiReportService::get((int) $id, (int) $user['id']);
|
||||
return success($r2);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ namespace app\controller\api;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\PdpDiscResultText;
|
||||
use app\common\service\FeishuLeadWebhookService;
|
||||
use app\common\service\JwtService;
|
||||
use app\common\service\UserJourneyService;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
@@ -22,6 +24,7 @@ class CrmReport extends BaseController
|
||||
* @param string remark 备注,如"申请咨询"/"完成付款"
|
||||
* @param string tags 可选,逗号分隔的微信标签
|
||||
* @param string siteTags 可选,逗号分隔的站内标签
|
||||
* @param bool deepConsult 为 true 时:「了解自己」申请咨询;apiKey 可空,后端按用户归属企业从 cunkebao_keys 解析;并推送飞书获客(若已配置)
|
||||
*/
|
||||
public function report()
|
||||
{
|
||||
@@ -49,6 +52,8 @@ class CrmReport extends BaseController
|
||||
$tags = trim((string) ($this->request->param('tags', '') ?? ''));
|
||||
$siteTags = trim((string) ($this->request->param('siteTags', '') ?? ''));
|
||||
|
||||
$deepConsult = self::isTruthyParam($this->request->param('deepConsult', false));
|
||||
|
||||
// 测评类付费(人脸/MBTI/PDP/DISC):未传 apiKey 时从企业后台配置 cunkebao_keys 解析(与深度服务「完成付款」上报一致)
|
||||
$testType = trim((string) ($this->request->param('testType', '') ?? ''));
|
||||
$testResultId = (int) ($this->request->param('testResultId', 0) ?? 0);
|
||||
@@ -70,6 +75,15 @@ class CrmReport extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
// 「了解自己」申请咨询:类目未配 consultWechat 时,按用户归属企业回落 cunkebao_keys
|
||||
if ($apiKey === '' && $deepConsult && $userId > 0) {
|
||||
$eidDeep = (int) (Db::name('wechat_users')->where('id', $userId)->value('enterpriseId') ?? 0);
|
||||
$fk = self::readSingleCunkebaoApiKeyWithFallback($eidDeep);
|
||||
if ($fk !== '') {
|
||||
$apiKey = $fk;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用企业 cunkebao_keys 的「测评付费」上报:须校验已支付(防止未支付伪造「完成付款」)
|
||||
if ($resolvedFromEnterpriseKeys && !empty($apiKey)) {
|
||||
$deny = self::verifyTestPaidForCrmReport($userId, $testType, $testResultId);
|
||||
@@ -78,19 +92,58 @@ class CrmReport extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
// apiKey 为空则跳过,不影响主流程
|
||||
if (empty($apiKey)) {
|
||||
if (empty($apiKey) && !$deepConsult) {
|
||||
return success(['reported' => false, 'reason' => 'no_api_key']);
|
||||
}
|
||||
|
||||
// 测评结果:备注仅摘要;站内/微信标签含「测评名,结果」
|
||||
if ($testResultId > 0 && $userId > 0) {
|
||||
self::applyTestResultSummaryToReportPayload($userId, $testType, $testResultId, $remark, $tags, $siteTags);
|
||||
$eidForMgmt = $contextEnterpriseId;
|
||||
if ($eidForMgmt <= 0) {
|
||||
$eidForMgmt = (int) (Db::name('test_results')->where('id', $testResultId)->where('userId', $userId)->value('enterpriseId') ?? 0);
|
||||
}
|
||||
self::appendCrmRemarkUserJourney($userId, $eidForMgmt, $remark);
|
||||
}
|
||||
|
||||
$ok = self::doReport($userId, $apiKey, $source, $remark, $tags, $siteTags);
|
||||
// 申请咨询:备注中附带用户管理摘要与旅程(与飞书卡片「最近行为」同源)
|
||||
if ($deepConsult && $userId > 0 && !empty($apiKey)) {
|
||||
$eidJ = (int) (Db::name('wechat_users')->where('id', $userId)->value('enterpriseId') ?? 0);
|
||||
self::appendCrmRemarkUserJourney($userId, $eidJ, $remark);
|
||||
}
|
||||
|
||||
return success(['reported' => $ok, 'reason' => $ok ? '' : 'api_error']);
|
||||
$ok = false;
|
||||
if (!empty($apiKey)) {
|
||||
$ok = self::doReport($userId, $apiKey, $source, $remark, $tags, $siteTags);
|
||||
}
|
||||
|
||||
if ($deepConsult && $userId > 0) {
|
||||
$feishuSource = $source !== '' ? $source : '深度服务·申请咨询';
|
||||
FeishuLeadWebhookService::onDeepServiceConsultApply($userId, $feishuSource, $siteTags);
|
||||
}
|
||||
|
||||
if ($ok) {
|
||||
return success(['reported' => true, 'reason' => '']);
|
||||
}
|
||||
|
||||
return success([
|
||||
'reported' => false,
|
||||
'reason' => empty($apiKey) ? 'no_api_key' : 'api_error',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param mixed $v */
|
||||
private static function isTruthyParam($v): bool
|
||||
{
|
||||
if ($v === true || $v === 1) {
|
||||
return true;
|
||||
}
|
||||
if ($v === false || $v === 0 || $v === null || $v === '') {
|
||||
return false;
|
||||
}
|
||||
$s = strtolower(trim((string) $v));
|
||||
|
||||
return in_array($s, ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -398,6 +451,8 @@ class CrmReport extends BaseController
|
||||
$tags = $merged;
|
||||
$siteTags = $merged;
|
||||
|
||||
self::appendCrmRemarkUserJourney($userId, $enterpriseId, $remark);
|
||||
|
||||
self::doReport($userId, $apiKey, $source, $remark, $tags, $siteTags);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('[CrmReport] reportTestCompletion 异常 userId=' . $userId . ' err=' . $e->getMessage());
|
||||
@@ -460,6 +515,36 @@ class CrmReport extends BaseController
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 存客宝 remark 追加:用户管理一行 + analytics 用户旅程(与飞书/出站 Hook 字段同源)
|
||||
*/
|
||||
private static function appendCrmRemarkUserJourney(int $userId, int $enterpriseIdForMgmt, string &$remark): void
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
$blocks = [];
|
||||
$m = UserJourneyService::managementSummaryLine($userId, $enterpriseIdForMgmt);
|
||||
if ($m !== '') {
|
||||
$blocks[] = '【用户管理】' . $m;
|
||||
}
|
||||
$jb = UserJourneyService::journeyLinesToRemarkBlock(UserJourneyService::recentBehaviorLines($userId, 10), 720);
|
||||
if ($jb !== '') {
|
||||
$blocks[] = $jb;
|
||||
}
|
||||
if (count($blocks) === 0) {
|
||||
return;
|
||||
}
|
||||
$append = implode("\n", $blocks);
|
||||
$remark = trim($remark === '' ? $append : ($remark . "\n" . $append));
|
||||
$cap = 900;
|
||||
if (function_exists('mb_strlen') && mb_strlen($remark) > $cap) {
|
||||
$remark = mb_substr($remark, 0, $cap) . '…';
|
||||
} elseif (!function_exists('mb_strlen') && strlen($remark) > $cap) {
|
||||
$remark = substr($remark, 0, $cap) . '…';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部通用上报(供 report() 接口和 reportTestCompletion 共用)
|
||||
*/
|
||||
|
||||
58
api/app/controller/api/MpConfig.php
Normal file
58
api/app/controller/api/MpConfig.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
namespace app\controller\api;
|
||||
|
||||
use app\BaseController;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 小程序公开运行配置
|
||||
* - GET /api/mp/tabbar : 返回底部 TabBar 动态布局
|
||||
*
|
||||
* 不强制登录,结果做 60s 软缓存(客户端也会做 localStorage 缓存)。
|
||||
*/
|
||||
class MpConfig extends BaseController
|
||||
{
|
||||
public function tabbar()
|
||||
{
|
||||
$items = Db::name('mp_tabbar_items')
|
||||
->where('visible', 1)
|
||||
->order('sortOrder', 'asc')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$list = [];
|
||||
foreach ($items as $row) {
|
||||
$iconKey = $row['iconKey'] ?? 'home';
|
||||
$iconUrl = $row['iconUrl'] ?? null;
|
||||
if ($iconKey === 'ai') {
|
||||
// 神仙AI:纯圆老头像(无放射线装饰),避免后台误配导致小图标异常
|
||||
$iconUrl = '/images/shenxian-oldman-circle.png';
|
||||
}
|
||||
$list[] = [
|
||||
'id' => (int) $row['id'],
|
||||
'pagePath' => $row['pagePath'],
|
||||
'text' => $row['text'],
|
||||
'iconKey' => $iconKey,
|
||||
'iconUrl' => $iconUrl,
|
||||
'highlight' => (int) ($row['highlight'] ?? 0) === 1,
|
||||
'badgeKey' => $row['badgeKey'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
// 兜底:后台还没配/表空,返回硬编码 4 项,保证小程序永远可用
|
||||
if (empty($list)) {
|
||||
$list = [
|
||||
['id' => 0, 'pagePath' => 'pages/index/index', 'text' => '首页', 'iconKey' => 'home', 'iconUrl' => null, 'highlight' => false, 'badgeKey' => null],
|
||||
['id' => 0, 'pagePath' => 'pages/index/camera', 'text' => '拍摄', 'iconKey' => 'camera', 'iconUrl' => null, 'highlight' => true, 'badgeKey' => null],
|
||||
['id' => 0, 'pagePath' => 'pages/ai-chat/index', 'text' => '神仙AI', 'iconKey' => 'ai', 'iconUrl' => '/images/shenxian-oldman-circle.png', 'highlight' => false, 'badgeKey' => null],
|
||||
['id' => 0, 'pagePath' => 'pages/profile/index', 'text' => '我', 'iconKey' => 'profile', 'iconUrl' => null, 'highlight' => false, 'badgeKey' => null],
|
||||
];
|
||||
}
|
||||
|
||||
return success([
|
||||
'items' => $list,
|
||||
'version' => (int) Db::name('mp_tabbar_items')->max('updatedAt'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use app\model\PricingConfig as PricingConfigModel;
|
||||
use app\model\UserProfile as UserProfileModel;
|
||||
use app\common\service\JwtService;
|
||||
use app\common\service\FeishuLeadWebhookService;
|
||||
use app\common\service\AiReportService;
|
||||
use think\facade\Request;
|
||||
use think\facade\Db;
|
||||
|
||||
@@ -364,6 +365,14 @@ class Payment extends BaseController
|
||||
// 企业四项测试支付后,订单金额进入企业余额
|
||||
$this->creditEnterpriseBalanceForOrder($order, $paidAmountFen, $now);
|
||||
|
||||
// AI 深度报告:支付成功后置 paid 并触发报告生成(幂等)
|
||||
if (($order['productType'] ?? '') === 'ai_deep_report' && !empty($order['orderNo'])) {
|
||||
try {
|
||||
AiReportService::markPaid((string) $order['orderNo']);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
}
|
||||
|
||||
if (($order['productType'] ?? '') !== 'recharge') {
|
||||
// 触发分销佣金结算
|
||||
try {
|
||||
@@ -372,6 +381,43 @@ class Payment extends BaseController
|
||||
// 佣金结算失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
// 成交归因:写入一条 analytics_events,便于漏斗统计
|
||||
try {
|
||||
$userId = (int) ($order['userId'] ?? 0);
|
||||
$inviterRow = null;
|
||||
if ($userId > 0) {
|
||||
$inviterRow = Db::name('distribution_bindings')
|
||||
->where('inviteeId', $userId)
|
||||
->where('status', 'active')
|
||||
->where('expireAt', '>', time())
|
||||
->order('id', 'desc')
|
||||
->field('inviterId')
|
||||
->find();
|
||||
}
|
||||
$amountFenForEvent = (int) ($order['amount'] ?? 0);
|
||||
$props = [
|
||||
'orderId' => (int) $order['id'],
|
||||
'orderNo' => $order['orderNo'] ?? '',
|
||||
'amountFen' => $amountFenForEvent,
|
||||
'amountYuan'=> $amountFenForEvent / 100,
|
||||
'productType' => $order['productType'] ?? '',
|
||||
'testType' => $order['testType'] ?? '',
|
||||
'inviterId' => $inviterRow ? (int) ($inviterRow['inviterId'] ?? 0) : 0,
|
||||
];
|
||||
Db::name('analytics_events')->insert([
|
||||
'userId' => $userId ?: null,
|
||||
'eventName' => 'pay_success_attribution',
|
||||
'pagePath' => 'server/payment/notify',
|
||||
'propsJson' => json_encode($props, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE),
|
||||
'clientTs' => (int) ($now * 1000),
|
||||
'platform' => 'server',
|
||||
'sessionId' => null,
|
||||
'createdAt' => date('Y-m-d H:i:s', $now),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// 埋点失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
return success([
|
||||
@@ -461,6 +507,14 @@ class Payment extends BaseController
|
||||
// 企业四项测试支付后,订单金额进入企业余额
|
||||
$this->creditEnterpriseBalanceForOrder($localOrder, $finalAmount, $now);
|
||||
|
||||
// AI 深度报告:查询确认支付时补偿置 paid(幂等)
|
||||
if (($localOrder['productType'] ?? '') === 'ai_deep_report' && !empty($localOrder['orderNo'])) {
|
||||
try {
|
||||
AiReportService::markPaid((string) $localOrder['orderNo']);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
}
|
||||
|
||||
if (($localOrder['productType'] ?? '') !== 'recharge') {
|
||||
// 触发分销佣金结算
|
||||
try {
|
||||
|
||||
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