feat: 小程序与管理端迭代(神仙AI、了解自己CRM、AI测试入口、报表与分润等)

Made-with: Cursor
This commit is contained in:
卡若
2026-04-17 19:46:48 +08:00
parent cf835ea585
commit 7108f28280
239 changed files with 21061 additions and 2175 deletions

View File

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

View File

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

View File

@@ -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.inviteeIdinviter=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([

View File

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

View 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;
// 组装 messagessystem + 最近 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;
}
}

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

View File

@@ -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 共用)
*/

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

View File

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

View 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, '无权限访问');
}
}
}

View File

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

View 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, '无权限访问');
}
}
}

View 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, '无权限访问');
}
}
}

View 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, '无权限访问');
}
}
}