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

@@ -0,0 +1,132 @@
<?php
namespace app\common;
/**
* 埋点事件中文名字典
* 配合 analytics_events.eventName 展示
*/
class AnalyticsEventLabels
{
/** @var array<string,string> */
private static $MAP = [
// 基础
'page_view' => '页面浏览',
'app_launch' => '小程序启动',
'share' => '分享',
'pay_success' => '支付成功',
'pay_fail' => '支付失败',
'pay_success_attribution' => '支付成功·归因',
// 首页 / 拍照入口
'tap_start_camera' => '点击·开始拍照测试',
'tap_questionnaire_home' => '点击·首页·去做问卷',
'tap_enterprise_entry' => '点击·切换企业版',
'tap_test_select' => '点击·选择测评类型',
// 问卷类
'test_start' => '开始答题',
'test_submit' => '提交答题',
'test_complete' => '答题完成',
'test_next' => '下一题',
'test_prev' => '上一题',
'tap_upload_photo_home' => '点击·首页·上传照片',
// 付费墙
'paywall_view' => '付费墙·曝光',
// 结果页通用
'tap_read_full' => '点击·看全文',
'tap_share_moment' => '点击·分享到朋友圈',
'tap_share_friend' => '点击·分享给好友',
'tap_face_camera' => '点击·去拍照',
'tap_complete_profile' => '点击·去完善资料',
'tap_unlock_full' => '点击·解锁完整报告',
'tap_retake_test' => '点击·重新测试',
// 深度解读 / 推广(带测评类型后缀)
'tap_deep_service_from_mbti' => '点击·MBTI·深度解读方案',
'tap_deep_service_from_disc' => '点击·DISC·深度解读方案',
'tap_deep_service_from_pdp' => '点击·PDP·深度解读方案',
'tap_deep_service_from_sbti' => '点击·SBTI·深度解读方案',
'tap_deep_service_face_result' => '点击·面相·深度解读方案',
'tap_promo_from_mbti' => '点击·MBTI·推广中心',
'tap_promo_from_disc' => '点击·DISC·推广中心',
'tap_promo_from_pdp' => '点击·PDP·推广中心',
'tap_promo_from_sbti' => '点击·SBTI·推广中心',
'tap_promo_face_result' => '点击·面相·推广中心',
// 面相成交链路
'tap_questionnaire_face_result' => '点击·面相页·补做问卷',
// 我的 / 推广中心
'tap_deep_service' => '点击·我的·深度解读方案',
'tap_my_orders' => '点击·我的·我的订单',
'tap_promo_center' => '点击·进入推广中心',
'tap_promo_poster' => '点击·生成推广海报',
'tap_promo_withdraw' => '点击·提现',
'tap_promo_share' => '点击·推广分享',
// 面相 / AI 结果页
'face_analyze_success' => '面相分析成功',
'face_analyze_fail' => '面相分析失败',
// 企业 / 简历
'tap_resume_upload' => '点击·上传简历',
'tap_resume_analyze' => '点击·简历分析',
// 登录 / 绑手机
'login_silent_success' => '静默登录成功',
'login_silent_fail' => '静默登录失败',
'bind_phone_success' => '绑定手机成功',
'bind_phone_fail' => '绑定手机失败',
// 神仙 AI功能六
'tap_tab_ai_chat' => '点击·底部Tab·神仙AI',
'ai_chat_send' => '神仙AI·发送消息',
'ai_chat_receive' => '神仙AI·收到回复',
'ai_chat_degrade' => '神仙AI·降级兜底',
'ai_quick_question_click' => '神仙AI·快捷问题点击',
'tap_ai_article' => '神仙AI·点击推荐文章',
'ai_article_read' => '神仙AI·文章阅读完成',
'ai_chat_share' => '神仙AI·分享对话',
'ai_chat_share_invite_tap' => '神仙AI·点击邀请赚佣金',
'ai_report_cta_tap' => '神仙AI·点击深度报告CTA',
'ai_report_pay_tap' => '神仙AI·深度报告点击支付',
'ai_report_pay_success' => '神仙AI·深度报告支付成功',
'ai_report_share' => '神仙AI·分享深度报告',
];
/**
* 获取事件中文名。未命中时回退到原 eventName。
*/
public static function cn(string $eventName): string
{
$name = trim($eventName);
if ($name === '') return '';
return self::$MAP[$name] ?? $name;
}
/**
* 批量翻译:在数组每行补 eventNameCn 字段
* @param array<int,array<string,mixed>> $rows
*/
public static function withCn(array $rows, string $field = 'eventName', string $cnField = 'eventNameCn'): array
{
foreach ($rows as &$r) {
$name = isset($r[$field]) ? (string) $r[$field] : '';
$r[$cnField] = self::cn($name);
}
unset($r);
return $rows;
}
/**
* 返回整张字典(供管理端下拉筛选)
* @return array<string,string>
*/
public static function all(): array
{
return self::$MAP;
}
}

View File

@@ -0,0 +1,128 @@
<?php
namespace app\common\service;
use app\model\AiProvider as AiProviderModel;
use think\facade\Db;
use think\facade\Log;
/**
* AI 服务商欠费预警
*
* 复用 `feishu_lead_webhook` 的 webhookUrl但触发文案独立不用走 lead 模板);
* 每个服务商·每天·同阈值只推一次ai_balance_alerts 表)。
*
* 触发建议:
* 1) 超管页面手动点击「余额检查」
* 2) 宝塔/Linux crontab 每天 9:00 + 21:00 POST 调用
* curl -X POST -H "Authorization: Bearer {SUPERADMIN_TOKEN}" https://.../api/v1/superadmin/ai/balance-check
*/
class AiBalanceAlertService
{
/**
* 扫描所有启用且开启预警的服务商,低于阈值则推飞书
* @return array{alerted:int,skipped:int,items:array<int,array<string,mixed>>}
*/
public static function scanAndAlert(): array
{
$rows = AiProviderModel::where('enabled', 1)
->where('balanceAlertEnabled', 1)
->whereNotNull('lastBalance')
->select()
->toArray();
$alerted = 0;
$skipped = 0;
$items = [];
foreach ($rows as $row) {
$threshold = (float) ($row['balanceAlertThreshold'] ?? 0);
$balance = (float) $row['lastBalance'];
if ($threshold <= 0 || $balance > $threshold) {
continue;
}
$dateStr = date('Y-m-d');
$dup = Db::name('ai_balance_alerts')
->where('providerId', $row['providerId'])
->where('dateStr', $dateStr)
->find();
if ($dup) {
$skipped++;
$items[] = ['providerId' => $row['providerId'], 'status' => 'dedup'];
continue;
}
$ok = self::pushFeishu($row, $balance, $threshold);
if ($ok) {
try {
Db::name('ai_balance_alerts')->insert([
'providerId' => $row['providerId'],
'balance' => $balance,
'threshold' => $threshold,
'currency' => $row['lastBalanceCurrency'] ?? 'CNY',
'alertedAt' => time(),
'dateStr' => $dateStr,
]);
} catch (\Throwable $e) {}
$alerted++;
$items[] = ['providerId' => $row['providerId'], 'status' => 'alerted', 'balance' => $balance, 'threshold' => $threshold];
} else {
$items[] = ['providerId' => $row['providerId'], 'status' => 'send-failed'];
}
}
return ['alerted' => $alerted, 'skipped' => $skipped, 'items' => $items];
}
private static function pushFeishu(array $row, float $balance, float $threshold): bool
{
$cfg = FeishuLeadWebhookService::getConfig();
$url = trim((string) ($cfg['webhookUrl'] ?? ''));
if ($url === '' || stripos($url, 'http') !== 0) {
Log::warning('AiBalanceAlertService: feishu webhookUrl 未配置');
return false;
}
$currency = ($row['lastBalanceCurrency'] ?? 'CNY') === 'USD' ? '$' : '¥';
$now = date('Y-m-d H:i');
$text = "⚠️ AI 服务余额预警\n"
. "服务商: {$row['name']} ({$row['providerId']})\n"
. "当前余额: {$currency}" . number_format($balance, 2) . "\n"
. "告警阈值: {$currency}" . number_format($threshold, 2) . "\n"
. "━━━━━━━━━━\n"
. "神仙 AI 对话 / 面相分析可能受影响,已自动把该服务商降权到故障切换队尾。\n"
. "请尽快到后台充值:\n"
. "时间: {$now}";
$payload = stripos($url, 'qyapi.weixin.qq.com') !== false
? ['msgtype' => 'text', 'text' => ['content' => $text]]
: ['msg_type' => 'text', 'content' => ['text' => $text]];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json; charset=utf-8'],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 8,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code >= 400) {
Log::warning("AiBalanceAlertService feishu push http={$code}");
return false;
}
if ($body !== false && $body !== '') {
$resp = json_decode($body, true);
if (is_array($resp)) {
if (isset($resp['code']) && (int) $resp['code'] !== 0) return false;
if (isset($resp['StatusCode']) && (int) $resp['StatusCode'] !== 0) return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,623 @@
<?php
namespace app\common\service;
use app\model\AiProvider as AiProviderModel;
use think\facade\Db;
use think\facade\Log;
/**
* 神仙 AI · 统一对话调用服务
*
* 能力:
* 1. 自动按 sortWeight / id 升序挑选可用服务商
* 2. 余额低于阈值的服务商自动降权沉底
* 3. 故障自动切换429/401/402/5xx/解析错误)
* 4. 支持 OpenAI 兼容 / Anthropic 两类协议
* 5. 全部失败返回降级兜底回答,**不抛给前端**
*
* 典型用法:
* $r = AiCallService::chat([
* ['role'=>'system','content'=>'你是…'],
* ['role'=>'user','content'=>'我是 INFP今天压力大'],
* ]);
* // => ['content'=>'...', 'providerId'=>'deepseek', 'isDegraded'=>false]
*/
class AiCallService
{
/**
* 对话入口
*
* @param array $messages OpenAI messages 格式
* @param array $options ['temperature'=>0.7, 'maxTokens'=>2048, 'stream'=>false]
* @return array ['content'=>string, 'providerId'=>string, 'isDegraded'=>bool, 'tokensIn'=>int, 'tokensOut'=>int]
*/
public static function chat(array $messages, array $options = []): array
{
$maxTokens = (int) ($options['maxTokens'] ?? 2048);
$temperature = isset($options['temperature']) ? (float) $options['temperature'] : 0.7;
$providers = self::resolveProviders();
if (empty($providers)) {
return self::degrade('no-provider', '小神仙在喝茶呢,超管还没配置好法力,稍后再来找我呀~');
}
$lastError = '';
foreach ($providers as $provider) {
$providerId = strtolower((string) ($provider->providerId ?? ''));
$apiKey = $provider->getRawApiKey();
if ($apiKey === '') {
continue;
}
$endpoint = self::resolveEndpoint($provider);
$model = !empty($provider->model) ? $provider->model : self::defaultModel($providerId);
try {
if ($providerId === 'anthropic') {
$content = self::callAnthropic($endpoint, $apiKey, $model, $messages, $maxTokens);
} else {
// openai / deepseek / moonshot / qwen / zhipu / zhizengzeng / groq 等
$content = self::callOpenAICompatible($endpoint, $apiKey, $model, $messages, $maxTokens, $temperature);
}
if ($content === '') {
$lastError = "provider={$providerId} empty-content";
continue;
}
return [
'content' => $content,
'providerId' => $providerId,
'model' => $model,
'isDegraded' => false,
'tokensIn' => 0, // 各家返回字段不一,留空(可二期补 usage
'tokensOut' => 0,
];
} catch (\Throwable $e) {
$lastError = "provider={$providerId} error=" . $e->getMessage();
Log::warning('AiCallService chat fallback: ' . $lastError);
// 遇到明显欠费/鉴权类错误,给该服务商的余额打低权重沉底
if (self::looksLikeBillingError($e->getMessage())) {
try {
$provider->sortWeight = 999;
$provider->save();
} catch (\Throwable $ignore) {}
}
continue;
}
}
return self::degrade('all-failed', '小神仙暂时联系不上天界了(' . $lastError . '),工程师已收到告警,请稍后再试 🌿');
}
/**
* 选出可用服务商列表(按 sortWeight 升序、余额不足的沉底)
* @return AiProviderModel[]
*/
private static function resolveProviders(): array
{
$all = AiProviderModel::where('enabled', 1)
->whereRaw('(visible IS NULL OR visible = 1)')
->whereRaw('(apiKey IS NOT NULL AND LENGTH(TRIM(apiKey)) > 0)')
->order('sortWeight', 'asc')
->order('id', 'asc')
->select()
->toArray();
if (empty($all)) {
return [];
}
// 把「余额低于阈值」的服务商排到队尾
$healthy = [];
$depleted = [];
foreach ($all as $row) {
$provider = AiProviderModel::where('id', $row['id'])->find();
if (!$provider) continue;
$isLowBalance = $provider->balanceAlertEnabled == 1
&& $provider->lastBalance !== null
&& $provider->lastBalance <= (float) ($provider->balanceAlertThreshold ?? 0);
if ($isLowBalance) {
$depleted[] = $provider;
} else {
$healthy[] = $provider;
}
}
return array_merge($healthy, $depleted);
}
private static function resolveEndpoint($provider): string
{
$endpoint = !empty($provider->apiEndpoint) ? rtrim($provider->apiEndpoint, '/') : '';
if ($endpoint !== '') return $endpoint;
return self::defaultEndpoint(strtolower((string) $provider->providerId));
}
private static function defaultEndpoint(string $providerId): string
{
switch ($providerId) {
case 'openai': return 'https://api.openai.com/v1';
case 'groq': return 'https://api.groq.com/openai/v1';
case 'deepseek': return 'https://api.deepseek.com/v1';
case 'moonshot': return 'https://api.moonshot.ai/v1';
case 'qwen': return 'https://dashscope.aliyuncs.com/compatible-mode/v1';
case 'anthropic': return 'https://api.anthropic.com/v1';
case 'zhipu': return 'https://api.z.ai/api/paas/v4';
case 'zhizengzeng': return 'https://api.zhizengzeng.com/v1';
default: return 'https://api.openai.com/v1';
}
}
private static function defaultModel(string $providerId): string
{
switch ($providerId) {
case 'deepseek': return 'deepseek-chat';
case 'moonshot': return 'moonshot-v1-8k';
case 'qwen': return 'qwen-turbo';
case 'zhipu': return 'glm-4-flash';
case 'anthropic': return 'claude-3-5-haiku-latest';
case 'groq': return 'llama-3.1-70b-versatile';
default: return 'gpt-4o-mini';
}
}
/**
* OpenAI 兼容协议deepseek / moonshot / qwen / zhipu / zhizengzeng / groq / openai…
*/
private static function callOpenAICompatible(string $endpoint, string $apiKey, string $model, array $messages, int $maxTokens, float $temperature): string
{
$body = [
'model' => $model,
'messages' => $messages,
'temperature' => $temperature,
];
if (strpos($endpoint, 'api.zhizengzeng.com') !== false) {
$body['max_completion_tokens'] = $maxTokens;
} else {
$body['max_tokens'] = $maxTokens;
}
$url = rtrim($endpoint, '/') . '/chat/completions';
$payload = json_encode($body, JSON_UNESCAPED_UNICODE);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
],
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErrNo = curl_errno($ch);
$curlError = curl_error($ch);
curl_close($ch);
if ($response === false || $response === '') {
throw new \RuntimeException("curl-failed code={$curlErrNo} msg={$curlError}");
}
if ($httpCode >= 400) {
throw new \RuntimeException("http-{$httpCode}: " . substr((string) $response, 0, 400));
}
$data = json_decode($response, true);
if (!is_array($data)) {
throw new \RuntimeException('invalid-json');
}
if (!empty($data['error'])) {
$errMsg = is_array($data['error']) ? ($data['error']['message'] ?? json_encode($data['error'])) : (string) $data['error'];
throw new \RuntimeException('api-error: ' . $errMsg);
}
$text = $data['choices'][0]['message']['content'] ?? '';
return trim((string) $text);
}
/**
* Anthropic Messages 协议
*/
private static function callAnthropic(string $endpoint, string $apiKey, string $model, array $messages, int $maxTokens): string
{
// Anthropic 需要单独传 systemmessages 只接受 user/assistant
$system = '';
$userMessages = [];
foreach ($messages as $m) {
if (($m['role'] ?? '') === 'system') {
$system .= "\n" . ($m['content'] ?? '');
} else {
$userMessages[] = [
'role' => $m['role'] ?? 'user',
'content' => [['type' => 'text', 'text' => (string) ($m['content'] ?? '')]],
];
}
}
$body = [
'model' => $model,
'max_tokens' => $maxTokens,
'messages' => $userMessages,
];
if (trim($system) !== '') {
$body['system'] = trim($system);
}
$url = rtrim($endpoint, '/') . '/messages';
$payload = json_encode($body, JSON_UNESCAPED_UNICODE);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . $apiKey,
'anthropic-version: 2023-06-01',
],
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response === false || $response === '') {
throw new \RuntimeException('anthropic-no-response');
}
if ($httpCode >= 400) {
throw new \RuntimeException("http-{$httpCode}: " . substr((string) $response, 0, 400));
}
$data = json_decode($response, true);
$text = '';
if (!empty($data['content']) && is_array($data['content'])) {
foreach ($data['content'] as $block) {
if (($block['type'] ?? '') === 'text') {
$text .= (string) ($block['text'] ?? '');
}
}
}
return trim($text);
}
/**
* 常见欠费/鉴权类错误关键字 → 触发降权
*/
private static function looksLikeBillingError(string $msg): bool
{
$lower = strtolower($msg);
foreach (['insufficient', 'balance', 'quota', 'rate_limit', '402', '429', 'unauthorized', '401'] as $kw) {
if (strpos($lower, $kw) !== false) return true;
}
return false;
}
private static function degrade(string $reason, string $content): array
{
Log::warning('AiCallService degraded: ' . $reason);
return [
'content' => $content,
'providerId' => 'degrade',
'model' => '',
'isDegraded' => true,
'tokensIn' => 0,
'tokensOut' => 0,
];
}
/**
* 构造针对某用户的 system prompt结合 MBTI 类型 + 画像)
*/
public static function buildSystemPrompt(array $userContext): string
{
$mbtiType = trim((string) ($userContext['mbtiType'] ?? ''));
$summary = trim((string) ($userContext['summary'] ?? ''));
$nickname = trim((string) ($userContext['nickname'] ?? ''));
$prompt = "你是「神仙 AI」神仙团队 MBTI 性格小程序的专属人格伙伴。\n";
$prompt .= "说话风格:像朋友一样亲切、简短(每次回答 150 字以内必要时可延展不堆砌术语适度鼓励用户完成其他测评DISC / PDP / SBTI / 面相)或去「一场 soul 创业实验」公众号阅读相关文章。\n";
$prompt .= "禁止:做医疗诊断 / 政治议题 / 具体投资建议。\n";
if ($nickname !== '') {
$prompt .= "称呼:可以偶尔叫用户「{$nickname}」。\n";
}
if ($mbtiType !== '') {
$prompt .= "用户 MBTI{$mbtiType}。请结合此类型做个性化回答。\n";
} else {
$prompt .= "用户尚未完成 MBTI 测试。回答时可友好建议先去做一下测评。\n";
}
if ($summary !== '') {
$prompt .= "用户性格画像摘要:{$summary}\n";
}
$appendix = trim((string) ($userContext['testAppendix'] ?? ''));
if ($appendix !== '') {
$prompt .= "\n【用户测评客观记录(含问卷选项,供你结合其当前提问做针对性回答;勿机械罗列题号套话)】\n";
$prompt .= $appendix . "\n";
}
return $prompt;
}
/**
* 拉取用户 MBTI 画像(最近一次 test_results 结果)
*/
public static function fetchUserContext(int $userId): array
{
if ($userId <= 0) {
return ['mbtiType' => '', 'summary' => '', 'nickname' => '', 'testAppendix' => ''];
}
$nickname = (string) Db::name('wechat_users')->where('id', $userId)->value('nickname');
$mbtiType = '';
$summary = '';
$row = Db::name('test_results')
->where('userId', $userId)
->where('testType', 'mbti')
->order('id', 'desc')
->find();
if ($row && !empty($row['resultData'])) {
$data = is_string($row['resultData']) ? json_decode($row['resultData'], true) : $row['resultData'];
if (is_array($data)) {
$mbtiType = (string) ($data['mbtiType'] ?? ($data['mbti']['type'] ?? ''));
if (!empty($data['description']['summary'])) {
$summary = (string) $data['description']['summary'];
} elseif (!empty($data['description']['overview'])) {
$summary = (string) $data['description']['overview'];
}
}
}
// 若无 MBTI尝试回落到面相分析给出的 mbti
if ($mbtiType === '') {
$faceRow = Db::name('test_results')
->where('userId', $userId)
->where('testType', 'face')
->order('id', 'desc')
->find();
if ($faceRow && !empty($faceRow['resultData'])) {
$data = is_string($faceRow['resultData']) ? json_decode($faceRow['resultData'], true) : $faceRow['resultData'];
if (is_array($data) && isset($data['mbti']['type'])) {
$mbtiType = (string) $data['mbti']['type'];
$summary = (string) ($data['personalitySummary'] ?? ($data['overview'] ?? ''));
}
}
}
$testAppendix = self::buildLatestTestsAppendix($userId);
return [
'mbtiType' => $mbtiType,
'summary' => $summary,
'nickname' => $nickname,
'testAppendix' => $testAppendix,
];
}
/**
* 汇总用户最近一次各类型测评的答题与结果,供 system prompt 使用(有长度上限)
*/
private static function buildLatestTestsAppendix(int $userId): string
{
$types = ['mbti', 'sbti', 'disc', 'pdp'];
$blocks = [];
foreach ($types as $type) {
$row = Db::name('test_results')
->where('userId', $userId)
->where('testType', $type)
->order('id', 'desc')
->find();
if (!$row || empty($row['resultData'])) {
continue;
}
$data = is_string($row['resultData']) ? json_decode($row['resultData'], true) : $row['resultData'];
if (!is_array($data)) {
continue;
}
$block = self::formatTestBlockForPrompt($type, $data);
if ($block !== '') {
$blocks[] = $block;
}
}
$text = implode("\n——\n", $blocks);
if ($text === '') {
return '';
}
$maxLen = 3800;
if (mb_strlen($text, 'UTF-8') > $maxLen) {
$text = mb_substr($text, 0, $maxLen, 'UTF-8') . '…(档案已截断)';
}
return $text;
}
/**
* @param array<string, mixed> $data
*/
private static function formatTestBlockForPrompt(string $testType, array $data): string
{
switch ($testType) {
case 'mbti':
return self::formatMbtiBlockForPrompt($data);
case 'sbti':
return self::formatSbtiBlockForPrompt($data);
case 'disc':
return self::formatDiscBlockForPrompt($data);
case 'pdp':
return self::formatPdpBlockForPrompt($data);
default:
return '';
}
}
/**
* @param array<string, mixed>|null $answers
*/
private static function formatAnswersCompact(?array $answers): string
{
if (!is_array($answers) || $answers === []) {
return '';
}
$keys = array_keys($answers);
sort($keys, SORT_NATURAL);
$parts = [];
foreach ($keys as $k) {
$v = $answers[$k];
if (is_array($v) || is_object($v)) {
$v = json_encode($v, JSON_UNESCAPED_UNICODE);
}
$parts[] = (string) $k . '→' . (string) $v;
if (count($parts) >= 100) {
$parts[] = '…共' . count($keys) . '题';
break;
}
}
return implode('', $parts);
}
/**
* @param array<string, mixed> $data
*/
private static function formatMbtiBlockForPrompt(array $data): string
{
$lines = ['【MBTI·最近一次】'];
$type = (string) ($data['mbtiType'] ?? ($data['mbti']['type'] ?? ''));
if ($type !== '') {
$lines[] = '类型:' . $type;
}
if (isset($data['confidence'])) {
$lines[] = '置信度:' . (int) $data['confidence'] . '%';
}
if (!empty($data['dimensionScores']) && is_array($data['dimensionScores'])) {
$lines[] = '四轴:' . json_encode($data['dimensionScores'], JSON_UNESCAPED_UNICODE);
}
if (!empty($data['scores']) && is_array($data['scores'])) {
$lines[] = '字母计数:' . json_encode($data['scores'], JSON_UNESCAPED_UNICODE);
}
$ans = self::formatAnswersCompact(isset($data['answers']) && is_array($data['answers']) ? $data['answers'] : null);
if ($ans !== '') {
$lines[] = '逐题选项(题号/ID→所选' . $ans;
}
return implode("\n", $lines);
}
/**
* @param array<string, mixed> $data
*/
private static function formatSbtiBlockForPrompt(array $data): string
{
$lines = ['【SBTI·最近一次】'];
$final = $data['finalType'] ?? null;
$fcode = is_array($final) ? (string) ($final['code'] ?? '') : '';
$code = (string) ($data['sbtiType'] ?? $fcode);
if ($code !== '') {
$lines[] = '类型代码:' . $code;
}
if (!empty($data['special'])) {
$lines[] = '特殊标记:' . json_encode($data['special'], JSON_UNESCAPED_UNICODE);
}
$ans = self::formatAnswersCompact(isset($data['answers']) && is_array($data['answers']) ? $data['answers'] : null);
if ($ans !== '') {
$lines[] = '逐题选项:' . $ans;
}
return implode("\n", $lines);
}
/**
* @param array<string, mixed> $data
*/
private static function formatDiscBlockForPrompt(array $data): string
{
$lines = ['【DISC·最近一次】'];
if (!empty($data['dominantType'])) {
$lines[] = '主导:' . (string) $data['dominantType'] . ',次要:' . (string) ($data['secondaryType'] ?? '');
}
if (!empty($data['scores']) && is_array($data['scores'])) {
$lines[] = '得分:' . json_encode($data['scores'], JSON_UNESCAPED_UNICODE);
}
$ans = self::formatAnswersCompact(isset($data['answers']) && is_array($data['answers']) ? $data['answers'] : null);
if ($ans !== '') {
$lines[] = '逐题选项:' . $ans;
}
return implode("\n", $lines);
}
/**
* @param array<string, mixed> $data
*/
private static function formatPdpBlockForPrompt(array $data): string
{
$lines = ['【PDP·最近一次】'];
if (!empty($data['dominantType'])) {
$lines[] = '主导:' . (string) $data['dominantType'] . ',次要:' . (string) ($data['secondaryType'] ?? '');
}
if (!empty($data['scores']) && is_array($data['scores'])) {
$lines[] = '得分:' . json_encode($data['scores'], JSON_UNESCAPED_UNICODE);
}
$ans = self::formatAnswersCompact(isset($data['answers']) && is_array($data['answers']) ? $data['answers'] : null);
if ($ans !== '') {
$lines[] = '逐题选项:' . $ans;
}
return implode("\n", $lines);
}
/**
* 过滤不在小程序展示的快捷问句(与产品配置一致)
*
* @param string[] $questions
* @return string[]
*/
public static function filterQuickQuestions(array $questions): array
{
$out = [];
foreach ($questions as $q) {
$s = trim((string) $q);
if ($s === '') {
continue;
}
if (preg_match('/记\s*一下\s*我的\s*MBTI/ui', $s)) {
continue;
}
$out[] = $s;
}
return $out;
}
/**
* 基于 MBTI 类型返回 3 条快捷问句
*/
public static function quickQuestions(string $mbtiType): array
{
$mbtiType = strtoupper(trim($mbtiType));
$generic = [
'我应该找什么样的工作?',
'我适合什么样的伴侣?',
'我的职业发展方向是什么?',
'我最近有点迷茫,有什么建议?',
'帮我做一个简短的自我介绍',
'我有哪些需要警惕的盲点?',
];
if ($mbtiType === '') return $generic;
return [
"作为 {$mbtiType},我最大的优势和盲点是什么?",
"{$mbtiType} 适合什么样的工作?",
"{$mbtiType} 适合找什么样的伴侣?",
"{$mbtiType} 的职业发展路径推荐?",
"{$mbtiType} 如何处理职场人际关系?",
"{$mbtiType} 最容易掉进什么心理陷阱?",
];
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace app\common\service;
use think\facade\Db;
/**
* 神仙 AI 页 · 推荐文章区块展示(超管可配)
*
* system_config.key = ai_chat_articles, enterprise_id = 0
* JSON: { enabled, maxShow(1-3), sectionExpandedDefault, profileRecoEnabled, profileSectionLabel }
*/
class AiChatArticleDisplayService
{
public const CONFIG_KEY = 'ai_chat_articles';
/** 我的页推荐条默认标题(后台可改) */
public const DEFAULT_PROFILE_SECTION_LABEL = '推荐阅读';
/**
* @return array{enabled:bool,maxShow:int,sectionExpandedDefault:bool,profileRecoEnabled:bool,profileSectionLabel:string}
*/
public static function getSettings(): array
{
$defaults = [
'enabled' => false,
'maxShow' => 1,
'sectionExpandedDefault' => false,
'profileRecoEnabled' => false,
'profileSectionLabel' => self::DEFAULT_PROFILE_SECTION_LABEL,
];
$row = Db::name('system_config')
->where('key', self::CONFIG_KEY)
->where('enterprise_id', 0)
->find();
if (!$row || empty($row['value'])) {
return $defaults;
}
$v = is_string($row['value']) ? json_decode($row['value'], true) : $row['value'];
if (!is_array($v)) {
return $defaults;
}
$maxShow = (int) ($v['maxShow'] ?? 1);
if ($maxShow < 1) {
$maxShow = 1;
}
if ($maxShow > 3) {
$maxShow = 3;
}
$profileLabel = isset($v['profileSectionLabel']) ? trim((string) $v['profileSectionLabel']) : '';
if ($profileLabel === '') {
$profileLabel = self::DEFAULT_PROFILE_SECTION_LABEL;
}
if (function_exists('mb_substr')) {
$profileLabel = mb_substr($profileLabel, 0, 32, 'UTF-8');
} else {
$profileLabel = substr($profileLabel, 0, 32);
}
return [
'enabled' => !empty($v['enabled']),
'maxShow' => $maxShow,
'sectionExpandedDefault' => !empty($v['sectionExpandedDefault']),
'profileRecoEnabled' => !empty($v['profileRecoEnabled']),
'profileSectionLabel' => $profileLabel,
];
}
/**
* @param array<string, mixed> $input
* @return array{enabled:bool,maxShow:int,sectionExpandedDefault:bool,profileRecoEnabled:bool,profileSectionLabel:string}
*/
public static function saveSettings(array $input): array
{
$rawLabel = isset($input['profileSectionLabel']) ? trim((string) $input['profileSectionLabel']) : '';
if ($rawLabel === '') {
$rawLabel = self::DEFAULT_PROFILE_SECTION_LABEL;
}
if (function_exists('mb_substr')) {
$rawLabel = mb_substr($rawLabel, 0, 32, 'UTF-8');
} else {
$rawLabel = substr($rawLabel, 0, 32);
}
$settings = [
'enabled' => !empty($input['enabled']),
'maxShow' => max(1, min(3, (int) ($input['maxShow'] ?? 1))),
'sectionExpandedDefault' => !empty($input['sectionExpandedDefault']),
'profileRecoEnabled' => !empty($input['profileRecoEnabled']),
'profileSectionLabel' => $rawLabel,
];
$json = json_encode($settings, JSON_UNESCAPED_UNICODE);
$now = time();
$exists = Db::name('system_config')
->where('key', self::CONFIG_KEY)
->where('enterprise_id', 0)
->find();
if ($exists) {
Db::name('system_config')
->where('key', self::CONFIG_KEY)
->where('enterprise_id', 0)
->update(['value' => $json, 'updatedAt' => $now]);
} else {
Db::name('system_config')->insert([
'key' => self::CONFIG_KEY,
'enterprise_id' => 0,
'value' => $json,
'description' => '神仙 AI / 我的页:推荐文章展示配置',
'createdAt' => $now,
'updatedAt' => $now,
]);
}
return $settings;
}
}

View File

@@ -0,0 +1,206 @@
<?php
namespace app\common\service;
use app\model\AiReport as AiReportModel;
use think\facade\Db;
use think\facade\Log;
/**
* AI 深度画像报告服务
*
* 生命周期:
* pending创建→ paid支付成功 / devmarkPaidDev
* → generating后台开始调 AI → done生成完成
* 失败时置 failed + 自动重试retryCount<=3
*/
class AiReportService
{
const PRICE_FEN = 990; // 9.9 元
public static function createOrGetPending(int $userId, int $conversationId = 0, string $mbtiType = ''): array
{
// 已有 pending/paid/generating/done → 返回最新那条
$existing = AiReportModel::where('userId', $userId)
->order('id', 'desc')
->find();
if ($existing && in_array($existing->status, ['pending', 'paid', 'generating', 'done'])) {
// 已完成的报告允许用户再做一份?按默认:只有 done 才允许再发起新的
if ($existing->status !== 'done') {
return self::toArray($existing);
}
}
$orderSn = 'AIR' . date('YmdHis') . mt_rand(1000, 9999);
$model = AiReportModel::create([
'userId' => $userId,
'conversationId' => $conversationId ?: null,
'mbtiType' => $mbtiType ?: null,
'orderSn' => $orderSn,
'priceFen' => self::PRICE_FEN,
'status' => 'pending',
]);
return self::toArray($model);
}
public static function myLatest(int $userId): ?array
{
$m = AiReportModel::where('userId', $userId)->order('id', 'desc')->find();
return $m ? self::toArray($m) : null;
}
public static function get(int $id, int $userId): ?array
{
$m = AiReportModel::where('id', $id)->where('userId', $userId)->find();
return $m ? self::toArray($m) : null;
}
/**
* 支付成功回调:把 report 置为 paid并触发异步生成
* 同时执行订单分账(走 ProfitSharingService
*/
public static function markPaid(string $orderSn): array
{
$m = AiReportModel::where('orderSn', $orderSn)->find();
if (!$m) return ['status' => 'not-found'];
if ($m->status !== 'pending') {
// 已处理过
return ['status' => 'already', 'reportStatus' => $m->status];
}
$m->status = 'paid';
$m->paidAt = time();
$m->save();
// 分账idempotent
try {
ProfitSharingService::executeSharing(
$m->orderSn,
'ai_deep_report',
(int) $m->priceFen,
['userId' => (int) $m->userId, 'orderId' => (int) $m->id]
);
} catch (\Throwable $e) {
Log::warning('AiReportService profit-sharing failed: ' . $e->getMessage());
}
// 同步生成报告(若耗时 >30s 可改异步队列,此处 PHP fpm 环境先同步,失败自动降级)
self::generate((int) $m->id);
$fresh = AiReportModel::find($m->id);
return ['status' => 'ok', 'report' => self::toArray($fresh)];
}
/** 调试/内部:跳过支付直接 markPaid */
public static function markPaidDev(string $orderSn): array
{
return self::markPaid($orderSn);
}
/**
* 生成报告正文(调 AI
*/
public static function generate(int $reportId): void
{
$m = AiReportModel::find($reportId);
if (!$m) return;
if (!in_array($m->status, ['paid', 'failed', 'generating'])) return;
$m->status = 'generating';
$m->retryCount = (int) $m->retryCount + 1;
$m->save();
try {
$user = Db::name('user')->where('id', (int) $m->userId)->find();
$mbti = $m->mbtiType ?: ($user['mbti_type'] ?? '未知');
$nickname = $user['nickname'] ?? '朋友';
// 拉取最近对话作为输入素材
$history = [];
if (!empty($m->conversationId)) {
$msgs = Db::name('ai_messages')
->where('conversationId', (int) $m->conversationId)
->order('id', 'asc')
->limit(40)
->select()
->toArray();
foreach ($msgs as $msg) {
if ($msg['role'] === 'system') continue;
$history[] = ($msg['role'] === 'user' ? '【你】' : '【神仙AI】') . "" . $msg['content'];
}
}
$system = "你是「神仙 AI」要基于用户的 MBTI 类型与最近的对话,输出一份具个性化的深度画像报告。\n"
. "报告要求:\n"
. "1) 长度 1200-1800 字,分 5 个章节(性格内核 / 优势雷达 / 潜在盲点 / 亲密关系建议 / 下一步成长路径)\n"
. "2) 每章 2-3 段,语气温暖不说教;禁止玄学与迷信\n"
. "3) 首段总结一句,用一条「超短金句」概括这个人\n"
. "4) 使用第二人称「你」,称呼:{$nickname}\n"
. "5) Markdown 纯文本,章节用 「## 章节标题」 "
. "";
$prompt = "【用户 MBTI】{$mbti}\n【最近对话】\n" . (empty($history) ? '(无历史对话)' : implode("\n", $history));
$r = AiCallService::chat([
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $prompt],
], ['maxTokens' => 3000, 'temperature' => 0.75]);
$content = (string) ($r['content'] ?? '');
if ($content === '' || !empty($r['isDegraded'])) {
throw new \RuntimeException('AI 生成失败或降级:' . (string) ($r['content'] ?? ''));
}
$summary = self::extractSummary($content);
$title = self::extractTitle($content, $mbti);
$m->title = $title;
$m->summary = $summary;
$m->content = $content;
$m->status = 'done';
$m->generatedAt = time();
$m->lastError = null;
$m->save();
} catch (\Throwable $e) {
Log::warning('AiReportService generate fail: ' . $e->getMessage());
$m->status = $m->retryCount >= 3 ? 'failed' : 'paid';
$m->lastError = mb_substr($e->getMessage(), 0, 480);
$m->save();
}
}
private static function extractSummary(string $md): string
{
$plain = preg_replace('/[#>*_`\[\]]+/', '', $md);
$plain = trim((string) $plain);
return mb_substr($plain, 0, 180);
}
private static function extractTitle(string $md, string $mbti): string
{
if (preg_match('/^\s*#+\s*(.+)$/m', $md, $mch)) {
return mb_substr(trim($mch[1]), 0, 80);
}
return "{$mbti} · 神仙 AI 深度画像";
}
private static function toArray(AiReportModel $m): array
{
return [
'id' => (int) $m->id,
'userId' => (int) $m->userId,
'conversationId' => (int) $m->conversationId,
'mbtiType' => $m->mbtiType,
'orderSn' => $m->orderSn,
'priceFen' => (int) $m->priceFen,
'priceYuan' => round($m->priceFen / 100, 2),
'status' => $m->status,
'title' => $m->title,
'summary' => $m->summary,
'content' => $m->content,
'posterUrl' => $m->posterUrl,
'paidAt' => (int) $m->paidAt,
'generatedAt' => (int) $m->generatedAt,
'createdAt' => (int) $m->createdAt,
];
}
}

View File

@@ -76,6 +76,26 @@ class FeishuLeadWebhookService
]);
}
/**
* 小程序「了解自己」页提交咨询申请(与 CrmReport 存客宝上报并行;飞书机器人需 feishu_lead_webhook.enabled
*/
public static function onDeepServiceConsultApply(int $userId, string $source, string $categoryTag): void
{
if ($userId <= 0) {
return;
}
$src = trim($source) !== '' ? trim($source) : '深度服务·申请咨询';
$tag = trim($categoryTag);
$dedup = 'deep_consult_apply:' . $userId . ':' . md5($src . '|' . $tag) . ':' . date('YmdH');
$extra = $tag !== '' ? ('套餐: ' . self::oneLine($tag, 60)) : '';
self::pushLead([
'dedupKey' => $dedup,
'userId' => $userId,
'source' => $src,
'extraLine' => $extra,
]);
}
/**
* @param array{dedupKey:string,userId:int,source:string,phone?:string,extraLine?:string} $p
*/
@@ -127,7 +147,7 @@ class FeishuLeadWebhookService
$text .= "\n" . $p['extraLine'];
}
$lines = self::recentBehaviorLines($userId, 8);
$lines = UserJourneyService::recentBehaviorLines($userId, 8);
if (count($lines) > 0) {
$text .= "\n━━━━━━━━━━\n最近行为:";
$i = 1;
@@ -184,85 +204,6 @@ class FeishuLeadWebhookService
return $s;
}
private static function recentBehaviorLines(int $userId, int $limit): array
{
if ($userId <= 0) {
return [];
}
try {
$rows = Db::name('analytics_events')
->where('userId', $userId)
->order('id', 'desc')
->limit($limit)
->select()
->toArray();
} catch (\Throwable $e) {
return [];
}
$out = [];
foreach ($rows as $r) {
$out[] = self::formatAnalyticsLine($r);
}
return $out;
}
private static function formatAnalyticsLine(array $r): string
{
$name = (string) ($r['eventName'] ?? '');
$path = trim((string) ($r['pagePath'] ?? ''));
$props = [];
if (!empty($r['propsJson'])) {
$decoded = is_string($r['propsJson']) ? json_decode($r['propsJson'], true) : [];
$props = is_array($decoded) ? $decoded : [];
}
$labelMap = [
'page_view' => '浏览页面',
'button_click' => '按钮点击',
'click_pay' => '发起支付',
'click_recharge'=> '点击充值',
];
$label = $labelMap[$name] ?? $name;
$detail = '';
if ($name === 'page_view' && $path !== '') {
$detail = $path;
}
if (isset($props['action']) && (string) $props['action'] !== '') {
$detail = (string) $props['action'];
if (!empty($props['productType'])) {
$detail .= ' · ' . (string) $props['productType'];
}
} elseif (isset($props['label']) && (string) $props['label'] !== '') {
$detail = (string) $props['label'];
} elseif ($path !== '' && $detail === '') {
$detail = $path;
}
$line = $detail !== '' ? "{$label}: {$detail}" : $label;
$ts = isset($r['clientTs']) ? (int) $r['clientTs'] : null;
if (!$ts && !empty($r['createdAt'])) {
$ts = strtotime((string) $r['createdAt']) * 1000;
}
if ($ts) {
$line .= ' · ' . self::humanTimeAgoCn((int) round($ts));
}
return $line;
}
private static function humanTimeAgoCn(int $clientTsMs): string
{
$now = (int) (microtime(true) * 1000);
$sec = max(0, (int) (($now - $clientTsMs) / 1000));
if ($sec < 60) {
return '刚刚';
}
if ($sec < 3600) {
return (int) floor($sec / 60) . '分钟前';
}
if ($sec < 86400) {
return (int) floor($sec / 3600) . '小时前';
}
return (int) floor($sec / 86400) . '天前';
}
private static function beginDedup(string $dedupKey): bool
{
try {

View File

@@ -243,8 +243,29 @@ class OutboundPushHookService
$lines[] = '测试结果: ' . self::truncatePlainText((string) ($payload['resultSummary'] ?? ''), 100) . "\n";
}
$lines[] = '测试时间: ' . $testTime;
$body = implode('', $lines);
$mgmtSummary = trim((string) ($payload['managementSummary'] ?? ''));
if ($mgmtSummary !== '') {
$body .= "\n━━━━━━━━━━\n用户管理:\n" . $mgmtSummary;
}
$um = $payload['userManagement'] ?? null;
if (is_array($um) && !empty($um['openidTail6'])) {
$body .= "\nOpenID尾号(脱敏): " . (string) $um['openidTail6'];
}
$beh = $payload['recentBehaviors'] ?? [];
if (is_array($beh) && count($beh) > 0) {
$body .= "\n━━━━━━━━━━\n最近行为:";
$i = 1;
foreach ($beh as $bl) {
if (!is_string($bl) || $bl === '') {
continue;
}
$body .= "\n {$i}. {$bl}";
$i++;
}
}
return implode('', $lines);
return $body;
default:
return $head . "\n事件: " . $event;
}
@@ -590,6 +611,50 @@ class OutboundPushHookService
return 0;
}
/**
* 测评完成 JSON / 飞书文本:附加用户管理字段 + analytics 用户旅程(与 FeishuLeadWebhookService 获客卡片「最近行为」同源)
*
* @param array<string,mixed> $payload 引用
* @param array<string,mixed> $testResultRow test_results 一行
* @param array<string,mixed>|null $wuArr wechat_users 行(可含 openid
*/
private static function mergeTestResultUserJourneyPayload(array &$payload, int $userId, array $testResultRow, ?array $wuArr): void
{
if ($userId <= 0) {
$payload['recentBehaviors'] = [];
$payload['userManagement'] = [
'wechatUserId' => 0,
'enterpriseId' => 0,
'enterpriseName' => null,
'openidTail6' => null,
'testScope' => (string) ($testResultRow['testScope'] ?? ''),
];
$payload['managementSummary'] = '';
return;
}
$eidResolved = self::resolveEnterpriseIdForTestResult($testResultRow, $wuArr);
$tenant = self::tenantPayload($eidResolved);
$openid = '';
if ($wuArr !== null && isset($wuArr['openid'])) {
$openid = trim((string) $wuArr['openid']);
}
$tail = '';
if ($openid !== '') {
$tail = strlen($openid) >= 6 ? substr($openid, -6) : $openid;
}
$payload['userManagement'] = [
'wechatUserId' => $userId,
'enterpriseId' => $eidResolved,
'enterpriseName' => $tenant['enterpriseName'],
'openidTail6' => $tail !== '' ? $tail : null,
'testScope' => (string) ($testResultRow['testScope'] ?? ''),
];
$payload['recentBehaviors'] = UserJourneyService::recentBehaviorLines($userId, 12);
$trEid = (int) ($testResultRow['enterpriseId'] ?? 0);
$payload['managementSummary'] = UserJourneyService::managementSummaryLine($userId, $trEid);
}
public static function isEventEnabled(string $event, array $cfg): bool
{
if (empty($cfg['enabled'])) {
@@ -712,7 +777,7 @@ class OutboundPushHookService
$completedAt = date('Y-m-d H:i:s', $createdAt);
$wu = $userId > 0
? Db::name('wechat_users')->where('id', $userId)->field('nickname,phone,enterpriseId')->find()
? Db::name('wechat_users')->where('id', $userId)->field('nickname,phone,enterpriseId,openid')->find()
: null;
$userName = $wu ? trim((string) ($wu['nickname'] ?? '')) : '';
if ($userName === '') {
@@ -765,6 +830,7 @@ class OutboundPushHookService
$payload['resultDisc'] = $dims['disc'];
}
}
self::mergeTestResultUserJourneyPayload($payload, $userId, $row, $wuArr);
self::dispatch('test.result_completed', [
'event' => 'test.result_completed',
@@ -807,7 +873,7 @@ class OutboundPushHookService
$completedAt = date('Y-m-d H:i:s', $createdAt);
$wu = $userId > 0
? Db::name('wechat_users')->where('id', $userId)->field('nickname,phone,enterpriseId')->find()
? Db::name('wechat_users')->where('id', $userId)->field('nickname,phone,enterpriseId,openid')->find()
: null;
$userName = $wu ? trim((string) ($wu['nickname'] ?? '')) : '';
if ($userName === '') {
@@ -860,6 +926,7 @@ class OutboundPushHookService
$payload['resultDisc'] = $dims['disc'];
}
}
self::mergeTestResultUserJourneyPayload($payload, $userId, $row, $wuArr);
return self::dispatchDetailed('test.result_completed', [
'event' => 'test.result_completed',

View File

@@ -0,0 +1,259 @@
<?php
namespace app\common\service;
use think\facade\Db;
use think\facade\Log;
/**
* 订单分账服务(参考知己 profit-sharing 思路)
*
* 用法:订单支付成功后调用 ::executeSharing($orderSn, $productType, $totalFen, $extra)
* - 按 mbti_profit_sharing_rules 配置把 totalFen 拆给多个 receiver
* - 对 distributor_l1 / distributor_l2会联动现有 mbti_commission_records 复用项目已有分销系统
* - 最终写入 mbti_profit_sharing_records 一行orderSn 唯一details JSON 里记所有明细
*
* 该服务 **idempotent**:同一 orderSn 重复调用不会重复分账。
*/
class ProfitSharingService
{
/**
* @param string $orderSn 订单号(唯一)
* @param string $productType 产品类型,对应 mbti_profit_sharing_rules.productType找不到则走 default
* @param int $totalFen 订单总金额(分)
* @param array $extra 扩展 ['userId'=>购买者id, 'orderId'=>订单表id, 'consultantId'=>?]
* @return array 分账明细
*/
public static function executeSharing(string $orderSn, string $productType, int $totalFen, array $extra = []): array
{
if ($totalFen <= 0 || $orderSn === '') {
return ['status' => 'skipped', 'reason' => 'invalid-input'];
}
$dup = Db::name('profit_sharing_records')->where('orderSn', $orderSn)->find();
if ($dup) {
return ['status' => 'dedup', 'recordId' => $dup['id']];
}
$rule = self::resolveRule($productType);
$receivers = $rule['receivers'] ?? [];
if (!is_array($receivers) || count($receivers) === 0) {
$receivers = [['type' => 'platform', 'name' => '平台', 'ratio' => 1.0]];
}
$totalRatio = 0.0;
foreach ($receivers as $r) { $totalRatio += (float) ($r['ratio'] ?? 0); }
if (abs($totalRatio - 1.0) > 0.001) {
Log::warning("ProfitSharingService: rule {$productType} ratio sum={$totalRatio}, will renormalize");
}
$now = time();
$details = [];
$buyerUserId = (int) ($extra['userId'] ?? 0);
// 一级 / 二级分销人解析(复用现有 distribution_bindings
list($inviterL1, $inviterL2) = self::resolveInviters($buyerUserId);
$allocated = 0;
foreach ($receivers as $idx => $r) {
$ratio = (float) ($r['ratio'] ?? 0);
$amount = (int) floor($totalFen * $ratio);
$type = (string) ($r['type'] ?? 'platform');
$account = null;
$status = 'success';
if ($type === 'distributor_l1') {
$account = $inviterL1 ? (string) $inviterL1 : null;
if (!$inviterL1) {
// 没有一级分销 → 金额归还平台
$status = 'no-inviter';
$amount = 0;
}
} elseif ($type === 'distributor_l2') {
$account = $inviterL2 ? (string) $inviterL2 : null;
if (!$inviterL2) {
$status = 'no-inviter';
$amount = 0;
}
} elseif ($type === 'consultant') {
$account = isset($extra['consultantId']) ? (string) $extra['consultantId'] : null;
}
$details[] = [
'receiverType' => $type,
'receiverName' => (string) ($r['name'] ?? $type),
'amount' => $amount,
'ratio' => $ratio,
'account' => $account,
'status' => $status,
];
$allocated += $amount;
}
// 尾差归平台
if ($allocated < $totalFen) {
$diff = $totalFen - $allocated;
foreach ($details as &$d) {
if ($d['receiverType'] === 'platform') { $d['amount'] += $diff; break; }
}
unset($d);
}
// 写主记录
$recordId = Db::name('profit_sharing_records')->insertGetId([
'orderSn' => $orderSn,
'orderId' => (int) ($extra['orderId'] ?? 0) ?: null,
'productType' => $productType,
'totalAmount' => $totalFen,
'details' => json_encode($details, JSON_UNESCAPED_UNICODE),
'status' => 'processing',
'createdAt' => $now,
'updatedAt' => $now,
]);
// 联动现有分销系统:把 distributor_l1/l2 的金额写进 mbti_commission_records
try {
foreach ($details as $d) {
if (in_array($d['receiverType'], ['distributor_l1', 'distributor_l2'], true)
&& (int) $d['amount'] > 0 && !empty($d['account'])) {
self::createCommissionRecord(
(int) $d['account'],
$buyerUserId,
$d['receiverType'] === 'distributor_l1' ? 1 : 2,
(int) $d['amount'],
(float) $d['ratio'],
$totalFen,
$orderSn,
(int) ($extra['orderId'] ?? 0)
);
}
}
} catch (\Throwable $e) {
Log::warning('ProfitSharingService commission hook failed: ' . $e->getMessage());
}
Db::name('profit_sharing_records')->where('id', $recordId)->update([
'status' => 'completed',
'processedAt' => time(),
'updatedAt' => time(),
]);
return [
'status' => 'completed',
'recordId' => $recordId,
'details' => $details,
];
}
private static function resolveRule(string $productType): array
{
$row = Db::name('profit_sharing_rules')
->where('productType', $productType)
->where('status', 'active')
->find();
if (!$row) {
$row = Db::name('profit_sharing_rules')
->where('productType', 'default')
->where('status', 'active')
->find();
}
if (!$row) {
return ['receivers' => [['type' => 'platform', 'name' => '平台', 'ratio' => 1.0]]];
}
$receivers = json_decode($row['receivers'] ?? '[]', true) ?: [];
return ['receivers' => $receivers];
}
/**
* 解析一级/二级分销人(复用 mbti_distribution_bindings
* @return array [inviterL1_userId|null, inviterL2_userId|null]
*/
private static function resolveInviters(int $buyerUserId): array
{
if ($buyerUserId <= 0) return [null, null];
try {
$now = time();
$bind1 = Db::name('distribution_bindings')
->where('inviteeId', $buyerUserId)
->where('status', 'active')
->where('expireAt', '>', $now)
->order('id', 'desc')
->find();
if (!$bind1) return [null, null];
$l1 = (int) $bind1['inviterId'];
$bind2 = Db::name('distribution_bindings')
->where('inviteeId', $l1)
->where('status', 'active')
->where('expireAt', '>', $now)
->order('id', 'desc')
->find();
$l2 = $bind2 ? (int) $bind2['inviterId'] : null;
return [$l1, $l2];
} catch (\Throwable $e) {
return [null, null];
}
}
/**
* 写入 mbti_commission_records复用项目已有的分销佣金表结构
* 若该表结构/字段命名不同,请在本方法内做适配;当前按 mbti_data.sql 中看到的字段写入
*/
private static function createCommissionRecord(
int $inviterId,
int $inviteeId,
int $level,
int $amountFen,
float $rate,
int $orderTotalFen,
string $orderSn,
int $orderId
): void {
$now = time();
// 防重:相同 orderSn + inviterId + level 只插一次
$dup = Db::name('commission_records')
->where('orderSn', $orderSn)
->where('inviterId', $inviterId)
->where('level', $level)
->find();
if ($dup) return;
// 字段按 mbti_commission_records 实际结构写amountFen 存分、amount 存元(容错)
$data = [
'inviterId' => $inviterId,
'inviteeId' => $inviteeId,
'level' => $level,
'rate' => $rate,
'amount' => round($amountFen / 100, 2),
'amountFen' => $amountFen,
'orderId' => $orderId ?: null,
'orderSn' => $orderSn,
'orderAmountFen'=> $orderTotalFen,
'status' => 'pending',
'scope' => 'personal',
'source' => 'ai_deep_report',
'createdAt' => $now,
'updatedAt' => $now,
];
try {
Db::name('commission_records')->insert($data);
} catch (\Throwable $e) {
// 字段不匹配时退化:只写关键字段
Log::warning('commission_records insert fallback: ' . $e->getMessage());
try {
Db::name('commission_records')->insert([
'inviterId' => $inviterId,
'inviteeId' => $inviteeId,
'level' => $level,
'rate' => $rate,
'amount' => round($amountFen / 100, 2),
'status' => 'pending',
'orderId' => $orderId ?: null,
'createdAt' => $now,
'updatedAt' => $now,
]);
} catch (\Throwable $e2) {
Log::error('commission_records insert failed: ' . $e2->getMessage());
}
}
}
}

View File

@@ -0,0 +1,441 @@
<?php
namespace app\common\service;
use app\model\SoulArticle as SoulArticleModel;
use app\model\SystemConfig as SystemConfigModel;
use think\facade\Db;
use think\facade\Log;
/**
* Soul 文章采集与推荐服务
*
* 数据源:一场 soul 创业实验 对外 API默认 base 见 resolveConfig
* 采集方式:仅本服务内 curl HTTP(S) 请求上述接口;不写磁盘拉取、不经 SSH、不连对方服务器 shell
* 鉴权system_config.key=`soul_api` 的 {baseUrl, token};环境变量 SOUL_API_BASE / SOUL_API_TOKEN 兜底
*/
class SoulArticleService
{
/**
* 按关键词搜索 Soul 内容并写入本地文章池
*/
public static function syncByKeyword(string $keyword, int $limit = 10, string $tag = 'MBTI'): array
{
$keyword = trim($keyword);
if ($keyword === '') {
return ['fetched' => 0, 'created' => 0, 'updated' => 0, 'items' => [], 'error' => '关键词不能为空'];
}
$cfg = self::resolveConfig();
if ($cfg['baseUrl'] === '') {
return ['fetched' => 0, 'created' => 0, 'updated' => 0, 'items' => [], 'error' => 'soul_api.baseUrl 未配置'];
}
$base = rtrim($cfg['baseUrl'], '/');
$headers = ['Accept: application/json'];
if ($cfg['token'] !== '') {
$headers[] = 'Authorization: Bearer ' . $cfg['token'];
}
// 优先尝试通用文章接口(若支持 keyword
$url = $base . '/api/articles?' . http_build_query([
'keyword' => $keyword,
'limit' => $limit,
'sort' => 'publishedAt:desc',
]);
[$response] = self::httpGet($url, $headers);
$data = is_string($response) ? json_decode($response, true) : null;
$list = [];
if (is_array($data)) {
if (isset($data['data']['list']) && is_array($data['data']['list'])) {
$list = $data['data']['list'];
} elseif (isset($data['data']) && is_array($data['data']) && isset($data['data'][0])) {
$list = $data['data'];
} elseif (isset($data['items']) && is_array($data['items'])) {
$list = $data['items'];
} elseif (isset($data[0])) {
$list = $data;
}
}
// 若无结果,回退到 book API 全量抓取后关键词过滤
if (empty($list)) {
$list = self::fetchFromSoulBookApi($base, max($limit * 3, 30), $tag, $keyword);
}
return self::upsertArticles($list, $tag, $limit);
}
/**
* 从 Soul 后台拉取最新 N 篇 MBTI 主题文章并写入本地 soul_articles 表
*
* @return array ['fetched'=>int, 'created'=>int, 'updated'=>int, 'items'=>[…]]
*/
public static function syncLatest(int $limit = 10, string $tag = 'MBTI'): array
{
$cfg = self::resolveConfig();
if ($cfg['baseUrl'] === '') {
return ['fetched' => 0, 'created' => 0, 'updated' => 0, 'items' => [], 'error' => 'soul_api.baseUrl 未配置'];
}
$base = rtrim($cfg['baseUrl'], '/');
$url = $base . '/api/articles';
$params = [
'tag' => $tag,
'limit' => $limit,
'sort' => 'publishedAt:desc',
];
$url .= '?' . http_build_query($params);
$headers = ['Accept: application/json'];
if ($cfg['token'] !== '') {
$headers[] = 'Authorization: Bearer ' . $cfg['token'];
}
[$response, $httpCode, $err] = self::httpGet($url, $headers);
if ($response === false || $response === '') {
Log::warning("SoulArticleService sync failed: http={$httpCode} err={$err}");
}
$data = json_decode($response, true);
if (!is_array($data)) {
$data = [];
}
// 兼容常见返回结构:{data:{list:[]}} / {data:[]} / {items:[]}
$list = [];
if (isset($data['data']['list']) && is_array($data['data']['list'])) {
$list = $data['data']['list'];
} elseif (isset($data['data']) && is_array($data['data']) && isset($data['data'][0])) {
$list = $data['data'];
} elseif (isset($data['items']) && is_array($data['items'])) {
$list = $data['items'];
} elseif (isset($data[0])) {
$list = $data;
}
// 兼容「一场 soul 创业实验」新接口:/api/miniprogram/book/*
if (empty($list)) {
$list = self::fetchFromSoulBookApi($base, $limit, $tag);
}
return self::upsertArticles($list, $tag, $limit);
}
/**
* 自动推送(傻瓜模式):检查 last_sync_at超过 intervalSec 则自动执行一次采集
* - 非阻塞:调用方可忽略返回值
* - 配置system_config.key='soul_article_auto_sync' {enabled, intervalSec, limit}
* @return array ['didSync'=>bool, ...syncResult]
*/
public static function autoSyncIfStale(): array
{
try {
$cfgRow = SystemConfigModel::where('key', 'soul_article_auto_sync')->find();
$enabled = true;
$intervalSec = 3600;
$limit = 10;
if ($cfgRow && !empty($cfgRow->value)) {
$v = $cfgRow->value;
if (is_string($v)) {
$d = json_decode($v, true);
if (is_array($d)) $v = $d;
}
if (is_array($v)) {
$enabled = !isset($v['enabled']) ? true : (bool) $v['enabled'];
$intervalSec = (int) ($v['intervalSec'] ?? 3600);
$limit = (int) ($v['limit'] ?? 10);
}
}
if (!$enabled) return ['didSync' => false, 'reason' => 'disabled'];
$lastRow = SystemConfigModel::where('key', 'soul_article_last_sync_at')->find();
$last = 0;
if ($lastRow && !empty($lastRow->value)) {
$last = is_numeric($lastRow->value) ? (int) $lastRow->value : (int) strtotime($lastRow->value);
}
$now = time();
if ($last > 0 && ($now - $last) < $intervalSec) {
return ['didSync' => false, 'lastAt' => $last, 'reason' => 'fresh'];
}
$r = self::syncLatest($limit, 'MBTI');
// 写回 last sync即使采集 0 篇也写回,避免高频重试)
$val = (string) $now;
$exist = SystemConfigModel::where('key', 'soul_article_last_sync_at')->find();
if ($exist) {
$exist->value = $val;
$exist->save();
} else {
SystemConfigModel::create([
'key' => 'soul_article_last_sync_at',
'value' => $val,
'description' => 'Soul 文章最近一次自动采集时间戳',
]);
}
return array_merge(['didSync' => true, 'lastAt' => $now], $r);
} catch (\Throwable $e) {
Log::warning('SoulArticleService autoSync failed: ' . $e->getMessage());
return ['didSync' => false, 'error' => $e->getMessage()];
}
}
/**
* 返回当前已推荐(最多 3 篇)
*/
public static function getRecommended(int $limit = 3): array
{
// 仅返回后台「推荐位」文章,不用候选池补齐(与超管「当前推荐」严格一致)
$rows = SoulArticleModel::where('isRecommended', 1)
->order('recommendedOrder', 'asc')
->order('publishedAt', 'desc')
->limit($limit)
->select()
->toArray();
return array_map(function ($r) {
$pub = (int) ($r['publishedAt'] ?? 0);
$upd = (int) ($r['updatedAt'] ?? 0);
// 避免 0 / 无效时间戳被格式化成 1970-01-01
$dateStr = '';
if ($pub > 946684800) {
$dateStr = date('Y-m-d', $pub);
} elseif ($upd > 946684800) {
$dateStr = date('Y-m-d', $upd);
}
return [
'id' => $r['id'],
'sourceId' => $r['sourceId'],
'title' => $r['title'],
'cover' => $r['cover'],
'url' => $r['url'],
'summary' => $r['summary'],
'author' => $r['author'],
'tag' => $r['tag'],
'publishedAt' => $dateStr,
];
}, $rows);
}
/**
* 把某篇文章设为推荐(至多 3 篇;超出时自动顶掉最老的一篇)
*/
public static function recommend(int $articleId): array
{
$article = SoulArticleModel::find($articleId);
if (!$article) {
return ['ok' => false, 'message' => '文章不存在'];
}
if ($article->isRecommended) {
$article->isRecommended = 0;
$article->save();
return ['ok' => true, 'message' => '已取消推荐', 'isRecommended' => false];
}
$current = SoulArticleModel::where('isRecommended', 1)->order('recommendedOrder', 'asc')->select()->toArray();
if (count($current) >= 3) {
// 顶掉最老的一篇
$oldest = end($current);
$old = SoulArticleModel::find($oldest['id']);
if ($old) {
$old->isRecommended = 0;
$old->save();
}
}
$maxOrder = (int) SoulArticleModel::where('isRecommended', 1)->max('recommendedOrder');
$article->isRecommended = 1;
$article->recommendedOrder = $maxOrder + 1;
$article->save();
return ['ok' => true, 'message' => '已设为推荐', 'isRecommended' => true];
}
/**
* soul_api 配置system_config.key='soul_api' / env 兜底
*/
private static function resolveConfig(): array
{
$baseUrl = trim((string) getenv('SOUL_API_BASE'));
$token = trim((string) getenv('SOUL_API_TOKEN'));
$row = SystemConfigModel::where('key', 'soul_api')->find();
if ($row && !empty($row->value)) {
$v = $row->value;
if (is_string($v)) {
$decoded = json_decode($v, true);
if (is_array($decoded)) $v = $decoded;
}
if (is_array($v)) {
if (!empty($v['baseUrl'])) $baseUrl = (string) $v['baseUrl'];
if (!empty($v['token'])) $token = (string) $v['token'];
}
}
if ($baseUrl === '') {
$baseUrl = 'https://soulapi.quwanzhi.com';
}
return ['baseUrl' => $baseUrl, 'token' => $token];
}
/**
* 从 Soul 小程序图书接口拉取最新章节,映射成文章池结构(用于 /api/articles 不可用时兜底)
*/
private static function fetchFromSoulBookApi(string $baseUrl, int $limit, string $tag, string $keyword = ''): array
{
$partsUrl = rtrim($baseUrl, '/') . '/api/miniprogram/book/parts';
[$rawParts] = self::httpGet($partsUrl, ['Accept: application/json']);
$partsData = is_string($rawParts) ? json_decode($rawParts, true) : null;
if (!is_array($partsData)) return [];
$parts = $partsData['parts'] ?? $partsData['data'] ?? [];
if (!is_array($parts) || empty($parts)) return [];
$chapters = [];
foreach ($parts as $p) {
$partId = (string) ($p['id'] ?? '');
if ($partId === '') continue;
$chaptersUrl = rtrim($baseUrl, '/') . '/api/miniprogram/book/chapters-by-part?partId=' . rawurlencode($partId);
[$rawRows] = self::httpGet($chaptersUrl, ['Accept: application/json']);
$rowsData = is_string($rawRows) ? json_decode($rawRows, true) : null;
if (!is_array($rowsData)) continue;
$rows = $rowsData['data'] ?? $rowsData['list'] ?? [];
if (!is_array($rows)) continue;
foreach ($rows as $row) {
$id = (string) ($row['id'] ?? '');
$title = trim((string) ($row['sectionTitle'] ?? $row['title'] ?? ''));
if ($id === '' || $title === '') continue;
// 只抓 MBTI 相关,减少噪音;标题无 MBTI 时保留“第x场”高频经营内容
$lc = mb_strtolower($title, 'UTF-8');
if (mb_strpos($lc, 'mbti') === false && mb_strpos($title, '第') === false) {
continue;
}
if ($keyword !== '') {
$kw = mb_strtolower($keyword, 'UTF-8');
$summary = trim((string) ($row['summary'] ?? ''));
$haystack = mb_strtolower($title . ' ' . $summary, 'UTF-8');
if (mb_strpos($haystack, $kw) === false) {
continue;
}
}
$publishedAt = 0;
$pubRaw = $row['updatedAt'] ?? ($row['createdAt'] ?? 0);
if (is_numeric($pubRaw)) {
$publishedAt = (int) $pubRaw;
} elseif (is_string($pubRaw) && trim($pubRaw) !== '') {
$t = strtotime((string) $pubRaw);
if ($t) $publishedAt = (int) $t;
}
// 异常时间戳(如 0 / 1970兜底当前时间
if ($publishedAt < 946684800) {
$publishedAt = time();
}
$cover = (string) ($row['coverUrl'] ?? $row['cover'] ?? '');
$summary = trim((string) ($row['summary'] ?? ''));
$chapters[] = [
'id' => $id,
'title' => $title,
'url' => rtrim($baseUrl, '/') . '/read/' . rawurlencode($id),
'cover' => $cover,
'summary' => $summary,
'author' => '一场 soul 创业实验',
'tag' => $tag,
'publishedAt' => $publishedAt ?: time(),
];
}
}
usort($chapters, function ($a, $b) {
return (int)($b['publishedAt'] ?? 0) <=> (int)($a['publishedAt'] ?? 0);
});
if (count($chapters) > $limit) {
$chapters = array_slice($chapters, 0, $limit);
}
return $chapters;
}
/**
* 统一写入文章池(幂等按 sourceId
*/
private static function upsertArticles(array $list, string $tag, int $limit): array
{
$created = 0;
$updated = 0;
$items = [];
$now = time();
if (!empty($list) && count($list) > $limit) {
$list = array_slice($list, 0, $limit);
}
foreach ($list as $raw) {
$sourceId = (string) ($raw['id'] ?? $raw['sourceId'] ?? $raw['articleId'] ?? '');
$title = trim((string) ($raw['title'] ?? ''));
$url2 = trim((string) ($raw['url'] ?? $raw['link'] ?? $raw['shareUrl'] ?? ''));
if ($sourceId === '' || $title === '' || $url2 === '') {
continue;
}
$cover = trim((string) ($raw['cover'] ?? $raw['coverUrl'] ?? $raw['thumbnail'] ?? ''));
$summary = trim((string) ($raw['summary'] ?? $raw['excerpt'] ?? $raw['description'] ?? ''));
$author = trim((string) ($raw['author'] ?? ($raw['authorName'] ?? '')));
$publishedAt = 0;
$pub = $raw['publishedAt'] ?? ($raw['createdAt'] ?? null);
if (is_numeric($pub)) {
$publishedAt = (int) $pub > 2000000000 ? (int)($pub / 1000) : (int) $pub;
} elseif (is_string($pub) && $pub !== '') {
$t = strtotime($pub);
if ($t) $publishedAt = $t;
}
if ($publishedAt < 946684800) {
$publishedAt = $now;
}
$payload = [
'title' => mb_substr($title, 0, 250, 'UTF-8'),
'cover' => $cover,
'url' => $url2,
'summary' => mb_substr($summary, 0, 500, 'UTF-8'),
'author' => mb_substr($author, 0, 60, 'UTF-8'),
'tag' => $tag,
'publishedAt' => $publishedAt ?: $now,
];
$existed = SoulArticleModel::where('sourceId', $sourceId)->find();
if ($existed) {
$existed->save($payload);
$updated++;
} else {
$payload['sourceId'] = $sourceId;
SoulArticleModel::create($payload);
$created++;
}
$items[] = array_merge(['sourceId' => $sourceId], $payload);
}
return ['fetched' => count($list), 'created' => $created, 'updated' => $updated, 'items' => $items];
}
/**
* 统一 HTTP GET返回 body/httpCode/error
*/
private static function httpGet(string $url, array $headers = []): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
return [$response, $httpCode, $err];
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace app\common\service;
use think\facade\Db;
/**
* 用户旅程:从 analytics_events 聚合最近行为,供飞书获客、出站 Hook、存客宝备注等复用。
*/
class UserJourneyService
{
/**
* @return string[] 每条一行展示文案(与 FeishuLeadWebhookService 历史格式一致)
*/
public static function recentBehaviorLines(int $userId, int $limit = 8): array
{
if ($userId <= 0 || $limit <= 0) {
return [];
}
try {
$rows = Db::name('analytics_events')
->where('userId', $userId)
->order('id', 'desc')
->limit($limit)
->select()
->toArray();
} catch (\Throwable $e) {
return [];
}
$out = [];
foreach ($rows as $r) {
if (is_array($r)) {
$out[] = self::formatAnalyticsLine($r);
}
}
return $out;
}
/**
* 用户管理摘要一行(平台用户 ID、归属企业用于 CRM 备注与推送文案。
*/
public static function managementSummaryLine(int $userId, int $testResultEnterpriseId = 0): string
{
if ($userId <= 0) {
return '';
}
$parts = ['平台用户ID:' . $userId];
$eid = $testResultEnterpriseId > 0 ? $testResultEnterpriseId : 0;
if ($eid <= 0) {
try {
$bound = Db::name('wechat_users')->where('id', $userId)->value('enterpriseId');
$eid = (int) ($bound ?? 0);
} catch (\Throwable $e) {
$eid = 0;
}
}
if ($eid > 0) {
try {
$name = Db::name('enterprises')->where('id', $eid)->value('name');
} catch (\Throwable $e) {
$name = null;
}
$label = $name !== null && (string) $name !== '' ? (string) $name : '企业';
$parts[] = '归属企业:' . $label . '(ID' . $eid . ')';
}
return implode(' · ', $parts);
}
/**
* @param array<string,mixed> $r analytics_events 一行
*/
public static function formatAnalyticsLine(array $r): string
{
$name = (string) ($r['eventName'] ?? '');
$path = trim((string) ($r['pagePath'] ?? ''));
$props = [];
if (!empty($r['propsJson'])) {
$decoded = is_string($r['propsJson']) ? json_decode($r['propsJson'], true) : [];
$props = is_array($decoded) ? $decoded : [];
}
$labelMap = [
'page_view' => '浏览页面',
'button_click' => '按钮点击',
'click_pay' => '发起支付',
'click_recharge' => '点击充值',
];
$label = $labelMap[$name] ?? $name;
$detail = '';
if ($name === 'page_view' && $path !== '') {
$detail = $path;
}
if (isset($props['action']) && (string) $props['action'] !== '') {
$detail = (string) $props['action'];
if (!empty($props['productType'])) {
$detail .= ' · ' . (string) $props['productType'];
}
} elseif (isset($props['label']) && (string) $props['label'] !== '') {
$detail = (string) $props['label'];
} elseif ($path !== '' && $detail === '') {
$detail = $path;
}
$line = $detail !== '' ? "{$label}: {$detail}" : $label;
$ts = isset($r['clientTs']) ? (int) $r['clientTs'] : null;
if (!$ts && !empty($r['createdAt'])) {
$ts = strtotime((string) $r['createdAt']) * 1000;
}
if ($ts) {
$line .= ' · ' . self::humanTimeAgoCn((int) round($ts));
}
return $line;
}
public static function humanTimeAgoCn(int $clientTsMs): string
{
$now = (int) (microtime(true) * 1000);
$sec = max(0, (int) (($now - $clientTsMs) / 1000));
if ($sec < 60) {
return '刚刚';
}
if ($sec < 3600) {
return (int) floor($sec / 60) . '分钟前';
}
if ($sec < 86400) {
return (int) floor($sec / 3600) . '小时前';
}
return (int) floor($sec / 86400) . '天前';
}
/**
* 将旅程行压成一段备注(用于存客宝 remark 等),超长截断。
*
* @param string[] $lines
*/
public static function journeyLinesToRemarkBlock(array $lines, int $maxChars = 600): string
{
if (count($lines) === 0) {
return '';
}
$body = '用户旅程:' . implode('', $lines);
if (function_exists('mb_strlen') && mb_strlen($body) > $maxChars) {
return mb_substr($body, 0, $maxChars) . '…';
}
if (!function_exists('mb_strlen') && strlen($body) > $maxChars) {
return substr($body, 0, $maxChars) . '…';
}
return $body;
}
}

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

View File

@@ -0,0 +1,29 @@
<?php
namespace app\model;
use think\Model;
use think\model\concern\SoftDelete;
/**
* 神仙 AI 对话
*/
class AiConversation extends Model
{
use SoftDelete;
protected $strict = false;
protected $name = 'ai_conversations';
protected $deleteTime = 'deletedAt';
protected $autoWriteTimestamp = 'int';
protected $createTime = 'createdAt';
protected $updateTime = 'updatedAt';
protected $type = [
'createdAt' => 'integer',
'updatedAt' => 'integer',
'deletedAt' => 'integer',
'lastMessageAt' => 'integer',
];
}

View File

@@ -0,0 +1,23 @@
<?php
namespace app\model;
use think\Model;
/**
* 神仙 AI 消息流
*/
class AiMessage extends Model
{
protected $name = 'ai_messages';
/** 避免因库表字段与模型缓存不一致导致写入抛错 */
protected $strict = false;
protected $autoWriteTimestamp = false;
protected $type = [
'createdAt' => 'integer',
'tokensIn' => 'integer',
'tokensOut' => 'integer',
];
}

View File

@@ -1,109 +1,110 @@
<?php
namespace app\model;
use think\Model;
use think\model\concern\SoftDelete;
/**
* AI服务商配置模型
*/
class AiProvider extends Model
{
use SoftDelete;
// 设置表名(不带前缀,前缀在数据库配置中设置)
protected $name = 'ai_providers';
// 软删除字段(驼峰命名,时间戳格式)
protected $deleteTime = 'deletedAt';
// 设置字段信息(匹配数据库字段命名:驼峰命名)
protected $schema = [
'id' => 'int',
'providerId' => 'string',
'name' => 'string',
'enabled' => 'int',
'visible' => 'int',
'apiKey' => 'string',
'apiEndpoint' => 'string',
'model' => 'string',
'organizationId' => 'string',
'maxTokens' => 'int',
'balanceAlertEnabled' => 'int',
'balanceAlertThreshold' => 'float',
'notes' => 'string',
'docUrl' => 'string',
'isFree' => 'int',
'supportsBalance' => 'int',
'lastBalance' => 'float',
'lastBalanceCurrency' => 'string',
'lastBalanceCheckedAt' => 'int',
'createdAt' => 'int',
'updatedAt' => 'int',
'deletedAt' => 'int',
'extraConfig' => 'string',
];
// 自动时间戳(使用驼峰命名,时间戳格式)
protected $autoWriteTimestamp = 'int';
// 时间戳字段名(驼峰命名,匹配数据库)
protected $createTime = 'createdAt';
protected $updateTime = 'updatedAt';
// 时间字段类型(时间戳格式)
protected $type = [
'lastBalanceCheckedAt' => 'integer',
'createdAt' => 'integer',
'updatedAt' => 'integer',
'deletedAt' => 'integer',
'extraConfig' => 'json',
];
// 注意API Key需要可逆读取用于API调用所以不隐藏但在获取器中脱敏
/**
* API Key 修改器存储原始值用于API调用
*/
public function setApiKeyAttr($value)
{
if (empty($value)) {
return null;
}
// 如果输入的是脱敏格式(包含****),不更新
if (strpos($value, '****') !== false) {
return null; // 返回null表示不更新此字段
}
// 直接存储原始值实际生产环境建议使用AES加密
return $value;
}
/**
* API Key 获取器(返回脱敏后的密钥)
* 注意如果需要原始密钥用于API调用使用 getRawApiKey() 方法
*/
public function getApiKeyAttr($value)
{
if (empty($value)) {
return '';
}
// 返回脱敏后的密钥显示前6位和后4位
if (strlen($value) > 10) {
return substr($value, 0, 6) . '****' . substr($value, -4);
}
return '****';
}
/**
* 获取原始API Key用于API调用
* @return string
*/
public function getRawApiKey()
{
// 直接从数据库读取原始值,绕过获取器
return \think\facade\Db::name('ai_providers')
->where('id', $this->id)
->value('apiKey') ?: '';
}
}
<?php
namespace app\model;
use think\Model;
use think\model\concern\SoftDelete;
/**
* AI服务商配置模型
*/
class AiProvider extends Model
{
use SoftDelete;
// 设置表名(不带前缀,前缀在数据库配置中设置)
protected $name = 'ai_providers';
// 软删除字段(驼峰命名,时间戳格式)
protected $deleteTime = 'deletedAt';
// 设置字段信息(匹配数据库字段命名:驼峰命名)
protected $schema = [
'id' => 'int',
'providerId' => 'string',
'name' => 'string',
'enabled' => 'int',
'visible' => 'int',
'apiKey' => 'string',
'apiEndpoint' => 'string',
'model' => 'string',
'organizationId' => 'string',
'maxTokens' => 'int',
'balanceAlertEnabled' => 'int',
'balanceAlertThreshold' => 'float',
'notes' => 'string',
'docUrl' => 'string',
'isFree' => 'int',
'supportsBalance' => 'int',
'lastBalance' => 'float',
'lastBalanceCurrency' => 'string',
'lastBalanceCheckedAt' => 'int',
'createdAt' => 'int',
'updatedAt' => 'int',
'deletedAt' => 'int',
'extraConfig' => 'string',
'sortWeight' => 'int',
];
// 自动时间戳(使用驼峰命名,时间戳格式)
protected $autoWriteTimestamp = 'int';
// 时间戳字段名(驼峰命名,匹配数据库)
protected $createTime = 'createdAt';
protected $updateTime = 'updatedAt';
// 时间字段类型(时间戳格式)
protected $type = [
'lastBalanceCheckedAt' => 'integer',
'createdAt' => 'integer',
'updatedAt' => 'integer',
'deletedAt' => 'integer',
'extraConfig' => 'json',
];
// 注意API Key需要可逆读取用于API调用所以不隐藏但在获取器中脱敏
/**
* API Key 修改器存储原始值用于API调用
*/
public function setApiKeyAttr($value)
{
if (empty($value)) {
return null;
}
// 如果输入的是脱敏格式(包含****),不更新
if (strpos($value, '****') !== false) {
return null; // 返回null表示不更新此字段
}
// 直接存储原始值实际生产环境建议使用AES加密
return $value;
}
/**
* API Key 获取器(返回脱敏后的密钥)
* 注意如果需要原始密钥用于API调用使用 getRawApiKey() 方法
*/
public function getApiKeyAttr($value)
{
if (empty($value)) {
return '';
}
// 返回脱敏后的密钥显示前6位和后4位
if (strlen($value) > 10) {
return substr($value, 0, 6) . '****' . substr($value, -4);
}
return '****';
}
/**
* 获取原始API Key用于API调用
* @return string
*/
public function getRawApiKey()
{
// 直接从数据库读取原始值,绕过获取器
return \think\facade\Db::name('ai_providers')
->where('id', $this->id)
->value('apiKey') ?: '';
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace app\model;
use think\Model;
class AiReport extends Model
{
protected $name = 'ai_reports';
protected $autoWriteTimestamp = 'int';
protected $createTime = 'createdAt';
protected $updateTime = 'updatedAt';
}

View File

@@ -61,6 +61,8 @@ class Enterprise extends Model
'sbti' => true,
'pdp' => true,
'disc' => true,
/** 详细测试列表中的「AI 性格测试」聚合入口 */
'aiHub' => true,
'distribution' => true,
];
}

View File

@@ -0,0 +1,16 @@
<?php
namespace app\model;
use think\Model;
class ProfitSharingRule extends Model
{
protected $name = 'profit_sharing_rules';
protected $autoWriteTimestamp = 'int';
protected $createTime = 'createdAt';
protected $updateTime = 'updatedAt';
protected $type = [
'receivers' => 'json',
];
}

View File

@@ -0,0 +1,30 @@
<?php
namespace app\model;
use think\Model;
use think\model\concern\SoftDelete;
/**
* Soul 采集文章MBTI 主题 · 为一场 soul 创业实验引流)
*/
class SoulArticle extends Model
{
use SoftDelete;
protected $name = 'soul_articles';
protected $deleteTime = 'deletedAt';
protected $autoWriteTimestamp = 'int';
protected $createTime = 'createdAt';
protected $updateTime = 'updatedAt';
protected $type = [
'createdAt' => 'integer',
'updatedAt' => 'integer',
'deletedAt' => 'integer',
'publishedAt' => 'integer',
'isRecommended' => 'integer',
'recommendedOrder' => 'integer',
'viewCount' => 'integer',
];
}