feat: 小程序与管理端迭代(神仙AI、了解自己CRM、AI测试入口、报表与分润等)
Made-with: Cursor
This commit is contained in:
10
api/.env.local.merge.example
Normal file
10
api/.env.local.merge.example
Normal file
@@ -0,0 +1,10 @@
|
||||
# 复制本文件为 api/.env.local.merge(与 .env 同级),再执行:
|
||||
# python3 scripts/apply_bt_download_env.py <宝塔下载的文件路径>
|
||||
# 脚本会把宝塔 .env 写入 api/.env,并把下面内容追加在末尾(后面的键覆盖前面的)。
|
||||
|
||||
# 示例:宝塔里数据库写的是 127.0.0.1(指服务器本机),你在本机用 SSH 隧道映射到 13306 时:
|
||||
# DATABASE_HOSTNAME = 127.0.0.1
|
||||
# DATABASE_HOSTPORT = 13306
|
||||
|
||||
# 示例:强制本机调试
|
||||
# APP_DEBUG = true
|
||||
132
api/app/common/AnalyticsEventLabels.php
Normal file
132
api/app/common/AnalyticsEventLabels.php
Normal 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;
|
||||
}
|
||||
}
|
||||
128
api/app/common/service/AiBalanceAlertService.php
Normal file
128
api/app/common/service/AiBalanceAlertService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
623
api/app/common/service/AiCallService.php
Normal file
623
api/app/common/service/AiCallService.php
Normal 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 需要单独传 system,messages 只接受 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} 最容易掉进什么心理陷阱?",
|
||||
];
|
||||
}
|
||||
}
|
||||
116
api/app/common/service/AiChatArticleDisplayService.php
Normal file
116
api/app/common/service/AiChatArticleDisplayService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
206
api/app/common/service/AiReportService.php
Normal file
206
api/app/common/service/AiReportService.php
Normal 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(支付成功 / dev:markPaidDev)
|
||||
* → 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
259
api/app/common/service/ProfitSharingService.php
Normal file
259
api/app/common/service/ProfitSharingService.php
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
441
api/app/common/service/SoulArticleService.php
Normal file
441
api/app/common/service/SoulArticleService.php
Normal 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];
|
||||
}
|
||||
}
|
||||
152
api/app/common/service/UserJourneyService.php
Normal file
152
api/app/common/service/UserJourneyService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,14 @@ class AppUser extends BaseController
|
||||
$pageSize = (int) Request::param('pageSize', 20);
|
||||
$pageSize = min(max($pageSize, 1), 100);
|
||||
$keyword = trim(Request::param('keyword', ''));
|
||||
$coldFaceLevelRaw = Request::param('coldFaceLevel', '');
|
||||
$coldFaceLevels = [];
|
||||
if (is_array($coldFaceLevelRaw)) {
|
||||
$coldFaceLevels = array_values(array_filter(array_map('strval', $coldFaceLevelRaw)));
|
||||
} elseif (is_string($coldFaceLevelRaw) && $coldFaceLevelRaw !== '') {
|
||||
$coldFaceLevels = array_values(array_filter(array_map('trim', explode(',', $coldFaceLevelRaw))));
|
||||
}
|
||||
$coldFaceLevels = array_values(array_intersect($coldFaceLevels, ['cold', 'neutral', 'warm']));
|
||||
|
||||
$where = [];
|
||||
if ($keyword !== '') {
|
||||
@@ -72,6 +80,19 @@ class AppUser extends BaseController
|
||||
->join([$poolSql => 'p'], 'w.id = p.userId')
|
||||
->join([$dedupSql => 'd'], 'w.id = d.mid');
|
||||
|
||||
if (!empty($coldFaceLevels)) {
|
||||
try {
|
||||
$cfSql = Db::name('user_profile')
|
||||
->whereRaw('enterpriseId = ' . $eid)
|
||||
->whereIn('coldFaceLevel', $coldFaceLevels)
|
||||
->field('userId')
|
||||
->buildSql(true);
|
||||
$baseQuery->join([$cfSql => 'cf'], 'w.id = cf.userId');
|
||||
} catch (\Throwable $e) {
|
||||
// coldFace 字段不存在时忽略筛选
|
||||
}
|
||||
}
|
||||
|
||||
if ($keyword !== '') {
|
||||
$like = '%' . addcslashes($keyword, '%_\\') . '%';
|
||||
$baseQuery->whereRaw(
|
||||
@@ -215,6 +236,31 @@ class AppUser extends BaseController
|
||||
} catch (\Throwable $e) {
|
||||
$payStats = [];
|
||||
}
|
||||
|
||||
// 冷脸分字段(容错:迁移未执行时 coldFace* 字段缺失,查询异常则视为全空)
|
||||
$coldFaceMap = [];
|
||||
try {
|
||||
$cfQuery = Db::name('user_profile')->where('userId', 'in', $ids);
|
||||
if ($enterpriseId) {
|
||||
$cfQuery->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$cfRows = $cfQuery
|
||||
->field('userId, coldFaceScore, coldFaceLevel, coldFaceUpdatedAt')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($cfRows as $r) {
|
||||
$uid = (int) ($r['userId'] ?? 0);
|
||||
if ($uid > 0) {
|
||||
$coldFaceMap[$uid] = [
|
||||
'score' => isset($r['coldFaceScore']) && $r['coldFaceScore'] !== null ? (int) $r['coldFaceScore'] : null,
|
||||
'level' => $r['coldFaceLevel'] ?? null,
|
||||
'updatedAt' => isset($r['coldFaceUpdatedAt']) ? (int) $r['coldFaceUpdatedAt'] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$coldFaceMap = [];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($list as &$row) {
|
||||
@@ -236,6 +282,18 @@ class AppUser extends BaseController
|
||||
$pay = $payStats[$id] ?? null;
|
||||
$row['paidOrders'] = $pay ? (int) ($pay['paidOrders'] ?? 0) : 0;
|
||||
$row['totalPaidAmount'] = $pay ? (int) ($pay['totalPaidAmount'] ?? 0) : 0;
|
||||
|
||||
$cf = $coldFaceMap[$id] ?? null;
|
||||
if ((!$cf || $cf['score'] === null) && !empty($testsForUser)) {
|
||||
$calc = $this->calcColdFace($testsForUser);
|
||||
if ($calc) {
|
||||
$this->writeColdFace((int) $id, $enterpriseId ? (int) $enterpriseId : null, $calc);
|
||||
$cf = ['score' => $calc['score'], 'level' => $calc['level'], 'updatedAt' => time()];
|
||||
}
|
||||
}
|
||||
$row['coldFaceScore'] = $cf && $cf['score'] !== null ? (int) $cf['score'] : null;
|
||||
$row['coldFaceLevel'] = $cf && !empty($cf['level']) ? (string) $cf['level'] : null;
|
||||
$row['coldFaceUpdatedAt'] = $cf ? ($cf['updatedAt'] ?? null) : null;
|
||||
}
|
||||
|
||||
return paginate_response($list, $total, $page, $pageSize);
|
||||
@@ -322,6 +380,35 @@ class AppUser extends BaseController
|
||||
$data['faceDiscType'] = $this->extractFaceSubType($tests, 'disc');
|
||||
$data['facePdpType'] = $this->extractFaceSubType($tests, 'pdp');
|
||||
|
||||
// 冷脸分字段(详情)
|
||||
$coldFace = null;
|
||||
try {
|
||||
$cfQuery = Db::name('user_profile')->where('userId', $id);
|
||||
if ($enterpriseId) {
|
||||
$cfQuery->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$cfRow = $cfQuery->field('coldFaceScore, coldFaceLevel, coldFaceUpdatedAt')->find();
|
||||
if ($cfRow) {
|
||||
$coldFace = [
|
||||
'score' => $cfRow['coldFaceScore'] !== null ? (int) $cfRow['coldFaceScore'] : null,
|
||||
'level' => $cfRow['coldFaceLevel'] ?? null,
|
||||
'updatedAt' => isset($cfRow['coldFaceUpdatedAt']) ? (int) $cfRow['coldFaceUpdatedAt'] : null,
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$coldFace = null;
|
||||
}
|
||||
if ((!$coldFace || $coldFace['score'] === null) && !empty($tests)) {
|
||||
$calc = $this->calcColdFace($tests);
|
||||
if ($calc) {
|
||||
$this->writeColdFace((int) $id, $enterpriseId ? (int) $enterpriseId : null, $calc);
|
||||
$coldFace = ['score' => $calc['score'], 'level' => $calc['level'], 'updatedAt' => time()];
|
||||
}
|
||||
}
|
||||
$data['coldFaceScore'] = $coldFace['score'] ?? null;
|
||||
$data['coldFaceLevel'] = $coldFace['level'] ?? null;
|
||||
$data['coldFaceUpdatedAt'] = $coldFace['updatedAt'] ?? null;
|
||||
|
||||
return success($data);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class Finance extends BaseController
|
||||
$baseOrderQuery = Db::name('orders')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->whereIn('status', ['paid', 'completed'])
|
||||
->whereIn('productType', ['face', 'mbti', 'sbti', 'disc', 'pdp']);
|
||||
->whereIn('productType', ['face', 'mbti', 'sbti', 'disc', 'pdp', 'ai_deep_report']);
|
||||
|
||||
$totalIncomeFen = (int) ((clone $baseOrderQuery)->sum('amount') ?? 0);
|
||||
$todayIncomeFen = (int) ((clone $baseOrderQuery)->where('payTime', '>=', $todayStart)->sum('amount') ?? 0);
|
||||
@@ -54,16 +54,44 @@ class Finance extends BaseController
|
||||
->where('status', 'frozen')
|
||||
->sum('commissionFen') ?? 0);
|
||||
|
||||
// 本月累计扣款(平台扣费 + 佣金扣减)
|
||||
$monthConsumeFen = (int) (Db::name('finance_records')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->where('type', 'consume')
|
||||
->where('createdAt', '>=', $monthStart)
|
||||
->sum('amount') ?? 0);
|
||||
|
||||
// 今日扣款
|
||||
$todayConsumeFen = (int) (Db::name('finance_records')
|
||||
->where('enterpriseId', $enterpriseId)
|
||||
->where('type', 'consume')
|
||||
->where('createdAt', '>=', $todayStart)
|
||||
->sum('amount') ?? 0);
|
||||
|
||||
// 建议充值:按本月实际天数日均 × 14 天,低于 500 元按 500 元兜底;余额足够则 0
|
||||
$balanceFen = (int) ($enterprise['balance'] ?? 0);
|
||||
$daysElapsed = max(1, (int) date('j'));
|
||||
$avgDaily = $monthConsumeFen > 0 ? intval($monthConsumeFen / $daysElapsed) : 0;
|
||||
$suggestThreshold = $avgDaily * 14;
|
||||
$suggestRechargeFen = 0;
|
||||
if ($avgDaily > 0 && $balanceFen < $suggestThreshold) {
|
||||
$suggestRechargeFen = max($suggestThreshold - $balanceFen, 50000); // ≥ 500 元
|
||||
}
|
||||
|
||||
return success([
|
||||
'enterpriseId' => $enterpriseId,
|
||||
'enterpriseName' => $enterprise['name'] ?? '',
|
||||
'balanceFen' => (int) ($enterprise['balance'] ?? 0),
|
||||
'balanceFen' => $balanceFen,
|
||||
'totalIncomeFen' => $totalIncomeFen,
|
||||
'todayIncomeFen' => $todayIncomeFen,
|
||||
'monthIncomeFen' => $monthIncomeFen,
|
||||
'manualRechargeFen' => $manualRechargeFen,
|
||||
'frozenCommissionFen' => $frozenCommissionFen,
|
||||
'paidOrderCount' => $paidOrderCount,
|
||||
'monthConsumeFen' => $monthConsumeFen,
|
||||
'todayConsumeFen' => $todayConsumeFen,
|
||||
'avgDailyConsumeFen' => $avgDaily,
|
||||
'suggestRechargeFen' => $suggestRechargeFen,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return error('获取企业财务概览失败:' . $e->getMessage(), 500);
|
||||
|
||||
@@ -31,6 +31,7 @@ class Order extends BaseController
|
||||
$keyword = trim(Request::param('keyword', ''));
|
||||
$status = trim(Request::param('status', ''));
|
||||
$productType = trim(Request::param('productType', ''));
|
||||
$inviterId = (int) Request::param('inviterId', 0);
|
||||
|
||||
// 超管:全平台订单;其余管理员仅本企业
|
||||
$enterpriseId = null;
|
||||
@@ -53,6 +54,24 @@ class Order extends BaseController
|
||||
if ($productType !== '') {
|
||||
$query->where('productType', $productType);
|
||||
}
|
||||
if ($inviterId > 0) {
|
||||
// 通过分销绑定表过滤「该分销商带来的订单」:orders.userId = distribution_bindings.inviteeId,inviter=inviterId
|
||||
try {
|
||||
$inviteeIds = Db::name('distribution_bindings')
|
||||
->where('inviterId', $inviterId)
|
||||
->column('inviteeId');
|
||||
$inviteeIds = array_values(array_filter(array_map('intval', $inviteeIds ?: [])));
|
||||
if (empty($inviteeIds)) {
|
||||
return success([
|
||||
'list' => [], 'total' => 0, 'page' => $page, 'pageSize' => $pageSize,
|
||||
'hasMore' => false, 'paidCompletedCount' => 0, 'totalRevenueFen' => 0,
|
||||
]);
|
||||
}
|
||||
$query->whereIn('userId', $inviteeIds);
|
||||
} catch (\Throwable $e) {
|
||||
// 表不存在或字段差异:忽略筛选
|
||||
}
|
||||
}
|
||||
if ($keyword !== '') {
|
||||
if (is_numeric($keyword)) {
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
@@ -119,12 +138,58 @@ class Order extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
// 当前页订单的分销分润记录
|
||||
$commissionsByOrder = [];
|
||||
if (!empty($orderIds)) {
|
||||
try {
|
||||
$crRows = Db::name('commission_records')
|
||||
->where('orderId', 'in', $orderIds)
|
||||
->field('id, orderId, inviterId, commissionFen, rate, status, paidAt, createdAt')
|
||||
->order('createdAt', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
$inviterIds = array_values(array_unique(array_filter(array_column($crRows, 'inviterId'))));
|
||||
$inviterMap = [];
|
||||
if (!empty($inviterIds)) {
|
||||
$inviters = Db::name('wechat_users')
|
||||
->where('id', 'in', $inviterIds)
|
||||
->field('id, nickname, phone')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($inviters as $iv) {
|
||||
$inviterMap[(int) $iv['id']] = $iv;
|
||||
}
|
||||
}
|
||||
foreach ($crRows as $cr) {
|
||||
$oid = (int) ($cr['orderId'] ?? 0);
|
||||
if ($oid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$iv = $inviterMap[(int) ($cr['inviterId'] ?? 0)] ?? null;
|
||||
$commissionsByOrder[$oid][] = [
|
||||
'id' => (int) $cr['id'],
|
||||
'inviterId' => (int) ($cr['inviterId'] ?? 0),
|
||||
'inviterName' => $iv['nickname'] ?? null,
|
||||
'inviterPhone' => $iv['phone'] ?? null,
|
||||
'commissionFen' => (int) ($cr['commissionFen'] ?? 0),
|
||||
'rate' => isset($cr['rate']) ? (float) $cr['rate'] : null,
|
||||
'status' => (string) ($cr['status'] ?? ''),
|
||||
'paidAt' => isset($cr['paidAt']) ? (int) $cr['paidAt'] : null,
|
||||
'createdAt' => isset($cr['createdAt']) ? (int) $cr['createdAt'] : null,
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$commissionsByOrder = [];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($list as &$row) {
|
||||
$uid = (int) ($row['userId'] ?? 0);
|
||||
$u = $usersMap[$uid] ?? null;
|
||||
$row['userName'] = $u ? ($u['nickname'] ?? ('用户' . $uid)) : ('用户' . $uid);
|
||||
$row['userPhone'] = $u ? ($u['phone'] ?? '') : '';
|
||||
$row['testData'] = $testsByOrder[$row['id']] ?? [];
|
||||
$row['commissions'] = $commissionsByOrder[$row['id']] ?? [];
|
||||
}
|
||||
|
||||
return success([
|
||||
|
||||
@@ -2,12 +2,115 @@
|
||||
namespace app\controller\admin\concern;
|
||||
|
||||
use app\common\PdpDiscResultText;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 从测试记录数组中解析 MBTI / DISC / PDP / 人脸子类型(与 AppUser 逻辑一致)
|
||||
*/
|
||||
trait ExtractsTestResults
|
||||
{
|
||||
/**
|
||||
* 基于测试记录推算冷脸分值(0-100)
|
||||
* - 面相 emotionScore/emotionNeutrality/microExpression 作为主输入
|
||||
* - MBTI I/E 作为补充(I 更冷,E 更暖)
|
||||
* 返回 ['score'=>int, 'level'=>'cold|neutral|warm'];如无任何线索返回 null
|
||||
*/
|
||||
public function calcColdFace(array $tests): ?array
|
||||
{
|
||||
$score = null; // 0-100
|
||||
$mbtiType = '';
|
||||
|
||||
foreach ($tests as $t) {
|
||||
$type = strtolower($t['testType'] ?? '');
|
||||
$raw = $t['result'] ?? ($t['resultData'] ?? '');
|
||||
if (!is_string($raw) || $raw === '') {
|
||||
continue;
|
||||
}
|
||||
$dec = json_decode($raw, true);
|
||||
if (!is_array($dec)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'face' && $score === null) {
|
||||
$face = is_array($dec['face'] ?? null) ? $dec['face'] : $dec;
|
||||
$emotion = null;
|
||||
foreach (['coldFaceScore', 'coldScore', 'emotionNeutrality', 'neutrality'] as $k) {
|
||||
if (isset($face[$k]) && is_numeric($face[$k])) {
|
||||
$emotion = (float) $face[$k];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($emotion === null && isset($face['emotionScore']) && is_numeric($face['emotionScore'])) {
|
||||
// emotionScore 越高越暖 → 冷脸分取反
|
||||
$emotion = 100 - (float) $face['emotionScore'];
|
||||
}
|
||||
if ($emotion === null && isset($face['microExpression']) && is_array($face['microExpression'])) {
|
||||
$mx = $face['microExpression'];
|
||||
$happy = is_numeric($mx['happy'] ?? null) ? (float) $mx['happy'] : 0;
|
||||
$neutral = is_numeric($mx['neutral'] ?? null) ? (float) $mx['neutral'] : 0;
|
||||
$total = $happy + $neutral + 1;
|
||||
$emotion = ($neutral / $total) * 100;
|
||||
}
|
||||
if ($emotion !== null) {
|
||||
if ($emotion <= 1) {
|
||||
$emotion = $emotion * 100;
|
||||
}
|
||||
$score = max(0, min(100, (int) round($emotion)));
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'mbti' && $mbtiType === '') {
|
||||
foreach (['mbtiType', 'type', 'result'] as $k) {
|
||||
$s = $this->coerceResultLabel($dec[$k] ?? null);
|
||||
if ($s !== '') {
|
||||
$mbtiType = strtoupper($s);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($score === null && $mbtiType !== '') {
|
||||
$score = strpos($mbtiType, 'I') === 0 ? 62 : 42;
|
||||
}
|
||||
if ($score === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$level = 'neutral';
|
||||
if ($score > 65) {
|
||||
$level = 'cold';
|
||||
} elseif ($score < 35) {
|
||||
$level = 'warm';
|
||||
}
|
||||
|
||||
return ['score' => (int) $score, 'level' => $level];
|
||||
}
|
||||
|
||||
/**
|
||||
* 写回 user_profile 的冷脸字段(存在才更新,否则跳过;失败静默)
|
||||
*/
|
||||
protected function writeColdFace(int $userId, ?int $enterpriseId, array $cold): void
|
||||
{
|
||||
if (!isset($cold['score'], $cold['level'])) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$query = Db::name('user_profile')->where('userId', $userId);
|
||||
if ($enterpriseId) {
|
||||
$query->where('enterpriseId', $enterpriseId);
|
||||
}
|
||||
$query->update([
|
||||
'coldFaceScore' => (int) $cold['score'],
|
||||
'coldFaceLevel' => (string) $cold['level'],
|
||||
'coldFaceUpdatedAt' => time(),
|
||||
'updatedAt' => time(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// 字段不存在(未执行迁移)时直接忽略
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 结果字段可能为数组/对象,禁止直接 (string) 强转导致 Array to string conversion
|
||||
*/
|
||||
|
||||
503
api/app/controller/api/AiChat.php
Normal file
503
api/app/controller/api/AiChat.php
Normal file
@@ -0,0 +1,503 @@
|
||||
<?php
|
||||
namespace app\controller\api;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\AiCallService;
|
||||
use app\common\service\AiChatArticleDisplayService;
|
||||
use app\common\service\SoulArticleService;
|
||||
use app\model\AiConversation as AiConversationModel;
|
||||
use app\model\AiMessage as AiMessageModel;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 神仙 AI 聊天接口(小程序侧)
|
||||
*
|
||||
* 路由:/api/ai/*
|
||||
* 鉴权:auth(微信登录后 token)
|
||||
*/
|
||||
class AiChat extends BaseController
|
||||
{
|
||||
/** 每用户每日发送上限(超出则返回 429) */
|
||||
private const DAILY_LIMIT = 20;
|
||||
|
||||
/** 单次对话最多携带的历史上下文(user+assistant 轮数) */
|
||||
private const CONTEXT_TURNS = 8;
|
||||
|
||||
/**
|
||||
* POST /api/ai/chat
|
||||
* body: { conversationId?: int, message: string }
|
||||
* 返回 { conversationId, message: {role, content, providerId, isDegraded, createdAt}, usageToday, dailyLimit }
|
||||
*/
|
||||
public function chat()
|
||||
{
|
||||
$userId = $this->currentUserId();
|
||||
if ($userId <= 0) return error('请先登录', 401);
|
||||
|
||||
$message = trim((string) Request::post('message', ''));
|
||||
if ($message === '') return error('消息不能为空', 400);
|
||||
if (mb_strlen($message, 'UTF-8') > 800) {
|
||||
return error('消息过长,请精简到 800 字以内', 400);
|
||||
}
|
||||
|
||||
$conversationId = (int) Request::post('conversationId', 0);
|
||||
|
||||
// 每日限流(必须在 try 外先取 used,失败时返回 200+降级,避免未捕获异常导致 HTTP 500)
|
||||
$used = 0;
|
||||
try {
|
||||
$used = $this->incrementAndCheckDaily($userId, self::DAILY_LIMIT);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('AiChat incrementAndCheckDaily: ' . $e->getMessage());
|
||||
|
||||
return $this->chatDegradeResponse(
|
||||
$conversationId,
|
||||
0,
|
||||
'小神仙这边计数服务抖了一下,请稍后再发一条消息~'
|
||||
);
|
||||
}
|
||||
if ($used === -1) {
|
||||
return error('今日对话次数已用完,明天再来找我呀~', 429);
|
||||
}
|
||||
|
||||
$userMsgSaved = false;
|
||||
$conversation = null;
|
||||
try {
|
||||
// 拉取/新建对话(测评档案组装失败时不阻断聊天)
|
||||
$userContext = [
|
||||
'mbtiType' => '', 'summary' => '', 'nickname' => '', 'testAppendix' => '',
|
||||
];
|
||||
try {
|
||||
$userContext = AiCallService::fetchUserContext($userId);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('AiChat fetchUserContext: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$conversation = $conversationId > 0
|
||||
? AiConversationModel::where('userId', $userId)->where('id', $conversationId)->find()
|
||||
: null;
|
||||
|
||||
$now = time();
|
||||
if (!$conversation) {
|
||||
$title = mb_substr($message, 0, 24, 'UTF-8');
|
||||
if ($title === false) {
|
||||
$title = mb_substr($message, 0, 24);
|
||||
}
|
||||
if (!is_string($title) || $title === '') {
|
||||
$title = '新对话';
|
||||
}
|
||||
$conversation = AiConversationModel::create([
|
||||
'userId' => $userId,
|
||||
'title' => $title,
|
||||
'mbtiType' => $userContext['mbtiType'] ?: '',
|
||||
'providerId' => '',
|
||||
'lastMessageAt' => $now,
|
||||
'messageCount' => 0,
|
||||
]);
|
||||
$conversationId = (int) $conversation->id;
|
||||
}
|
||||
|
||||
// 写入 user 消息
|
||||
AiMessageModel::create([
|
||||
'conversationId' => $conversationId,
|
||||
'role' => 'user',
|
||||
'content' => $message,
|
||||
'tokensIn' => 0,
|
||||
'tokensOut' => 0,
|
||||
'providerId' => '',
|
||||
'isDegraded' => 0,
|
||||
'createdAt' => $now,
|
||||
]);
|
||||
$userMsgSaved = true;
|
||||
|
||||
// 组装 messages:system + 最近 N 轮历史 + 本次 user
|
||||
try {
|
||||
$systemPrompt = AiCallService::buildSystemPrompt($userContext);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('AiChat buildSystemPrompt: ' . $e->getMessage());
|
||||
$systemPrompt = "你是「神仙 AI」,神仙团队 MBTI 小程序的专属伙伴。请亲切、简短地回答用户;禁止医疗诊断与投资建议。\n";
|
||||
}
|
||||
$history = AiMessageModel::where('conversationId', $conversationId)
|
||||
->order('id', 'desc')
|
||||
->limit(self::CONTEXT_TURNS * 2)
|
||||
->select()
|
||||
->toArray();
|
||||
$history = array_reverse($history);
|
||||
|
||||
$messages = [['role' => 'system', 'content' => $systemPrompt]];
|
||||
foreach ($history as $row) {
|
||||
if (!in_array($row['role'], ['user', 'assistant'], true)) {
|
||||
continue;
|
||||
}
|
||||
$messages[] = ['role' => $row['role'], 'content' => (string) $row['content']];
|
||||
}
|
||||
|
||||
$r = AiCallService::chat($messages, ['temperature' => 0.75, 'maxTokens' => 1024]);
|
||||
$assistantContent = $r['content'] !== '' ? $r['content'] : '(小神仙被问住了,换个问法试试?)';
|
||||
|
||||
// 写入 assistant 消息
|
||||
$msgRow = AiMessageModel::create([
|
||||
'conversationId' => $conversationId,
|
||||
'role' => 'assistant',
|
||||
'content' => $assistantContent,
|
||||
'tokensIn' => (int) ($r['tokensIn'] ?? 0),
|
||||
'tokensOut' => (int) ($r['tokensOut'] ?? 0),
|
||||
'providerId' => (string) ($r['providerId'] ?? ''),
|
||||
'isDegraded' => !empty($r['isDegraded']) ? 1 : 0,
|
||||
'createdAt' => time(),
|
||||
]);
|
||||
|
||||
// 更新对话元数据
|
||||
$conversation->lastMessageAt = time();
|
||||
$conversation->messageCount = AiMessageModel::where('conversationId', $conversationId)->count();
|
||||
$conversation->providerId = (string) ($r['providerId'] ?? '');
|
||||
$conversation->save();
|
||||
|
||||
return success([
|
||||
'conversationId' => $conversationId,
|
||||
'message' => [
|
||||
'id' => (int) $msgRow->id,
|
||||
'role' => 'assistant',
|
||||
'content' => $assistantContent,
|
||||
'providerId' => $r['providerId'] ?? '',
|
||||
'isDegraded' => (bool) ($r['isDegraded'] ?? false),
|
||||
'createdAt' => (int) $msgRow->createdAt,
|
||||
],
|
||||
'usageToday' => $used,
|
||||
'dailyLimit' => self::DAILY_LIMIT,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('AiChat::chat 异常: ' . $e->getMessage() . "\n" . $e->getTraceAsString());
|
||||
|
||||
// 用户消息已落库时:降级返回一条助手回复,避免前端只看到 500
|
||||
if ($userMsgSaved && $conversationId > 0 && $conversation) {
|
||||
try {
|
||||
$assistantContent = '小神仙这边刚刚抖了一下(已自动记录)。你可以把问题缩短一点重发,或稍后再试~';
|
||||
$msgRow = AiMessageModel::create([
|
||||
'conversationId' => $conversationId,
|
||||
'role' => 'assistant',
|
||||
'content' => $assistantContent,
|
||||
'tokensIn' => 0,
|
||||
'tokensOut' => 0,
|
||||
'providerId' => 'degrade',
|
||||
'isDegraded' => 1,
|
||||
'createdAt' => time(),
|
||||
]);
|
||||
$conversation->lastMessageAt = time();
|
||||
$conversation->messageCount = AiMessageModel::where('conversationId', $conversationId)->count();
|
||||
$conversation->providerId = 'degrade';
|
||||
$conversation->save();
|
||||
|
||||
return success([
|
||||
'conversationId' => $conversationId,
|
||||
'message' => [
|
||||
'id' => (int) $msgRow->id,
|
||||
'role' => 'assistant',
|
||||
'content' => $assistantContent,
|
||||
'providerId' => 'degrade',
|
||||
'isDegraded' => true,
|
||||
'createdAt' => (int) $msgRow->createdAt,
|
||||
],
|
||||
'usageToday' => $used,
|
||||
'dailyLimit' => self::DAILY_LIMIT,
|
||||
]);
|
||||
} catch (\Throwable $e2) {
|
||||
Log::error('AiChat::chat 降级回复写入失败: ' . $e2->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 统一不再返回业务 code=500,前端只认 code=200;避免用户看到「服务异常(500)」
|
||||
return $this->chatDegradeResponse(
|
||||
$conversationId,
|
||||
$used,
|
||||
'小神仙服务暂时不可用,请稍后再试。如反复出现可在「我的」联系客服~'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天失败时仍返回 HTTP JSON code=200,由 message.isDegraded 标记,小程序可正常展示气泡
|
||||
*
|
||||
* @param int $conversationId 当前会话 id(未知则 0)
|
||||
* @param int $usageToday 今日已计次数(失败时可能为 0)
|
||||
* @param string $assistantText 助手可见文案
|
||||
*/
|
||||
private function chatDegradeResponse(int $conversationId, int $usageToday, string $assistantText)
|
||||
{
|
||||
$now = time();
|
||||
|
||||
return success([
|
||||
'conversationId' => $conversationId,
|
||||
'message' => [
|
||||
'id' => 0,
|
||||
'role' => 'assistant',
|
||||
'content' => $assistantText,
|
||||
'providerId' => 'degrade',
|
||||
'isDegraded' => true,
|
||||
'createdAt' => $now,
|
||||
],
|
||||
'usageToday' => max(0, $usageToday),
|
||||
'dailyLimit' => self::DAILY_LIMIT,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ai/articles/recommended
|
||||
* 无需登录(也可以;这里先要求登录保持一致)
|
||||
*/
|
||||
public function recommendedArticles()
|
||||
{
|
||||
$display = AiChatArticleDisplayService::getSettings();
|
||||
$rows = [];
|
||||
if ($display['enabled']) {
|
||||
try { SoulArticleService::autoSyncIfStale(); } catch (\Throwable $e) {}
|
||||
$limit = (int) $display['maxShow'];
|
||||
$rows = SoulArticleService::getRecommended($limit);
|
||||
if (empty($rows)) {
|
||||
try { SoulArticleService::syncLatest(10, 'MBTI'); } catch (\Throwable $e) {}
|
||||
$rows = SoulArticleService::getRecommended($limit);
|
||||
}
|
||||
}
|
||||
|
||||
$resp = success([
|
||||
'list' => $rows,
|
||||
'recoCount' => count($rows),
|
||||
'display' => [
|
||||
'enabled' => $display['enabled'],
|
||||
'maxShow' => $display['maxShow'],
|
||||
'sectionExpandedDefault' => $display['sectionExpandedDefault'],
|
||||
],
|
||||
]);
|
||||
if (method_exists($resp, 'header')) {
|
||||
$resp->header([
|
||||
'Cache-Control' => 'no-store, no-cache, must-revalidate',
|
||||
'Pragma' => 'no-cache',
|
||||
]);
|
||||
}
|
||||
return $resp;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ai/articles/profile-teaser
|
||||
* 我的页底部:展示后台「当前推荐」排序第一的一篇 + 可配区块标题(无需登录)
|
||||
*/
|
||||
public function profileArticleTeaser()
|
||||
{
|
||||
$display = AiChatArticleDisplayService::getSettings();
|
||||
if (empty($display['profileRecoEnabled'])) {
|
||||
$resp = success([
|
||||
'enabled' => false,
|
||||
'sectionLabel' => $display['profileSectionLabel'] ?? '',
|
||||
'article' => null,
|
||||
]);
|
||||
} else {
|
||||
try {
|
||||
SoulArticleService::autoSyncIfStale();
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
$rows = SoulArticleService::getRecommended(1);
|
||||
if (empty($rows)) {
|
||||
try {
|
||||
SoulArticleService::syncLatest(10, 'MBTI');
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
$rows = SoulArticleService::getRecommended(1);
|
||||
}
|
||||
$article = !empty($rows[0]) ? $rows[0] : null;
|
||||
$resp = success([
|
||||
'enabled' => true,
|
||||
'sectionLabel' => $display['profileSectionLabel'] ?? '',
|
||||
'article' => $article,
|
||||
]);
|
||||
}
|
||||
if (method_exists($resp, 'header')) {
|
||||
$resp->header([
|
||||
'Cache-Control' => 'no-store, no-cache, must-revalidate',
|
||||
'Pragma' => 'no-cache',
|
||||
]);
|
||||
}
|
||||
|
||||
return $resp;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ai/quick-questions
|
||||
* 基于当前用户 MBTI 类型返回快捷提问
|
||||
*/
|
||||
public function quickQuestions()
|
||||
{
|
||||
// 公开接口:无中间件时从 Authorization 解析微信用户(可选)
|
||||
$userId = $this->currentUserId();
|
||||
if ($userId <= 0) {
|
||||
$u = $this->resolveUser();
|
||||
if ($u && ($u['source'] ?? '') === 'wechat') {
|
||||
$userId = (int) ($u['user_id'] ?? $u['userId'] ?? 0);
|
||||
}
|
||||
}
|
||||
$ctx = AiCallService::fetchUserContext($userId);
|
||||
$qs = AiCallService::filterQuickQuestions(AiCallService::quickQuestions($ctx['mbtiType']));
|
||||
return success([
|
||||
'mbtiType' => $ctx['mbtiType'],
|
||||
'nickname' => $ctx['nickname'],
|
||||
'questions' => $qs,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ai/conversations 分页取当前用户会话列表
|
||||
* query: page, pageSize
|
||||
*/
|
||||
public function conversations()
|
||||
{
|
||||
$userId = $this->currentUserId();
|
||||
if ($userId <= 0) return error('请先登录', 401);
|
||||
|
||||
$page = max(1, (int) Request::get('page', 1));
|
||||
$pageSize = min(50, max(1, (int) Request::get('pageSize', 20)));
|
||||
|
||||
$query = AiConversationModel::where('userId', $userId);
|
||||
$total = $query->count();
|
||||
$list = $query
|
||||
->order('lastMessageAt', 'desc')
|
||||
->page($page, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return paginate_response($list, $total, $page, $pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ai/conversations/:id/messages
|
||||
*/
|
||||
public function messages()
|
||||
{
|
||||
$userId = $this->currentUserId();
|
||||
if ($userId <= 0) return error('请先登录', 401);
|
||||
|
||||
$cid = (int) Request::param('id', 0);
|
||||
$conversation = AiConversationModel::where('userId', $userId)->where('id', $cid)->find();
|
||||
if (!$conversation) return error('会话不存在', 404);
|
||||
|
||||
$rows = AiMessageModel::where('conversationId', $cid)
|
||||
->order('id', 'asc')
|
||||
->limit(200)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$list = array_map(function ($r) {
|
||||
return [
|
||||
'id' => (int) $r['id'],
|
||||
'role' => $r['role'],
|
||||
'content' => $r['content'],
|
||||
'providerId' => $r['providerId'],
|
||||
'isDegraded' => (bool) $r['isDegraded'],
|
||||
'createdAt' => (int) $r['createdAt'],
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return success([
|
||||
'conversation' => [
|
||||
'id' => (int) $conversation->id,
|
||||
'title' => (string) $conversation->title,
|
||||
'mbtiType' => (string) $conversation->mbtiType,
|
||||
'messageCount' => (int) $conversation->messageCount,
|
||||
'lastMessageAt' => (int) $conversation->lastMessageAt,
|
||||
],
|
||||
'messages' => $list,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ai/transcribe
|
||||
* 语音转文字:接受前端 audio 文件上传。
|
||||
* 当前占位:若未配置语音识别服务(ai_transcribe_provider),返回 501 + 降级提示,
|
||||
* 前端会降级到"请用键盘麦克风"。接入 ASR 后在此填充 provider 调用即可。
|
||||
*/
|
||||
public function transcribe()
|
||||
{
|
||||
$userId = $this->currentUserId();
|
||||
if ($userId <= 0) return error('请先登录', 401);
|
||||
|
||||
$file = Request::file('audio');
|
||||
if (!$file) return error('未收到音频文件', 400);
|
||||
|
||||
// 读取 system_config.ai_transcribe_provider(未配置则降级)
|
||||
$cfgRow = Db::name('system_config')->where('key', 'ai_transcribe_provider')->find();
|
||||
if (!$cfgRow || empty($cfgRow['value'])) {
|
||||
return error('语音识别未开通,请使用键盘麦克风', 501);
|
||||
}
|
||||
|
||||
// TODO: 二期对接 ASR(如通义听悟/讯飞/whisper API)
|
||||
// 当前阶段:保持降级,避免伪造返回值骗用户
|
||||
return error('语音识别通道建设中,请使用键盘麦克风', 501);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ai/articles/:id/click
|
||||
* 轻量记录点击,用于分析(失败不影响用户)
|
||||
*/
|
||||
public function articleClick()
|
||||
{
|
||||
$id = (int) Request::param('id', 0);
|
||||
if ($id > 0) {
|
||||
try {
|
||||
Db::name('soul_articles')->where('id', $id)->inc('viewCount')->update();
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
}
|
||||
return success(null);
|
||||
}
|
||||
|
||||
/** 从 auth 中间件挂好的 request->user 中取出当前微信用户 id */
|
||||
private function currentUserId(): int
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || ($user['source'] ?? '') !== 'wechat') return 0;
|
||||
return (int) ($user['user_id'] ?? $user['userId'] ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自增今日用量,若已达上限返回 -1
|
||||
*/
|
||||
private function incrementAndCheckDaily(int $userId, int $limit): int
|
||||
{
|
||||
$dateStr = date('Y-m-d');
|
||||
$now = time();
|
||||
|
||||
$row = Db::name('ai_usage_daily')
|
||||
->where('userId', $userId)
|
||||
->where('dateStr', $dateStr)
|
||||
->find();
|
||||
|
||||
if (!$row) {
|
||||
try {
|
||||
Db::name('ai_usage_daily')->insert([
|
||||
'userId' => $userId,
|
||||
'dateStr' => $dateStr,
|
||||
'messageCount' => 1,
|
||||
'createdAt' => $now,
|
||||
'updatedAt' => $now,
|
||||
]);
|
||||
return 1;
|
||||
} catch (\Throwable $e) {
|
||||
// 并发首条:另一请求已插入,回读后再自增
|
||||
$row = Db::name('ai_usage_daily')
|
||||
->where('userId', $userId)
|
||||
->where('dateStr', $dateStr)
|
||||
->find();
|
||||
if (!$row) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$used = (int) $row['messageCount'];
|
||||
if ($used >= $limit) return -1;
|
||||
|
||||
Db::name('ai_usage_daily')
|
||||
->where('id', $row['id'])
|
||||
->update(['messageCount' => $used + 1, 'updatedAt' => $now]);
|
||||
|
||||
return $used + 1;
|
||||
}
|
||||
}
|
||||
84
api/app/controller/api/AiReport.php
Normal file
84
api/app/controller/api/AiReport.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
namespace app\controller\api;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\AiReportService;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* AI 深度画像报告 · 小程序 API
|
||||
*
|
||||
* POST /api/ai/report/create 创建或返回现有 pending 报告
|
||||
* GET /api/ai/report/my-latest 我最近的一份报告(判断是否已买)
|
||||
* GET /api/ai/report/:id 获取报告正文(必须属于当前用户)
|
||||
* POST /api/ai/report/:id/mark-paid-dev 调试用:跳过支付直接置已付
|
||||
* POST /api/ai/report/:id/regenerate 失败后重试生成(仅管理员/作者)
|
||||
*/
|
||||
class AiReport extends BaseController
|
||||
{
|
||||
public function create()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || empty($user['id'])) return error('请先登录', 401);
|
||||
$conversationId = (int) Request::param('conversationId', 0);
|
||||
$mbtiType = trim((string) Request::param('mbtiType', ''));
|
||||
|
||||
$r = AiReportService::createOrGetPending((int) $user['id'], $conversationId, $mbtiType);
|
||||
return success($r);
|
||||
}
|
||||
|
||||
public function myLatest()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || empty($user['id'])) return success(['status' => '']);
|
||||
$r = AiReportService::myLatest((int) $user['id']);
|
||||
if (!$r) return success(['status' => '']);
|
||||
return success($r);
|
||||
}
|
||||
|
||||
public function show($id)
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || empty($user['id'])) return error('请先登录', 401);
|
||||
$r = AiReportService::get((int) $id, (int) $user['id']);
|
||||
if (!$r) return error('报告不存在', 404);
|
||||
|
||||
// 未付费时,content 不下发,只给 summary + 解锁引导
|
||||
if ($r['status'] !== 'done') {
|
||||
unset($r['content']);
|
||||
}
|
||||
return success($r);
|
||||
}
|
||||
|
||||
public function markPaidDev($id)
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || empty($user['id'])) return error('请先登录', 401);
|
||||
$r = AiReportService::get((int) $id, (int) $user['id']);
|
||||
if (!$r) return error('报告不存在', 404);
|
||||
|
||||
// 仅超管或本地 debug 开关允许
|
||||
$role = $user['role'] ?? '';
|
||||
$isSuper = $role === 'superadmin';
|
||||
$isDebug = function_exists('env') ? (bool) env('app.ai_report_paid_dev', false) : false;
|
||||
if (!$isSuper && !$isDebug) {
|
||||
return error('仅测试模式可用', 403);
|
||||
}
|
||||
$ret = AiReportService::markPaidDev((string) $r['orderSn']);
|
||||
return success($ret);
|
||||
}
|
||||
|
||||
public function regenerate($id)
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || empty($user['id'])) return error('请先登录', 401);
|
||||
$r = AiReportService::get((int) $id, (int) $user['id']);
|
||||
if (!$r) return error('报告不存在', 404);
|
||||
if (!in_array($r['status'], ['failed', 'paid'])) {
|
||||
return error('当前状态不支持重试:' . $r['status']);
|
||||
}
|
||||
AiReportService::generate((int) $r['id']);
|
||||
$r2 = AiReportService::get((int) $id, (int) $user['id']);
|
||||
return success($r2);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ namespace app\controller\api;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\PdpDiscResultText;
|
||||
use app\common\service\FeishuLeadWebhookService;
|
||||
use app\common\service\JwtService;
|
||||
use app\common\service\UserJourneyService;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
@@ -22,6 +24,7 @@ class CrmReport extends BaseController
|
||||
* @param string remark 备注,如"申请咨询"/"完成付款"
|
||||
* @param string tags 可选,逗号分隔的微信标签
|
||||
* @param string siteTags 可选,逗号分隔的站内标签
|
||||
* @param bool deepConsult 为 true 时:「了解自己」申请咨询;apiKey 可空,后端按用户归属企业从 cunkebao_keys 解析;并推送飞书获客(若已配置)
|
||||
*/
|
||||
public function report()
|
||||
{
|
||||
@@ -49,6 +52,8 @@ class CrmReport extends BaseController
|
||||
$tags = trim((string) ($this->request->param('tags', '') ?? ''));
|
||||
$siteTags = trim((string) ($this->request->param('siteTags', '') ?? ''));
|
||||
|
||||
$deepConsult = self::isTruthyParam($this->request->param('deepConsult', false));
|
||||
|
||||
// 测评类付费(人脸/MBTI/PDP/DISC):未传 apiKey 时从企业后台配置 cunkebao_keys 解析(与深度服务「完成付款」上报一致)
|
||||
$testType = trim((string) ($this->request->param('testType', '') ?? ''));
|
||||
$testResultId = (int) ($this->request->param('testResultId', 0) ?? 0);
|
||||
@@ -70,6 +75,15 @@ class CrmReport extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
// 「了解自己」申请咨询:类目未配 consultWechat 时,按用户归属企业回落 cunkebao_keys
|
||||
if ($apiKey === '' && $deepConsult && $userId > 0) {
|
||||
$eidDeep = (int) (Db::name('wechat_users')->where('id', $userId)->value('enterpriseId') ?? 0);
|
||||
$fk = self::readSingleCunkebaoApiKeyWithFallback($eidDeep);
|
||||
if ($fk !== '') {
|
||||
$apiKey = $fk;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用企业 cunkebao_keys 的「测评付费」上报:须校验已支付(防止未支付伪造「完成付款」)
|
||||
if ($resolvedFromEnterpriseKeys && !empty($apiKey)) {
|
||||
$deny = self::verifyTestPaidForCrmReport($userId, $testType, $testResultId);
|
||||
@@ -78,19 +92,58 @@ class CrmReport extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
// apiKey 为空则跳过,不影响主流程
|
||||
if (empty($apiKey)) {
|
||||
if (empty($apiKey) && !$deepConsult) {
|
||||
return success(['reported' => false, 'reason' => 'no_api_key']);
|
||||
}
|
||||
|
||||
// 测评结果:备注仅摘要;站内/微信标签含「测评名,结果」
|
||||
if ($testResultId > 0 && $userId > 0) {
|
||||
self::applyTestResultSummaryToReportPayload($userId, $testType, $testResultId, $remark, $tags, $siteTags);
|
||||
$eidForMgmt = $contextEnterpriseId;
|
||||
if ($eidForMgmt <= 0) {
|
||||
$eidForMgmt = (int) (Db::name('test_results')->where('id', $testResultId)->where('userId', $userId)->value('enterpriseId') ?? 0);
|
||||
}
|
||||
self::appendCrmRemarkUserJourney($userId, $eidForMgmt, $remark);
|
||||
}
|
||||
|
||||
$ok = self::doReport($userId, $apiKey, $source, $remark, $tags, $siteTags);
|
||||
// 申请咨询:备注中附带用户管理摘要与旅程(与飞书卡片「最近行为」同源)
|
||||
if ($deepConsult && $userId > 0 && !empty($apiKey)) {
|
||||
$eidJ = (int) (Db::name('wechat_users')->where('id', $userId)->value('enterpriseId') ?? 0);
|
||||
self::appendCrmRemarkUserJourney($userId, $eidJ, $remark);
|
||||
}
|
||||
|
||||
return success(['reported' => $ok, 'reason' => $ok ? '' : 'api_error']);
|
||||
$ok = false;
|
||||
if (!empty($apiKey)) {
|
||||
$ok = self::doReport($userId, $apiKey, $source, $remark, $tags, $siteTags);
|
||||
}
|
||||
|
||||
if ($deepConsult && $userId > 0) {
|
||||
$feishuSource = $source !== '' ? $source : '深度服务·申请咨询';
|
||||
FeishuLeadWebhookService::onDeepServiceConsultApply($userId, $feishuSource, $siteTags);
|
||||
}
|
||||
|
||||
if ($ok) {
|
||||
return success(['reported' => true, 'reason' => '']);
|
||||
}
|
||||
|
||||
return success([
|
||||
'reported' => false,
|
||||
'reason' => empty($apiKey) ? 'no_api_key' : 'api_error',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param mixed $v */
|
||||
private static function isTruthyParam($v): bool
|
||||
{
|
||||
if ($v === true || $v === 1) {
|
||||
return true;
|
||||
}
|
||||
if ($v === false || $v === 0 || $v === null || $v === '') {
|
||||
return false;
|
||||
}
|
||||
$s = strtolower(trim((string) $v));
|
||||
|
||||
return in_array($s, ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -398,6 +451,8 @@ class CrmReport extends BaseController
|
||||
$tags = $merged;
|
||||
$siteTags = $merged;
|
||||
|
||||
self::appendCrmRemarkUserJourney($userId, $enterpriseId, $remark);
|
||||
|
||||
self::doReport($userId, $apiKey, $source, $remark, $tags, $siteTags);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('[CrmReport] reportTestCompletion 异常 userId=' . $userId . ' err=' . $e->getMessage());
|
||||
@@ -460,6 +515,36 @@ class CrmReport extends BaseController
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 存客宝 remark 追加:用户管理一行 + analytics 用户旅程(与飞书/出站 Hook 字段同源)
|
||||
*/
|
||||
private static function appendCrmRemarkUserJourney(int $userId, int $enterpriseIdForMgmt, string &$remark): void
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
$blocks = [];
|
||||
$m = UserJourneyService::managementSummaryLine($userId, $enterpriseIdForMgmt);
|
||||
if ($m !== '') {
|
||||
$blocks[] = '【用户管理】' . $m;
|
||||
}
|
||||
$jb = UserJourneyService::journeyLinesToRemarkBlock(UserJourneyService::recentBehaviorLines($userId, 10), 720);
|
||||
if ($jb !== '') {
|
||||
$blocks[] = $jb;
|
||||
}
|
||||
if (count($blocks) === 0) {
|
||||
return;
|
||||
}
|
||||
$append = implode("\n", $blocks);
|
||||
$remark = trim($remark === '' ? $append : ($remark . "\n" . $append));
|
||||
$cap = 900;
|
||||
if (function_exists('mb_strlen') && mb_strlen($remark) > $cap) {
|
||||
$remark = mb_substr($remark, 0, $cap) . '…';
|
||||
} elseif (!function_exists('mb_strlen') && strlen($remark) > $cap) {
|
||||
$remark = substr($remark, 0, $cap) . '…';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部通用上报(供 report() 接口和 reportTestCompletion 共用)
|
||||
*/
|
||||
|
||||
58
api/app/controller/api/MpConfig.php
Normal file
58
api/app/controller/api/MpConfig.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
namespace app\controller\api;
|
||||
|
||||
use app\BaseController;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 小程序公开运行配置
|
||||
* - GET /api/mp/tabbar : 返回底部 TabBar 动态布局
|
||||
*
|
||||
* 不强制登录,结果做 60s 软缓存(客户端也会做 localStorage 缓存)。
|
||||
*/
|
||||
class MpConfig extends BaseController
|
||||
{
|
||||
public function tabbar()
|
||||
{
|
||||
$items = Db::name('mp_tabbar_items')
|
||||
->where('visible', 1)
|
||||
->order('sortOrder', 'asc')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$list = [];
|
||||
foreach ($items as $row) {
|
||||
$iconKey = $row['iconKey'] ?? 'home';
|
||||
$iconUrl = $row['iconUrl'] ?? null;
|
||||
if ($iconKey === 'ai') {
|
||||
// 神仙AI:纯圆老头像(无放射线装饰),避免后台误配导致小图标异常
|
||||
$iconUrl = '/images/shenxian-oldman-circle.png';
|
||||
}
|
||||
$list[] = [
|
||||
'id' => (int) $row['id'],
|
||||
'pagePath' => $row['pagePath'],
|
||||
'text' => $row['text'],
|
||||
'iconKey' => $iconKey,
|
||||
'iconUrl' => $iconUrl,
|
||||
'highlight' => (int) ($row['highlight'] ?? 0) === 1,
|
||||
'badgeKey' => $row['badgeKey'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
// 兜底:后台还没配/表空,返回硬编码 4 项,保证小程序永远可用
|
||||
if (empty($list)) {
|
||||
$list = [
|
||||
['id' => 0, 'pagePath' => 'pages/index/index', 'text' => '首页', 'iconKey' => 'home', 'iconUrl' => null, 'highlight' => false, 'badgeKey' => null],
|
||||
['id' => 0, 'pagePath' => 'pages/index/camera', 'text' => '拍摄', 'iconKey' => 'camera', 'iconUrl' => null, 'highlight' => true, 'badgeKey' => null],
|
||||
['id' => 0, 'pagePath' => 'pages/ai-chat/index', 'text' => '神仙AI', 'iconKey' => 'ai', 'iconUrl' => '/images/shenxian-oldman-circle.png', 'highlight' => false, 'badgeKey' => null],
|
||||
['id' => 0, 'pagePath' => 'pages/profile/index', 'text' => '我', 'iconKey' => 'profile', 'iconUrl' => null, 'highlight' => false, 'badgeKey' => null],
|
||||
];
|
||||
}
|
||||
|
||||
return success([
|
||||
'items' => $list,
|
||||
'version' => (int) Db::name('mp_tabbar_items')->max('updatedAt'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use app\model\PricingConfig as PricingConfigModel;
|
||||
use app\model\UserProfile as UserProfileModel;
|
||||
use app\common\service\JwtService;
|
||||
use app\common\service\FeishuLeadWebhookService;
|
||||
use app\common\service\AiReportService;
|
||||
use think\facade\Request;
|
||||
use think\facade\Db;
|
||||
|
||||
@@ -364,6 +365,14 @@ class Payment extends BaseController
|
||||
// 企业四项测试支付后,订单金额进入企业余额
|
||||
$this->creditEnterpriseBalanceForOrder($order, $paidAmountFen, $now);
|
||||
|
||||
// AI 深度报告:支付成功后置 paid 并触发报告生成(幂等)
|
||||
if (($order['productType'] ?? '') === 'ai_deep_report' && !empty($order['orderNo'])) {
|
||||
try {
|
||||
AiReportService::markPaid((string) $order['orderNo']);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
}
|
||||
|
||||
if (($order['productType'] ?? '') !== 'recharge') {
|
||||
// 触发分销佣金结算
|
||||
try {
|
||||
@@ -372,6 +381,43 @@ class Payment extends BaseController
|
||||
// 佣金结算失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
// 成交归因:写入一条 analytics_events,便于漏斗统计
|
||||
try {
|
||||
$userId = (int) ($order['userId'] ?? 0);
|
||||
$inviterRow = null;
|
||||
if ($userId > 0) {
|
||||
$inviterRow = Db::name('distribution_bindings')
|
||||
->where('inviteeId', $userId)
|
||||
->where('status', 'active')
|
||||
->where('expireAt', '>', time())
|
||||
->order('id', 'desc')
|
||||
->field('inviterId')
|
||||
->find();
|
||||
}
|
||||
$amountFenForEvent = (int) ($order['amount'] ?? 0);
|
||||
$props = [
|
||||
'orderId' => (int) $order['id'],
|
||||
'orderNo' => $order['orderNo'] ?? '',
|
||||
'amountFen' => $amountFenForEvent,
|
||||
'amountYuan'=> $amountFenForEvent / 100,
|
||||
'productType' => $order['productType'] ?? '',
|
||||
'testType' => $order['testType'] ?? '',
|
||||
'inviterId' => $inviterRow ? (int) ($inviterRow['inviterId'] ?? 0) : 0,
|
||||
];
|
||||
Db::name('analytics_events')->insert([
|
||||
'userId' => $userId ?: null,
|
||||
'eventName' => 'pay_success_attribution',
|
||||
'pagePath' => 'server/payment/notify',
|
||||
'propsJson' => json_encode($props, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE),
|
||||
'clientTs' => (int) ($now * 1000),
|
||||
'platform' => 'server',
|
||||
'sessionId' => null,
|
||||
'createdAt' => date('Y-m-d H:i:s', $now),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// 埋点失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
return success([
|
||||
@@ -461,6 +507,14 @@ class Payment extends BaseController
|
||||
// 企业四项测试支付后,订单金额进入企业余额
|
||||
$this->creditEnterpriseBalanceForOrder($localOrder, $finalAmount, $now);
|
||||
|
||||
// AI 深度报告:查询确认支付时补偿置 paid(幂等)
|
||||
if (($localOrder['productType'] ?? '') === 'ai_deep_report' && !empty($localOrder['orderNo'])) {
|
||||
try {
|
||||
AiReportService::markPaid((string) $localOrder['orderNo']);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
}
|
||||
|
||||
if (($localOrder['productType'] ?? '') !== 'recharge') {
|
||||
// 触发分销佣金结算
|
||||
try {
|
||||
|
||||
29
api/app/controller/superadmin/AiMonitor.php
Normal file
29
api/app/controller/superadmin/AiMonitor.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
namespace app\controller\superadmin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\AiBalanceAlertService;
|
||||
|
||||
/**
|
||||
* 超管 · 神仙 AI 监控面板
|
||||
*
|
||||
* - POST /api/v1/superadmin/ai/balance-check 手动触发余额预警扫描
|
||||
* 可直接由宝塔 cron 每 12 小时调用一次
|
||||
*/
|
||||
class AiMonitor extends BaseController
|
||||
{
|
||||
public function balanceCheck()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$r = AiBalanceAlertService::scanAndAlert();
|
||||
return success($r, "扫描完成:推送 {$r['alerted']} 条,跳过 {$r['skipped']} 条(当日去重)");
|
||||
}
|
||||
|
||||
private function ensureSuperadmin()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
|
||||
abort(403, '无权限访问');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
namespace app\controller\superadmin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\AnalyticsEventLabels;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
@@ -11,30 +12,42 @@ use think\facade\Request;
|
||||
class Analytics extends BaseController
|
||||
{
|
||||
/**
|
||||
* GET /api/v1/superadmin/analytics/summary?days=7
|
||||
* GET /api/v1/superadmin/analytics/summary?days=7&eventName=可选
|
||||
* 返回字段附加 eventNameCn(中文名)
|
||||
*/
|
||||
public function summary()
|
||||
{
|
||||
$days = min(90, max(1, (int) Request::param('days', 7)));
|
||||
$since = date('Y-m-d H:i:s', time() - $days * 86400);
|
||||
$eventFilter = trim((string) Request::param('eventName', ''));
|
||||
|
||||
try {
|
||||
$list = Db::name('analytics_events')
|
||||
$q = Db::name('analytics_events')
|
||||
->field('eventName, COUNT(*) AS cnt')
|
||||
->where('createdAt', '>=', $since)
|
||||
->where('createdAt', '>=', $since);
|
||||
if ($eventFilter !== '') {
|
||||
$q->where('eventName', $eventFilter);
|
||||
}
|
||||
$list = $q
|
||||
->group('eventName')
|
||||
->order('cnt', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$total = Db::name('analytics_events')
|
||||
->where('createdAt', '>=', $since)
|
||||
->count();
|
||||
$list = AnalyticsEventLabels::withCn($list);
|
||||
|
||||
$cntQuery = Db::name('analytics_events')
|
||||
->where('createdAt', '>=', $since);
|
||||
if ($eventFilter !== '') {
|
||||
$cntQuery->where('eventName', $eventFilter);
|
||||
}
|
||||
$total = $cntQuery->count();
|
||||
|
||||
return success([
|
||||
'days' => $days,
|
||||
'total' => (int) $total,
|
||||
'list' => $list,
|
||||
'labels' => AnalyticsEventLabels::all(),
|
||||
'tableMissing' => false,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -42,13 +55,14 @@ class Analytics extends BaseController
|
||||
'days' => $days,
|
||||
'total' => 0,
|
||||
'list' => [],
|
||||
'labels' => AnalyticsEventLabels::all(),
|
||||
'tableMissing' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/superadmin/analytics/events?days=7&page=1&pageSize=50
|
||||
* GET /api/v1/superadmin/analytics/events?days=7&page=1&pageSize=50&eventName=&userId=
|
||||
*/
|
||||
public function events()
|
||||
{
|
||||
@@ -56,14 +70,21 @@ class Analytics extends BaseController
|
||||
$since = date('Y-m-d H:i:s', time() - $days * 86400);
|
||||
$page = max(1, (int) Request::param('page', 1));
|
||||
$pageSize = min(100, max(10, (int) Request::param('pageSize', 50)));
|
||||
$eventFilter = trim((string) Request::param('eventName', ''));
|
||||
$userIdFilter = (int) Request::param('userId', 0);
|
||||
|
||||
try {
|
||||
$total = (int) Db::name('analytics_events')
|
||||
->where('createdAt', '>=', $since)
|
||||
->count();
|
||||
$baseQ = Db::name('analytics_events')->where('createdAt', '>=', $since);
|
||||
if ($eventFilter !== '') {
|
||||
$baseQ->where('eventName', $eventFilter);
|
||||
}
|
||||
if ($userIdFilter > 0) {
|
||||
$baseQ->where('userId', $userIdFilter);
|
||||
}
|
||||
|
||||
$total = (int) (clone $baseQ)->count();
|
||||
$offset = ($page - 1) * $pageSize;
|
||||
$rows = Db::name('analytics_events')
|
||||
->where('createdAt', '>=', $since)
|
||||
$rows = $baseQ
|
||||
->order('id', 'desc')
|
||||
->limit($offset, $pageSize)
|
||||
->select()
|
||||
@@ -80,6 +101,8 @@ class Analytics extends BaseController
|
||||
}
|
||||
unset($r);
|
||||
|
||||
$rows = AnalyticsEventLabels::withCn($rows);
|
||||
|
||||
return success([
|
||||
'list' => $rows,
|
||||
'total' => $total,
|
||||
@@ -97,4 +120,260 @@ class Analytics extends BaseController
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/superadmin/analytics/user-journey?userId=&days=30
|
||||
* 单个用户旅程:按时间倒序展示最近 200 条事件(中文名)
|
||||
*/
|
||||
public function userJourney()
|
||||
{
|
||||
$userId = (int) Request::param('userId', 0);
|
||||
if ($userId <= 0) {
|
||||
return error('userId 不能为空', 400);
|
||||
}
|
||||
$days = min(180, max(1, (int) Request::param('days', 30)));
|
||||
$since = date('Y-m-d H:i:s', time() - $days * 86400);
|
||||
|
||||
try {
|
||||
$rows = Db::name('analytics_events')
|
||||
->where('userId', $userId)
|
||||
->where('createdAt', '>=', $since)
|
||||
->order('id', 'desc')
|
||||
->limit(200)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
foreach ($rows as &$r) {
|
||||
if (!empty($r['propsJson'])) {
|
||||
$decoded = json_decode($r['propsJson'], true);
|
||||
$r['props'] = is_array($decoded) ? $decoded : null;
|
||||
} else {
|
||||
$r['props'] = null;
|
||||
}
|
||||
unset($r['propsJson']);
|
||||
}
|
||||
unset($r);
|
||||
$rows = AnalyticsEventLabels::withCn($rows);
|
||||
|
||||
return success([
|
||||
'userId' => $userId,
|
||||
'days' => $days,
|
||||
'list' => $rows,
|
||||
'total' => count($rows),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return success([
|
||||
'userId' => $userId,
|
||||
'days' => $days,
|
||||
'list' => [],
|
||||
'total' => 0,
|
||||
'tableMissing' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/superadmin/analytics/share-stats?days=30
|
||||
* 分享与邀请统计:
|
||||
* - 每位用户累计分享次数
|
||||
* - 每位用户邀请绑定人数(distribution_bindings)
|
||||
* - 每位用户累计产生分润(distribution_commissions)
|
||||
*/
|
||||
public function shareStats()
|
||||
{
|
||||
$days = min(180, max(1, (int) Request::param('days', 30)));
|
||||
$since = date('Y-m-d H:i:s', time() - $days * 86400);
|
||||
$page = max(1, (int) Request::param('page', 1));
|
||||
$pageSize = min(100, max(10, (int) Request::param('pageSize', 30)));
|
||||
|
||||
$out = [];
|
||||
$total = 0;
|
||||
$tableMissing = false;
|
||||
|
||||
try {
|
||||
// 分享次数:share / tap_share_moment / tap_share_friend 都算
|
||||
$shareEvents = ['share', 'tap_share_moment', 'tap_share_friend'];
|
||||
$shareRows = Db::name('analytics_events')
|
||||
->field('userId, COUNT(*) AS shareCount')
|
||||
->whereIn('eventName', $shareEvents)
|
||||
->where('createdAt', '>=', $since)
|
||||
->where('userId', '>', 0)
|
||||
->group('userId')
|
||||
->order('shareCount', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($shareRows as $row) {
|
||||
$uid = (int) ($row['userId'] ?? 0);
|
||||
if ($uid <= 0) continue;
|
||||
$out[$uid] = [
|
||||
'userId' => $uid,
|
||||
'shareCount' => (int) ($row['shareCount'] ?? 0),
|
||||
'inviteBound' => 0,
|
||||
'totalCommissionFen' => 0,
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$tableMissing = true;
|
||||
}
|
||||
|
||||
// 邀请绑定人数(按 inviteeId 去重,只统计当前 active)
|
||||
try {
|
||||
$bindRows = Db::name('distribution_bindings')
|
||||
->field('inviterId AS userId, COUNT(DISTINCT inviteeId) AS cnt')
|
||||
->where('status', 'active')
|
||||
->group('inviterId')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($bindRows as $row) {
|
||||
$uid = (int) ($row['userId'] ?? 0);
|
||||
if ($uid <= 0) continue;
|
||||
if (!isset($out[$uid])) {
|
||||
$out[$uid] = [
|
||||
'userId' => $uid,
|
||||
'shareCount' => 0,
|
||||
'inviteBound' => 0,
|
||||
'totalCommissionFen' => 0,
|
||||
];
|
||||
}
|
||||
$out[$uid]['inviteBound'] = (int) ($row['cnt'] ?? 0);
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
// 分润金额(从 commission_records 聚合)
|
||||
try {
|
||||
$commRows = Db::name('commission_records')
|
||||
->field('inviterId AS userId, SUM(commissionFen) AS sumFen')
|
||||
->where('commissionFen', '>', 0)
|
||||
->group('inviterId')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($commRows as $row) {
|
||||
$uid = (int) ($row['userId'] ?? 0);
|
||||
if ($uid <= 0) continue;
|
||||
if (!isset($out[$uid])) {
|
||||
$out[$uid] = [
|
||||
'userId' => $uid,
|
||||
'shareCount' => 0,
|
||||
'inviteBound' => 0,
|
||||
'totalCommissionFen' => 0,
|
||||
];
|
||||
}
|
||||
$out[$uid]['totalCommissionFen'] = (int) ($row['sumFen'] ?? 0);
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
// 合并后按邀请人数、分润、分享次数排序
|
||||
$list = array_values($out);
|
||||
usort($list, function ($a, $b) {
|
||||
if ($a['totalCommissionFen'] !== $b['totalCommissionFen']) {
|
||||
return $b['totalCommissionFen'] <=> $a['totalCommissionFen'];
|
||||
}
|
||||
if ($a['inviteBound'] !== $b['inviteBound']) {
|
||||
return $b['inviteBound'] <=> $a['inviteBound'];
|
||||
}
|
||||
return $b['shareCount'] <=> $a['shareCount'];
|
||||
});
|
||||
|
||||
$total = count($list);
|
||||
$offset = ($page - 1) * $pageSize;
|
||||
$pageList = array_slice($list, $offset, $pageSize);
|
||||
|
||||
// 补用户资料
|
||||
$uids = array_column($pageList, 'userId');
|
||||
if (!empty($uids)) {
|
||||
try {
|
||||
$users = Db::name('wechat_users')
|
||||
->whereIn('id', $uids)
|
||||
->field('id, nickname, avatar, phone, enterpriseId, createdAt')
|
||||
->select()
|
||||
->toArray();
|
||||
$byId = [];
|
||||
foreach ($users as $u) { $byId[(int) $u['id']] = $u; }
|
||||
foreach ($pageList as &$row) {
|
||||
$u = $byId[$row['userId']] ?? null;
|
||||
$row['nickname'] = $u['nickname'] ?? '';
|
||||
$row['avatar'] = $u['avatar'] ?? '';
|
||||
$row['phone'] = $u['phone'] ?? '';
|
||||
$row['enterpriseId'] = $u['enterpriseId'] ?? null;
|
||||
$row['createdAt'] = $u['createdAt'] ?? null;
|
||||
}
|
||||
unset($row);
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
|
||||
return success([
|
||||
'days' => $days,
|
||||
'total' => $total,
|
||||
'list' => $pageList,
|
||||
'page' => $page,
|
||||
'pageSize' => $pageSize,
|
||||
'tableMissing' => $tableMissing,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/superadmin/analytics/share-funnel?days=14
|
||||
* 分享漏斗:
|
||||
* 1) 结果页访问(pages/result/* page_view)
|
||||
* 2) 分享动作(share / tap_share_moment / tap_share_friend)
|
||||
* 3) 好友登录(login_silent_success)
|
||||
* 4) 好友付费(pay_success_attribution 或 pay_success)
|
||||
* 5) 累计分润(distribution_commissions)
|
||||
*/
|
||||
public function shareFunnel()
|
||||
{
|
||||
$days = min(180, max(1, (int) Request::param('days', 14)));
|
||||
$since = date('Y-m-d H:i:s', time() - $days * 86400);
|
||||
|
||||
$out = [
|
||||
['stage' => '结果页访问', 'value' => 0],
|
||||
['stage' => '分享动作', 'value' => 0],
|
||||
['stage' => '好友登录', 'value' => 0],
|
||||
['stage' => '好友付费', 'value' => 0],
|
||||
['stage' => '累计分润(元)', 'value' => 0],
|
||||
];
|
||||
$tableMissing = false;
|
||||
|
||||
try {
|
||||
$out[0]['value'] = (int) Db::name('analytics_events')
|
||||
->where('eventName', 'page_view')
|
||||
->where('pagePath', 'like', 'pages/result/%')
|
||||
->where('createdAt', '>=', $since)
|
||||
->count();
|
||||
|
||||
$out[1]['value'] = (int) Db::name('analytics_events')
|
||||
->whereIn('eventName', ['share', 'tap_share_moment', 'tap_share_friend'])
|
||||
->where('createdAt', '>=', $since)
|
||||
->count();
|
||||
|
||||
$out[2]['value'] = (int) Db::name('analytics_events')
|
||||
->where('eventName', 'login_silent_success')
|
||||
->where('createdAt', '>=', $since)
|
||||
->count();
|
||||
|
||||
$out[3]['value'] = (int) Db::name('analytics_events')
|
||||
->whereIn('eventName', ['pay_success_attribution', 'pay_success'])
|
||||
->where('createdAt', '>=', $since)
|
||||
->count();
|
||||
} catch (\Throwable $e) {
|
||||
$tableMissing = true;
|
||||
}
|
||||
|
||||
// 分润金额(元)— commission_records.commissionFen
|
||||
// createdAt 在该表为整数秒时间戳,而不是 datetime;需要改成整数比较
|
||||
try {
|
||||
$sinceTs = time() - $days * 86400;
|
||||
$totalFen = (int) Db::name('commission_records')
|
||||
->where('createdAt', '>=', $sinceTs)
|
||||
->where('commissionFen', '>', 0)
|
||||
->sum('commissionFen');
|
||||
$out[4]['value'] = round($totalFen / 100, 2);
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
return success([
|
||||
'days' => $days,
|
||||
'funnel' => $out,
|
||||
'tableMissing' => $tableMissing,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
127
api/app/controller/superadmin/MpTabBar.php
Normal file
127
api/app/controller/superadmin/MpTabBar.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
namespace app\controller\superadmin;
|
||||
|
||||
use app\BaseController;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 超管 · 小程序 TabBar 配置管理
|
||||
*/
|
||||
class MpTabBar extends BaseController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$list = Db::name('mp_tabbar_items')
|
||||
->order('sortOrder', 'asc')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
return success(['list' => $list]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /superadmin/tabbar/save 整体覆盖保存
|
||||
* body: { items: [{id?, sortOrder, pagePath, text, iconKey, highlight, visible, badgeKey}] }
|
||||
*/
|
||||
public function save()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$items = Request::param('items/a', []);
|
||||
if (!is_array($items) || count($items) === 0) {
|
||||
return error('items 不能为空');
|
||||
}
|
||||
if (count($items) > 5) {
|
||||
return error('最多支持 5 个 Tab');
|
||||
}
|
||||
|
||||
$visibleCount = 0;
|
||||
foreach ($items as $it) {
|
||||
if ((int) ($it['visible'] ?? 1) === 1) $visibleCount++;
|
||||
}
|
||||
if ($visibleCount < 2) {
|
||||
return error('至少保留 2 个可见 Tab');
|
||||
}
|
||||
if ($visibleCount > 5) {
|
||||
return error('可见 Tab 不得超过 5 个');
|
||||
}
|
||||
|
||||
$now = time();
|
||||
Db::startTrans();
|
||||
try {
|
||||
$keepIds = [];
|
||||
foreach ($items as $idx => $it) {
|
||||
$data = [
|
||||
'sortOrder' => (int) ($it['sortOrder'] ?? ($idx + 1) * 10),
|
||||
'pagePath' => trim((string) ($it['pagePath'] ?? '')),
|
||||
'text' => trim((string) ($it['text'] ?? '')),
|
||||
'iconKey' => trim((string) ($it['iconKey'] ?? 'home')),
|
||||
'iconUrl' => isset($it['iconUrl']) && $it['iconUrl'] !== '' ? (string) $it['iconUrl'] : null,
|
||||
'visible' => (int) ($it['visible'] ?? 1) === 1 ? 1 : 0,
|
||||
'highlight' => (int) ($it['highlight'] ?? 0) === 1 ? 1 : 0,
|
||||
'badgeKey' => isset($it['badgeKey']) ? (string) $it['badgeKey'] : null,
|
||||
'updatedAt' => $now,
|
||||
];
|
||||
if ($data['pagePath'] === '' || $data['text'] === '') {
|
||||
throw new \Exception('pagePath / text 不能为空');
|
||||
}
|
||||
|
||||
if (!empty($it['id'])) {
|
||||
Db::name('mp_tabbar_items')->where('id', (int) $it['id'])->update($data);
|
||||
$keepIds[] = (int) $it['id'];
|
||||
} else {
|
||||
$data['createdAt'] = $now;
|
||||
$newId = Db::name('mp_tabbar_items')->insertGetId($data);
|
||||
$keepIds[] = (int) $newId;
|
||||
}
|
||||
}
|
||||
// 删除不在本次保存列表中的旧行
|
||||
Db::name('mp_tabbar_items')->whereNotIn('id', $keepIds)->delete();
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
return error('保存失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
return success(null, '已保存 ' . count($items) . ' 个 Tab 项');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /superadmin/tabbar/reorder 仅排序
|
||||
* body: { ids: [id1,id2,id3,...] }
|
||||
*/
|
||||
public function reorder()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$ids = Request::param('ids/a', []);
|
||||
if (!is_array($ids) || empty($ids)) return error('ids 不能为空');
|
||||
$now = time();
|
||||
foreach ($ids as $idx => $id) {
|
||||
Db::name('mp_tabbar_items')->where('id', (int) $id)->update([
|
||||
'sortOrder' => ($idx + 1) * 10,
|
||||
'updatedAt' => $now,
|
||||
]);
|
||||
}
|
||||
return success(null, '排序已更新');
|
||||
}
|
||||
|
||||
public function remove()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$id = (int) Request::param('id', 0);
|
||||
if ($id <= 0) return error('id 非法');
|
||||
$left = Db::name('mp_tabbar_items')->where('visible', 1)->where('id', '<>', $id)->count();
|
||||
if ($left < 2) return error('至少保留 2 个可见 Tab,不能再删');
|
||||
Db::name('mp_tabbar_items')->where('id', $id)->delete();
|
||||
return success(null, '已删除');
|
||||
}
|
||||
|
||||
private function ensureSuperadmin()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
|
||||
abort(403, '无权限访问');
|
||||
}
|
||||
}
|
||||
}
|
||||
79
api/app/controller/superadmin/ProfitRule.php
Normal file
79
api/app/controller/superadmin/ProfitRule.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
namespace app\controller\superadmin;
|
||||
|
||||
use app\BaseController;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
class ProfitRule extends BaseController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$list = Db::name('profit_sharing_rules')->order('id', 'asc')->select()->toArray();
|
||||
foreach ($list as &$row) {
|
||||
$row['receivers'] = json_decode($row['receivers'] ?? '[]', true) ?: [];
|
||||
}
|
||||
unset($row);
|
||||
return success(['list' => $list]);
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$id = (int) Request::param('id', 0);
|
||||
$productType = trim((string) Request::param('productType', ''));
|
||||
$name = trim((string) Request::param('name', ''));
|
||||
$receivers = Request::param('receivers/a', []);
|
||||
$status = Request::param('status', 'active');
|
||||
if ($productType === '' || $name === '') return error('productType / name 必填');
|
||||
if (!is_array($receivers) || count($receivers) === 0) return error('至少一个收款人');
|
||||
|
||||
// 校验比例合计 = 1
|
||||
$sum = 0.0;
|
||||
foreach ($receivers as $r) { $sum += (float) ($r['ratio'] ?? 0); }
|
||||
if (abs($sum - 1.0) > 0.005) {
|
||||
return error('分账比例合计必须等于 100%(当前 ' . round($sum * 100, 2) . '%)');
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$data = [
|
||||
'productType' => $productType,
|
||||
'name' => $name,
|
||||
'receivers' => json_encode($receivers, JSON_UNESCAPED_UNICODE),
|
||||
'status' => $status === 'disabled' ? 'disabled' : 'active',
|
||||
'updatedAt' => $now,
|
||||
];
|
||||
|
||||
if ($id > 0) {
|
||||
Db::name('profit_sharing_rules')->where('id', $id)->update($data);
|
||||
} else {
|
||||
$dup = Db::name('profit_sharing_rules')->where('productType', $productType)->find();
|
||||
if ($dup) return error('该产品类型已存在规则,请编辑现有规则');
|
||||
$data['createdAt'] = $now;
|
||||
Db::name('profit_sharing_rules')->insert($data);
|
||||
}
|
||||
return success(null, '已保存');
|
||||
}
|
||||
|
||||
public function toggle($id)
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$row = Db::name('profit_sharing_rules')->where('id', (int) $id)->find();
|
||||
if (!$row) return error('规则不存在');
|
||||
$next = $row['status'] === 'active' ? 'disabled' : 'active';
|
||||
Db::name('profit_sharing_rules')->where('id', (int) $id)->update([
|
||||
'status' => $next,
|
||||
'updatedAt' => time(),
|
||||
]);
|
||||
return success(['status' => $next]);
|
||||
}
|
||||
|
||||
private function ensureSuperadmin()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
|
||||
abort(403, '无权限访问');
|
||||
}
|
||||
}
|
||||
}
|
||||
232
api/app/controller/superadmin/SoulArticle.php
Normal file
232
api/app/controller/superadmin/SoulArticle.php
Normal file
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
namespace app\controller\superadmin;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\AiChatArticleDisplayService;
|
||||
use app\common\service\SoulArticleService;
|
||||
use app\model\SoulArticle as SoulArticleModel;
|
||||
use app\model\AiProvider as AiProviderModel;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 超管 · Soul 文章管理(采集 / 推荐 / AI 健康概览)
|
||||
*/
|
||||
class SoulArticle extends BaseController
|
||||
{
|
||||
/** POST /api/v1/superadmin/soul-articles/sync */
|
||||
public function sync()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
|
||||
$limit = (int) Request::post('limit', 10);
|
||||
$limit = max(1, min($limit, 30));
|
||||
$tag = trim((string) Request::post('tag', 'MBTI'));
|
||||
$keyword = trim((string) Request::post('keyword', ''));
|
||||
if ($tag === '') $tag = 'MBTI';
|
||||
|
||||
$r = $keyword !== ''
|
||||
? SoulArticleService::syncByKeyword($keyword, $limit, $tag)
|
||||
: SoulArticleService::syncLatest($limit, $tag);
|
||||
if (!empty($r['error'])) {
|
||||
return error($r['error'], 500);
|
||||
}
|
||||
if ($keyword !== '') {
|
||||
return success($r, "搜索并添加完成:新增 {$r['created']} 篇,更新 {$r['updated']} 篇");
|
||||
}
|
||||
return success($r, "采集完成:新增 {$r['created']} 篇,更新 {$r['updated']} 篇");
|
||||
}
|
||||
|
||||
/** GET /api/v1/superadmin/soul-articles/ai-chat-display */
|
||||
public function aiChatDisplayGet()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
return success(AiChatArticleDisplayService::getSettings());
|
||||
}
|
||||
|
||||
/** POST /api/v1/superadmin/soul-articles/ai-chat-display */
|
||||
public function aiChatDisplaySave()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$input = Request::post();
|
||||
$settings = AiChatArticleDisplayService::saveSettings(is_array($input) ? $input : []);
|
||||
return success($settings, '已保存');
|
||||
}
|
||||
|
||||
/** GET /api/v1/superadmin/soul-articles */
|
||||
public function index()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
|
||||
$page = max(1, (int) Request::get('page', 1));
|
||||
$pageSize = min(50, max(1, (int) Request::get('pageSize', 20)));
|
||||
$isReco = Request::get('isRecommended', '');
|
||||
$keyword = trim((string) Request::get('keyword', ''));
|
||||
$tag = trim((string) Request::get('tag', ''));
|
||||
$dateRange = Request::get('dateRange', '');
|
||||
|
||||
$query = SoulArticleModel::order('isRecommended', 'desc')
|
||||
->order('recommendedOrder', 'asc')
|
||||
->order('publishedAt', 'desc');
|
||||
if ($isReco === '1' || $isReco === 1) {
|
||||
$query = $query->where('isRecommended', 1);
|
||||
} elseif ($isReco === '0' || $isReco === 0) {
|
||||
$query = $query->where('isRecommended', 0);
|
||||
}
|
||||
if ($keyword !== '') {
|
||||
$query = $query->whereLike('title', "%{$keyword}%");
|
||||
}
|
||||
if ($tag !== '') {
|
||||
$query = $query->where('tag', $tag);
|
||||
}
|
||||
if (is_array($dateRange) && count($dateRange) === 2) {
|
||||
$start = (int) strtotime((string) $dateRange[0] . ' 00:00:00');
|
||||
$end = (int) strtotime((string) $dateRange[1] . ' 23:59:59');
|
||||
if ($start > 0 && $end > 0) {
|
||||
$query = $query->whereBetween('publishedAt', [$start, $end]);
|
||||
}
|
||||
}
|
||||
|
||||
$total = $query->count();
|
||||
$rows = $query->page($page, $pageSize)->select()->toArray();
|
||||
|
||||
return paginate_response($rows, $total, $page, $pageSize);
|
||||
}
|
||||
|
||||
/** POST /api/v1/superadmin/soul-articles/:id/order */
|
||||
public function setOrder()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$id = (int) Request::param('id', 0);
|
||||
$order = (int) Request::post('recommendedOrder', 0);
|
||||
if ($id <= 0) return error('无效 id', 400);
|
||||
if ($order < 0) return error('排序值不合法', 400);
|
||||
$article = SoulArticleModel::find($id);
|
||||
if (!$article) return error('文章不存在', 404);
|
||||
if ((int) $article->isRecommended !== 1) {
|
||||
return error('仅推荐中的文章可调整排序', 400);
|
||||
}
|
||||
$article->recommendedOrder = $order;
|
||||
$article->save();
|
||||
return success(['id' => $id, 'recommendedOrder' => $order], '排序已更新');
|
||||
}
|
||||
|
||||
/** POST /api/v1/superadmin/soul-articles/reorder-normalize */
|
||||
public function normalizeOrder()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$rows = SoulArticleModel::where('isRecommended', 1)
|
||||
->order('recommendedOrder', 'asc')
|
||||
->order('publishedAt', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
$idx = 1;
|
||||
foreach ($rows as $row) {
|
||||
$m = SoulArticleModel::find((int) $row['id']);
|
||||
if (!$m) continue;
|
||||
$m->recommendedOrder = $idx;
|
||||
$m->save();
|
||||
$idx++;
|
||||
}
|
||||
return success(['count' => count($rows)], '推荐权重已归一化');
|
||||
}
|
||||
|
||||
/** POST /api/v1/superadmin/soul-articles/:id/recommend */
|
||||
public function recommend()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$id = (int) Request::param('id', 0);
|
||||
if ($id <= 0) return error('无效 id', 400);
|
||||
|
||||
$r = SoulArticleService::recommend($id);
|
||||
if (empty($r['ok'])) {
|
||||
return error($r['message'] ?? '操作失败', 400);
|
||||
}
|
||||
return success($r, $r['message']);
|
||||
}
|
||||
|
||||
/** POST /api/v1/superadmin/soul-articles/:id/delete */
|
||||
public function remove()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
$id = (int) Request::param('id', 0);
|
||||
if ($id <= 0) return error('无效 id', 400);
|
||||
$article = SoulArticleModel::find($id);
|
||||
if (!$article) return error('文章不存在', 404);
|
||||
$article->delete();
|
||||
return success(null, '已删除');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/superadmin/ai/health
|
||||
* AI 健康小条:各服务商可用 / 余额 / 最后检查时间
|
||||
*/
|
||||
public function health()
|
||||
{
|
||||
$this->ensureSuperadmin();
|
||||
|
||||
$rows = AiProviderModel::whereRaw('(visible IS NULL OR visible = 1)')
|
||||
->order('sortWeight', 'asc')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$list = [];
|
||||
foreach ($rows as $r) {
|
||||
$balance = isset($r['lastBalance']) ? (float) $r['lastBalance'] : null;
|
||||
$threshold = isset($r['balanceAlertThreshold']) ? (float) $r['balanceAlertThreshold'] : 0.0;
|
||||
$alertOn = (int) ($r['balanceAlertEnabled'] ?? 0) === 1;
|
||||
$hasKey = !empty($r['apiKey']);
|
||||
$enabled = (int) ($r['enabled'] ?? 0) === 1;
|
||||
|
||||
$status = 'unknown';
|
||||
if (!$enabled) {
|
||||
$status = 'disabled';
|
||||
} elseif (!$hasKey) {
|
||||
$status = 'no-key';
|
||||
} elseif ($alertOn && $balance !== null && $balance <= $threshold) {
|
||||
$status = 'low-balance';
|
||||
} elseif ($balance !== null) {
|
||||
$status = 'healthy';
|
||||
} else {
|
||||
$status = 'pending-check';
|
||||
}
|
||||
|
||||
$list[] = [
|
||||
'providerId' => $r['providerId'],
|
||||
'name' => $r['name'],
|
||||
'enabled' => $enabled,
|
||||
'hasKey' => $hasKey,
|
||||
'balance' => $balance,
|
||||
'currency' => $r['lastBalanceCurrency'] ?? 'CNY',
|
||||
'threshold' => $threshold,
|
||||
'balanceAlertEnabled' => $alertOn,
|
||||
'lastBalanceCheckedAt' => !empty($r['lastBalanceCheckedAt'])
|
||||
? date('Y-m-d H:i:s', (int) $r['lastBalanceCheckedAt'])
|
||||
: null,
|
||||
'sortWeight' => (int) ($r['sortWeight'] ?? 100),
|
||||
'status' => $status,
|
||||
];
|
||||
}
|
||||
|
||||
// 最近一次告警
|
||||
$lastAlert = Db::name('ai_balance_alerts')->order('alertedAt', 'desc')->find();
|
||||
return success([
|
||||
'providers' => $list,
|
||||
'lastAlert' => $lastAlert ? [
|
||||
'providerId' => $lastAlert['providerId'],
|
||||
'balance' => (float) $lastAlert['balance'],
|
||||
'threshold' => (float) $lastAlert['threshold'],
|
||||
'alertedAt' => date('Y-m-d H:i:s', (int) $lastAlert['alertedAt']),
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function ensureSuperadmin()
|
||||
{
|
||||
$user = $this->request->user ?? null;
|
||||
if (!$user || ($user['role'] ?? '') !== 'superadmin') {
|
||||
abort(403, '无权限访问');
|
||||
}
|
||||
}
|
||||
}
|
||||
29
api/app/model/AiConversation.php
Normal file
29
api/app/model/AiConversation.php
Normal 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',
|
||||
];
|
||||
}
|
||||
23
api/app/model/AiMessage.php
Normal file
23
api/app/model/AiMessage.php
Normal 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',
|
||||
];
|
||||
}
|
||||
@@ -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') ?: '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
12
api/app/model/AiReport.php
Normal file
12
api/app/model/AiReport.php
Normal 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';
|
||||
}
|
||||
@@ -61,6 +61,8 @@ class Enterprise extends Model
|
||||
'sbti' => true,
|
||||
'pdp' => true,
|
||||
'disc' => true,
|
||||
/** 详细测试列表中的「AI 性格测试」聚合入口 */
|
||||
'aiHub' => true,
|
||||
'distribution' => true,
|
||||
];
|
||||
}
|
||||
|
||||
16
api/app/model/ProfitSharingRule.php
Normal file
16
api/app/model/ProfitSharingRule.php
Normal 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',
|
||||
];
|
||||
}
|
||||
30
api/app/model/SoulArticle.php
Normal file
30
api/app/model/SoulArticle.php
Normal 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',
|
||||
];
|
||||
}
|
||||
80
api/database/migrations/add_ai_chat_and_soul_articles.sql
Normal file
80
api/database/migrations/add_ai_chat_and_soul_articles.sql
Normal file
@@ -0,0 +1,80 @@
|
||||
-- AI 聊天 + Soul 文章推荐 + 欠费预警字段
|
||||
-- 对应需求:ai功能202600417 -6.md
|
||||
-- 建议部署时用 source 一次性执行;前缀 mbti_ 按环境 .env DATABASE_PREFIX 调整
|
||||
|
||||
-- 1) ai_providers 新增排序权重(故障切换时可手动调优先级)
|
||||
ALTER TABLE `mbti_ai_providers`
|
||||
ADD COLUMN `sortWeight` INT NOT NULL DEFAULT 100 COMMENT '优先级,升序;欠费自动降到999' AFTER `visible`;
|
||||
|
||||
-- 2) AI 对话表
|
||||
CREATE TABLE IF NOT EXISTS `mbti_ai_conversations` (
|
||||
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
`userId` INT UNSIGNED NOT NULL,
|
||||
`title` VARCHAR(128) NOT NULL DEFAULT '',
|
||||
`mbtiType` VARCHAR(16) NOT NULL DEFAULT '',
|
||||
`providerId` VARCHAR(32) NOT NULL DEFAULT '',
|
||||
`lastMessageAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`messageCount` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`createdAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`updatedAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`deletedAt` INT UNSIGNED NULL,
|
||||
INDEX `idx_user` (`userId`, `updatedAt`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='神仙 AI 对话';
|
||||
|
||||
-- 3) AI 消息表
|
||||
CREATE TABLE IF NOT EXISTS `mbti_ai_messages` (
|
||||
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
`conversationId` INT UNSIGNED NOT NULL,
|
||||
`role` ENUM('system','user','assistant') NOT NULL,
|
||||
`content` MEDIUMTEXT NOT NULL,
|
||||
`tokensIn` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`tokensOut` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`providerId` VARCHAR(32) NOT NULL DEFAULT '',
|
||||
`isDegraded` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为降级兜底回答',
|
||||
`createdAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
INDEX `idx_conv` (`conversationId`, `id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='神仙 AI 消息流';
|
||||
|
||||
-- 4) Soul 采集文章表(一场 soul 创业实验 引流内容池)
|
||||
CREATE TABLE IF NOT EXISTS `mbti_soul_articles` (
|
||||
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
`sourceId` VARCHAR(64) NOT NULL COMMENT 'soul 侧文章 id',
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`cover` VARCHAR(512) NOT NULL DEFAULT '',
|
||||
`url` VARCHAR(512) NOT NULL,
|
||||
`summary` VARCHAR(512) NOT NULL DEFAULT '',
|
||||
`author` VARCHAR(64) NOT NULL DEFAULT '',
|
||||
`tag` VARCHAR(32) NOT NULL DEFAULT 'MBTI',
|
||||
`publishedAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`isRecommended` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`recommendedOrder` INT NOT NULL DEFAULT 0,
|
||||
`viewCount` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`createdAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`updatedAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`deletedAt` INT UNSIGNED NULL,
|
||||
UNIQUE KEY `uk_source` (`sourceId`),
|
||||
INDEX `idx_reco` (`isRecommended`, `recommendedOrder`, `publishedAt`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Soul 采集文章(MBTI 主题)';
|
||||
|
||||
-- 5) AI 使用限额(每日)—— 用户层面的限流记录
|
||||
CREATE TABLE IF NOT EXISTS `mbti_ai_usage_daily` (
|
||||
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
`userId` INT UNSIGNED NOT NULL,
|
||||
`dateStr` CHAR(10) NOT NULL COMMENT '格式 YYYY-MM-DD',
|
||||
`messageCount` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`createdAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`updatedAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
UNIQUE KEY `uk_user_date` (`userId`, `dateStr`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='神仙 AI 每日限流计数';
|
||||
|
||||
-- 6) AI 余额预警日志(去重同一阈值每天只发 1 次)
|
||||
CREATE TABLE IF NOT EXISTS `mbti_ai_balance_alerts` (
|
||||
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
`providerId` VARCHAR(32) NOT NULL,
|
||||
`balance` DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
`threshold` DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
`currency` VARCHAR(8) NOT NULL DEFAULT 'CNY',
|
||||
`alertedAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`dateStr` CHAR(10) NOT NULL,
|
||||
UNIQUE KEY `uk_provider_date` (`providerId`, `dateStr`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 欠费预警去重日志';
|
||||
12
api/database/migrations/add_cold_face.sql
Normal file
12
api/database/migrations/add_cold_face.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- 冷脸分析字段:扩展 user_profile,用于列表/详情展示
|
||||
-- 说明:
|
||||
-- coldFaceScore 0-100;<35=暖(warm), 35-65=中(neutral), >65=冷(cold)
|
||||
-- coldFaceLevel 字符串枚举 cold/neutral/warm
|
||||
-- coldFaceUpdatedAt 最近一次测评更新时间戳(秒)
|
||||
-- 执行: 在 phpMyAdmin 或 CLI 运行
|
||||
|
||||
ALTER TABLE `mbti_user_profile`
|
||||
ADD COLUMN `coldFaceScore` tinyint(3) UNSIGNED NULL DEFAULT NULL COMMENT '冷脸分(0-100)' AFTER `lastFaceResultId`,
|
||||
ADD COLUMN `coldFaceLevel` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '冷脸等级: cold/neutral/warm' AFTER `coldFaceScore`,
|
||||
ADD COLUMN `coldFaceUpdatedAt` int(11) NULL DEFAULT NULL COMMENT '冷脸更新时间(时间戳)' AFTER `coldFaceLevel`,
|
||||
ADD INDEX `idx_coldFaceLevel` (`coldFaceLevel`) USING BTREE;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 埋点表补充:userId + createdAt 复合索引(用于用户旅程、分享统计)
|
||||
-- 幂等写法:若索引已存在会报错,可忽略(或手工 SHOW INDEX 检查后再执行)
|
||||
ALTER TABLE `mbti_analytics_events`
|
||||
ADD INDEX `idx_user_created` (`userId`, `createdAt`);
|
||||
95
api/database/migrations/add_tabbar_and_ai_profit_sharing.sql
Normal file
95
api/database/migrations/add_tabbar_and_ai_profit_sharing.sql
Normal file
@@ -0,0 +1,95 @@
|
||||
-- =====================================================
|
||||
-- 神仙 AI v2 · TabBar 后台可配 + AI 深度报告 + 分账规则
|
||||
-- Date: 2026-04-17
|
||||
-- 生产库表前缀若为 mbti_,则下方表名以 mbti_ 为准
|
||||
-- =====================================================
|
||||
|
||||
-- ---------- 1) 小程序 TabBar 配置 ----------
|
||||
CREATE TABLE IF NOT EXISTS `mbti_mp_tabbar_items` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`sortOrder` INT NOT NULL DEFAULT 100 COMMENT '排序,小的在前',
|
||||
`pagePath` VARCHAR(128) NOT NULL COMMENT '目标页面 pages/xxx/xxx',
|
||||
`text` VARCHAR(32) NOT NULL COMMENT '底部文案',
|
||||
`iconKey` VARCHAR(32) NOT NULL DEFAULT 'home' COMMENT '图标key: home/camera/ai/profile',
|
||||
`visible` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1=显示 0=隐藏(审核模式)',
|
||||
`highlight` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=中间突出按钮(圆形)',
|
||||
`badgeKey` VARCHAR(32) NULL COMMENT '红点角标来源key(预留)',
|
||||
`createdAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`updatedAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_sort` (`sortOrder`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='小程序 TabBar 后台可配';
|
||||
|
||||
-- 初始 4 项(默认顺序:首页·拍摄·神仙AI·我)
|
||||
INSERT INTO `mbti_mp_tabbar_items` (sortOrder, pagePath, text, iconKey, highlight, visible, createdAt, updatedAt) VALUES
|
||||
(10, 'pages/index/index', '首页', 'home', 0, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(20, 'pages/index/camera', '拍摄', 'camera', 1, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(30, 'pages/ai-chat/index', '神仙AI', 'ai', 0, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
(40, 'pages/profile/index', '我', 'profile', 0, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
|
||||
|
||||
-- ---------- 2) 分账规则(知己 profit-sharing 思路) ----------
|
||||
CREATE TABLE IF NOT EXISTS `mbti_profit_sharing_rules` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`productType` VARCHAR(64) NOT NULL COMMENT 'ai_deep_report / mbti_report / face_analysis / default',
|
||||
`name` VARCHAR(128) NOT NULL,
|
||||
`receivers` JSON NOT NULL COMMENT '[{type,name,ratio,account?}]',
|
||||
`status` VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT 'active/disabled',
|
||||
`createdAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`updatedAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_product` (`productType`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单分账规则';
|
||||
|
||||
INSERT INTO `mbti_profit_sharing_rules` (productType, name, receivers, status, createdAt, updatedAt) VALUES
|
||||
('ai_deep_report', 'AI 深度画像报告',
|
||||
'[{"type":"platform","name":"平台","ratio":0.70},{"type":"distributor_l1","name":"一级分销","ratio":0.20},{"type":"distributor_l2","name":"二级分销","ratio":0.10}]',
|
||||
'active', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
|
||||
('default', '默认规则 · 平台 100%',
|
||||
'[{"type":"platform","name":"平台","ratio":1.00}]',
|
||||
'active', UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
|
||||
|
||||
-- ---------- 3) 分账记录 ----------
|
||||
CREATE TABLE IF NOT EXISTS `mbti_profit_sharing_records` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`orderSn` VARCHAR(64) NOT NULL COMMENT '订单号',
|
||||
`orderId` INT UNSIGNED NULL COMMENT '订单表id',
|
||||
`productType` VARCHAR(64) NOT NULL,
|
||||
`totalAmount` INT NOT NULL DEFAULT 0 COMMENT '订单总金额(分)',
|
||||
`details` JSON NOT NULL COMMENT '分账明细 [{receiverType,receiverName,amount,ratio,account,status}]',
|
||||
`status` VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/processing/completed/failed',
|
||||
`processedAt` INT UNSIGNED NULL,
|
||||
`createdAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`updatedAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_order_sn` (`orderSn`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单分账记录';
|
||||
|
||||
|
||||
-- ---------- 4) AI 深度报告(付费解锁阅读) ----------
|
||||
CREATE TABLE IF NOT EXISTS `mbti_ai_reports` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`userId` INT UNSIGNED NOT NULL,
|
||||
`conversationId` INT UNSIGNED NULL COMMENT '来源对话',
|
||||
`mbtiType` VARCHAR(8) NULL,
|
||||
`orderSn` VARCHAR(64) NULL COMMENT '对应订单号',
|
||||
`orderId` INT UNSIGNED NULL,
|
||||
`priceFen` INT NOT NULL DEFAULT 990 COMMENT '单价(分)',
|
||||
`status` VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending=未支付 paid=已支付未生成 generating=生成中 done=已完成 failed=失败',
|
||||
`title` VARCHAR(255) NULL,
|
||||
`summary` TEXT NULL COMMENT '免费预览的摘要片段',
|
||||
`content` MEDIUMTEXT NULL COMMENT '完整报告正文',
|
||||
`posterUrl` VARCHAR(512) NULL COMMENT '分享海报',
|
||||
`retryCount` INT NOT NULL DEFAULT 0,
|
||||
`lastError` VARCHAR(512) NULL,
|
||||
`paidAt` INT UNSIGNED NULL,
|
||||
`generatedAt` INT UNSIGNED NULL,
|
||||
`createdAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`updatedAt` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user` (`userId`),
|
||||
KEY `idx_order_sn` (`orderSn`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 深度画像报告';
|
||||
@@ -0,0 +1,24 @@
|
||||
-- =====================================================
|
||||
-- plan7 补丁:给 tabBar 配置加 iconUrl(可传图片 URL 覆盖 SVG)
|
||||
-- 给 soul_articles 自动推送时间戳
|
||||
-- Date: 2026-04-17
|
||||
-- =====================================================
|
||||
|
||||
ALTER TABLE `mbti_mp_tabbar_items`
|
||||
ADD COLUMN `iconUrl` VARCHAR(512) NULL AFTER `iconKey`
|
||||
COMMENT '图片图标 URL,若有则优先覆盖 iconKey 的 SVG';
|
||||
|
||||
-- 给"神仙AI"tab 默认用团队 logo 图
|
||||
UPDATE `mbti_mp_tabbar_items`
|
||||
SET `iconUrl` = '/images/mbti-team-image.png'
|
||||
WHERE `iconKey` = 'ai' AND (`iconUrl` IS NULL OR `iconUrl` = '');
|
||||
|
||||
-- Soul 自动采集:last sync 时间戳记录到 system_config
|
||||
INSERT INTO `mbti_system_config` (`key`, `value`, `description`, `createdAt`, `updatedAt`)
|
||||
SELECT 'soul_article_auto_sync',
|
||||
JSON_OBJECT('enabled', TRUE, 'intervalSec', 3600, 'limit', 10),
|
||||
'Soul 文章自动采集配置',
|
||||
UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `mbti_system_config` WHERE `key` = 'soul_article_auto_sync'
|
||||
);
|
||||
@@ -27,6 +27,14 @@ Route::group('api', function () {
|
||||
Route::post('analytics/events', 'api.Analytics/batch');
|
||||
// 好友打开分享卡片:凭 st 校验查看他人测试结果(无需登录)
|
||||
Route::get('test/share-detail', 'api.Test/shareDetail');
|
||||
// 神仙 AI · 推荐文章(无需登录也可拉取,首屏展示)
|
||||
Route::get('ai/articles/recommended', 'api.AiChat/recommendedArticles');
|
||||
Route::get('ai/articles/profile-teaser', 'api.AiChat/profileArticleTeaser');
|
||||
Route::post('ai/articles/:id/click', 'api.AiChat/articleClick');
|
||||
// 快捷问句:公开接口;若请求头带有效微信 token 则按用户 MBTI 个性化
|
||||
Route::get('ai/quick-questions', 'api.AiChat/quickQuestions');
|
||||
// 小程序 TabBar 动态配置(无需登录,启动拉取)
|
||||
Route::get('mp/tabbar', 'api.MpConfig/tabbar');
|
||||
})->middleware('cors');
|
||||
|
||||
// 前端需要认证的API路由
|
||||
@@ -81,6 +89,19 @@ Route::group('api', function () {
|
||||
Route::post('wechat/transfer/notify', 'api.WechatTransferNotify/notify')->middleware('cors');
|
||||
// 存客宝获客线索上报
|
||||
Route::post('crm/report', 'api.CrmReport/report');
|
||||
|
||||
// ==================== 神仙 AI(需要微信登录)====================
|
||||
Route::post('ai/chat', 'api.AiChat/chat');
|
||||
Route::get('ai/conversations', 'api.AiChat/conversations');
|
||||
Route::get('ai/conversations/:id/messages', 'api.AiChat/messages');
|
||||
Route::post('ai/transcribe', 'api.AiChat/transcribe');
|
||||
|
||||
// AI 深度画像报告
|
||||
Route::post('ai/report/create', 'api.AiReport/create');
|
||||
Route::get('ai/report/my-latest', 'api.AiReport/myLatest');
|
||||
Route::get('ai/report/:id', 'api.AiReport/show');
|
||||
Route::post('ai/report/:id/mark-paid-dev', 'api.AiReport/markPaidDev');
|
||||
Route::post('ai/report/:id/regenerate', 'api.AiReport/regenerate');
|
||||
})->middleware(['cors', 'auth']);
|
||||
|
||||
// ==================== 小程序API路由(匹配前端 /api 路径)====================
|
||||
@@ -295,6 +316,34 @@ Route::group('api/v1/superadmin', function () {
|
||||
// 小程序埋点统计(仅超管)
|
||||
Route::get('analytics/summary', 'superadmin.Analytics/summary');
|
||||
Route::get('analytics/events', 'superadmin.Analytics/events');
|
||||
Route::get('analytics/user-journey', 'superadmin.Analytics/userJourney');
|
||||
Route::get('analytics/share-stats', 'superadmin.Analytics/shareStats');
|
||||
Route::get('analytics/share-funnel', 'superadmin.Analytics/shareFunnel');
|
||||
|
||||
// Soul 文章采集与推荐(一场 soul 创业实验引流)
|
||||
Route::get('soul-articles/ai-chat-display', 'superadmin.SoulArticle/aiChatDisplayGet');
|
||||
Route::post('soul-articles/ai-chat-display', 'superadmin.SoulArticle/aiChatDisplaySave');
|
||||
Route::post('soul-articles/sync', 'superadmin.SoulArticle/sync');
|
||||
Route::post('soul-articles/:id/recommend', 'superadmin.SoulArticle/recommend');
|
||||
Route::post('soul-articles/:id/order', 'superadmin.SoulArticle/setOrder');
|
||||
Route::post('soul-articles/reorder-normalize', 'superadmin.SoulArticle/normalizeOrder');
|
||||
Route::post('soul-articles/:id/delete', 'superadmin.SoulArticle/remove');
|
||||
Route::get('soul-articles', 'superadmin.SoulArticle/index');
|
||||
|
||||
// 神仙 AI 健康与欠费监控
|
||||
Route::get('ai/health', 'superadmin.SoulArticle/health');
|
||||
Route::post('ai/balance-check', 'superadmin.AiMonitor/balanceCheck');
|
||||
|
||||
// 小程序 TabBar 后台可配
|
||||
Route::get('tabbar', 'superadmin.MpTabBar/index');
|
||||
Route::post('tabbar/save', 'superadmin.MpTabBar/save');
|
||||
Route::post('tabbar/reorder', 'superadmin.MpTabBar/reorder');
|
||||
Route::post('tabbar/:id/delete', 'superadmin.MpTabBar/remove');
|
||||
|
||||
// 分账规则
|
||||
Route::get('profit-rules', 'superadmin.ProfitRule/index');
|
||||
Route::post('profit-rules/save', 'superadmin.ProfitRule/save');
|
||||
Route::post('profit-rules/:id/toggle', 'superadmin.ProfitRule/toggle');
|
||||
})->middleware(['cors', 'auth', 'superadmin']);
|
||||
|
||||
// ==================== 兼容旧版路由(保留,逐步废弃)====================
|
||||
|
||||
Reference in New Issue
Block a user